diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 16270e6..5e39189 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -9,7 +9,7 @@ on: - master jobs: - test: + unit-tests: runs-on: ubuntu-latest steps: - name: Checkout code @@ -20,9 +20,31 @@ jobs: with: bun-version: latest + - name: Cache Bun dependencies + uses: actions/cache@v4 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }} + restore-keys: | + ${{ runner.os }}-bun- + - name: Install dependencies run: bun install + - name: Run unit tests + run: bun run test:run + + component-tests: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - name: Cache Bun dependencies uses: actions/cache@v4 with: @@ -31,5 +53,30 @@ jobs: restore-keys: | ${{ runner.os }}-bun- - - name: Run tests - run: bun run test:run + - name: Install dependencies + run: bun install + + - name: Cache Playwright browsers + uses: actions/cache@v4 + id: playwright-cache + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('**/bun.lock') }} + + - name: Install Playwright browsers + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: bun run playwright:install + + - name: Install Playwright system dependencies + run: bunx playwright install-deps + + - name: Run component tests + run: bun run test:ct + + - name: Upload test results + uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index 56e05f1..110def3 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,10 @@ yarn-error.log* *storybook.log /dist + +# Playwright +node_modules/ +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/README.md b/README.md index 2ed789d..6cba67e 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Or use `npm`, `yarn`, or `pnpm`. Whatever you like. ## 🧩 Components -### `TimeSlice` +### `Chrono` A smart, headless time range picker that speaks human. @@ -38,7 +38,7 @@ A smart, headless time range picker that speaks human. #### 🛠 Basic Usage ```tsx -import { TimeSlice } from '@bizarre/ui' +import { Chrono } from '@bizarre/ui' function MyComponent() { const handleConfirm = (range) => { @@ -46,19 +46,19 @@ function MyComponent() { } return ( - - - - + + + + 15 minutes - - 1 hour - 1 day - + + 1 hour + 1 day + 1 month - - - + + + ) } ``` diff --git a/bun.lock b/bun.lock index 06d299a..ae2fb33 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "bizarre/ui", @@ -8,13 +9,19 @@ "@radix-ui/react-compose-refs": "^1.1.2", "@radix-ui/react-context": "^1.1.2", "@radix-ui/react-dismissable-layer": "^1.1.9", - "@radix-ui/react-slot": "^1.2.2", + "@radix-ui/react-popover": "^1.1.14", + "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-use-controllable-state": "^1.2.2", "chrono-node": "^2.8.0", "date-fns": "^4.1.0", + "graphemer": "^1.4.0", "timezone-enum": "^1.0.4", }, "devDependencies": { + "@axe-core/playwright": "^4.11.0", + "@heroicons/react": "^2.2.0", + "@playwright/experimental-ct-react": "^1.54.2", + "@playwright/test": "^1.54.2", "@semantic-release/changelog": "^6.0.3", "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/git": "^10.0.1", @@ -29,11 +36,12 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^13.5.0", + "@types/node": "^24.2.1", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", "@typescript-eslint/eslint-plugin": "^8.32.0", "@typescript-eslint/parser": "^8.32.0", - "@vitejs/plugin-react-swc": "^3.9.0", + "@vitejs/plugin-react-swc": "^4.0.0", "@vitest/ui": "^3.1.3", "ajv": "^8.17.1", "autoprefixer": "^10.4.21", @@ -53,13 +61,14 @@ "storybook": "^8.6.12", "tailwindcss": "^4.1.5", "typescript": "^5.3.3", + "typescript-eslint": "^8.39.1", "vite": "^6.3.5", "vite-plugin-dts": "^4.5.3", "vitest": "^3.1.3", }, "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", }, }, }, @@ -72,33 +81,43 @@ "@asamuzakjp/css-color": ["@asamuzakjp/css-color@3.1.7", "", { "dependencies": { "@csstools/css-calc": "^2.1.3", "@csstools/css-color-parser": "^3.0.9", "@csstools/css-parser-algorithms": "^3.0.4", "@csstools/css-tokenizer": "^3.0.3", "lru-cache": "^10.4.3" } }, "sha512-Ok5fYhtwdyJQmU1PpEv6Si7Y+A4cYb8yNM9oiIJC9TzXPMuN9fvdonKJqcnz9TbFqV6bQ8z0giRq0iaOpGZV2g=="], + "@axe-core/playwright": ["@axe-core/playwright@4.11.0", "", { "dependencies": { "axe-core": "~4.11.0" }, "peerDependencies": { "playwright-core": ">= 1.0.0" } }, "sha512-70vBT/Ylqpm65RQz2iCG2o0JJCEG/WCNyefTr2xcOcr1CoSee60gNQYUMZZ7YukoKkFLv26I/jjlsvwwp532oQ=="], + "@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], - "@babel/compat-data": ["@babel/compat-data@7.26.8", "", {}, "sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ=="], + "@babel/compat-data": ["@babel/compat-data@7.28.0", "", {}, "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw=="], + + "@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="], - "@babel/core": ["@babel/core@7.26.9", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.2", "@babel/generator": "^7.26.9", "@babel/helper-compilation-targets": "^7.26.5", "@babel/helper-module-transforms": "^7.26.0", "@babel/helpers": "^7.26.9", "@babel/parser": "^7.26.9", "@babel/template": "^7.26.9", "@babel/traverse": "^7.26.9", "@babel/types": "^7.26.9", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-lWBYIrF7qK5+GjY5Uy+/hEgp8OJWOD/rpy74GplYRhEauvbHDeFB8t5hPOZxCZ0Oxf4Cc36tK51/l3ymJysrKw=="], + "@babel/generator": ["@babel/generator@7.28.0", "", { "dependencies": { "@babel/parser": "^7.28.0", "@babel/types": "^7.28.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg=="], - "@babel/generator": ["@babel/generator@7.26.9", "", { "dependencies": { "@babel/parser": "^7.26.9", "@babel/types": "^7.26.9", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" } }, "sha512-kEWdzjOAUMW4hAyrzJ0ZaTOu9OmpyDIQicIh0zg0EEcEkYXZb2TjtBhnHi2ViX7PKwZqF4xwqfAm299/QMP3lg=="], + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.27.2", "", { "dependencies": { "@babel/compat-data": "^7.27.2", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ=="], - "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.26.5", "", { "dependencies": { "@babel/compat-data": "^7.26.5", "@babel/helper-validator-option": "^7.25.9", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-IXuyn5EkouFJscIDuFF5EsiSolseme1s0CZB+QxVugqJLYmKdxI1VfIBOst0SUu4rnk2Z7kqTwmoO1lp3HIfnA=="], + "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], - "@babel/helper-module-imports": ["@babel/helper-module-imports@7.25.9", "", { "dependencies": { "@babel/traverse": "^7.25.9", "@babel/types": "^7.25.9" } }, "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw=="], + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="], - "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.26.0", "", { "dependencies": { "@babel/helper-module-imports": "^7.25.9", "@babel/helper-validator-identifier": "^7.25.9", "@babel/traverse": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw=="], + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.27.3", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", "@babel/traverse": "^7.27.3" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], "@babel/helper-string-parser": ["@babel/helper-string-parser@7.25.9", "", {}, "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA=="], "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="], - "@babel/helper-validator-option": ["@babel/helper-validator-option@7.25.9", "", {}, "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw=="], + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], + + "@babel/helpers": ["@babel/helpers@7.28.2", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.28.2" } }, "sha512-/V9771t+EgXz62aCcyofnQhGM8DQACbRhvzKFsXKC9QM+5MadF8ZmIm0crDMaz3+o0h0zXfJnd4EhbYbxsrcFw=="], - "@babel/helpers": ["@babel/helpers@7.26.9", "", { "dependencies": { "@babel/template": "^7.26.9", "@babel/types": "^7.26.9" } }, "sha512-Mz/4+y8udxBKdmzt/UjPACs4G3j5SshJJEFFKxlCGPydG4JAHXxjWjAwjd09tf6oINvl1VfMJo+nB7H2YKQ0dA=="], + "@babel/parser": ["@babel/parser@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.0" }, "bin": "./bin/babel-parser.js" }, "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g=="], - "@babel/parser": ["@babel/parser@7.26.9", "", { "dependencies": { "@babel/types": "^7.26.9" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A=="], + "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="], + + "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], "@babel/runtime": ["@babel/runtime@7.26.9", "", { "dependencies": { "regenerator-runtime": "^0.14.0" } }, "sha512-aA63XwOkcl4xxQa3HjPMqOP6LiK0ZDv3mUPYEFXkpHbaFjtGggE1A61FjFzJnB+p7/oy2gA8E+rcBNl/zC1tMg=="], - "@babel/template": ["@babel/template@7.26.9", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "@babel/parser": "^7.26.9", "@babel/types": "^7.26.9" } }, "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA=="], + "@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="], "@babel/traverse": ["@babel/traverse@7.26.9", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "@babel/generator": "^7.26.9", "@babel/parser": "^7.26.9", "@babel/template": "^7.26.9", "@babel/types": "^7.26.9", "debug": "^4.3.1", "globals": "^11.1.0" } }, "sha512-ZYW7L+pL8ahU5fXmNbPF+iZFHCv5scFak7MZ9bwaRPLUhHh7QQEMjZUg0HevihoqCM5iSYHN61EyCoZvqC+bxg=="], @@ -184,6 +203,14 @@ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.2.8", "", { "dependencies": { "@eslint/core": "^0.13.0", "levn": "^0.4.1" } }, "sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA=="], + "@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.7.3", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, "sha512-uZA413QEpNuhtb3/iIKoYMSK07keHPYeXF02Zhd6e213j+d1NamLix/mCLxBUDW/Gx52sPH2m+chlUsyaBs/Ag=="], + + "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.5", "", { "dependencies": { "@floating-ui/dom": "^1.7.3" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-HDO/1/1oH9fjj4eLgegrlH3dklZpHtUYYFiVwMUwfGvk9jWDRWqkklA2/NFScknrcNSspbV868WjXORvreDX+Q=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], + "@formatjs/ecma402-abstract": ["@formatjs/ecma402-abstract@2.3.4", "", { "dependencies": { "@formatjs/fast-memoize": "2.2.7", "@formatjs/intl-localematcher": "0.6.1", "decimal.js": "^10.4.3", "tslib": "^2.8.0" } }, "sha512-qrycXDeaORzIqNhBOx0btnhpD1c+/qFIHAN9znofuMJX6QBwtbrmlpWfD4oiUUD2vJUOIYFA/gYtg2KAMGG7sA=="], "@formatjs/fast-memoize": ["@formatjs/fast-memoize@2.2.7", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ=="], @@ -192,6 +219,8 @@ "@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.6.1", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-ePEgLgVCqi2BBFnTMWPfIghu6FkbZnnBVhO2sSxvLfrdFw7wCHAHiDoM2h4NRgjbaY7+B7HgOLZGkK187pZTZg=="], + "@heroicons/react": ["@heroicons/react@2.2.0", "", { "peerDependencies": { "react": ">= 16 || ^19.0.0-rc" } }, "sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ=="], + "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], "@humanfs/node": ["@humanfs/node@0.16.6", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.3.0" } }, "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw=="], @@ -258,6 +287,12 @@ "@pkgr/core": ["@pkgr/core@0.2.4", "", {}, "sha512-ROFF39F6ZrnzSUEmQQZUar0Jt4xVoP9WnDRdWwF4NNcXs3xBTLgBUDoOwW141y1jP+S8nahIbdxbFC7IShw9Iw=="], + "@playwright/experimental-ct-core": ["@playwright/experimental-ct-core@1.54.2", "", { "dependencies": { "playwright": "1.54.2", "playwright-core": "1.54.2", "vite": "^6.3.4" } }, "sha512-W4XXNJEsCtuHP8Rm3XoQQraljvk+1yK1aqQnJ5/G601FHVWHFoyq+wkplWxlggmFVNWAK4ReB9VlZ3xkgkqFMg=="], + + "@playwright/experimental-ct-react": ["@playwright/experimental-ct-react@1.54.2", "", { "dependencies": { "@playwright/experimental-ct-core": "1.54.2", "@vitejs/plugin-react": "^4.2.1" }, "bin": { "playwright": "cli.js" } }, "sha512-W12fcW0jB2DgMkuZqQNazuWRPCX9ySsGzvEUeBpxpv2kora2f1bD0Lm1mRM7tXD9l7TeclAAsP59aamDKITe6w=="], + + "@playwright/test": ["@playwright/test@1.54.2", "", { "dependencies": { "playwright": "1.54.2" }, "bin": { "playwright": "cli.js" } }, "sha512-A+znathYxPf+72riFd1r1ovOLqsIIB0jKIoPjyK2kqEIe30/6jF6BC7QNluHuwUmsD2tv1XZVugN8GqfTMOxsA=="], + "@pnpm/config.env-replace": ["@pnpm/config.env-replace@1.1.0", "", {}, "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w=="], "@pnpm/network.ca-file": ["@pnpm/network.ca-file@1.0.2", "", { "dependencies": { "graceful-fs": "4.2.10" } }, "sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA=="], @@ -268,15 +303,31 @@ "@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], + "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], + "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.9", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.2", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-way197PiTvNp+WBP7svMJasHl+vibhWGQDb6Mgf5mhEWJkgb85z7Lfl9TUdkqpWsf8GRNmoopx9ZxCyDzmgRMQ=="], + "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA=="], + + "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="], + + "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], + + "@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ODz16+1iIbGUfFEfKx2HTPKizg2MN39uIOV8MXeHnmdd3i/N9Wt7vU46wbHsqA0xoaQyXVcs0KIlBdOA2Y95bw=="], + + "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.7", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ=="], + + "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], + + "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA=="], + "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.2", "", { "dependencies": { "@radix-ui/react-slot": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-uHa+l/lKfxuDD2zjN/0peM/RhhSmRjr5YWdk/37EnSv1nJ88uvG85DPexSm8HdFQROd2VdERJ6ynXbkCFi+APw=="], - "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-y7TBO4xN4Y94FvcWIOIh18fM4R1A8S4q1jhoz4PNzOoHsFcN8pogcFmZrTYAm4F9VRUrWP/Mw7xSKybIeRI+CQ=="], + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], @@ -288,6 +339,14 @@ "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], + "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="], + + "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="], + + "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.30", "", {}, "sha512-whXaSoNUFiyDAjkUF8OBpOm77Szdbk5lGNqFe6CbVbJFrhCCPinCbRA3NjawwlNHla1No7xvXXh+CpSxnPfUEw=="], + "@rollup/pluginutils": ["@rollup/pluginutils@5.1.4", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.40.2", "", { "os": "android", "cpu": "arm" }, "sha512-JkdNEq+DFxZfUwxvB58tHMHBHVgX23ew41g1OQinthJ+ryhdRk67O31S7sYw8u2lTjHUPFxwar07BBt1KHp/hg=="], @@ -404,31 +463,31 @@ "@storybook/theming": ["@storybook/theming@8.6.12", "", { "peerDependencies": { "storybook": "^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0" } }, "sha512-6VjZg8HJ2Op7+KV7ihJpYrDnFtd9D1jrQnUS8LckcpuBXrIEbaut5+34ObY8ssQnSqkk2GwIZBBBQYQBCVvkOw=="], - "@swc/core": ["@swc/core@1.11.24", "", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.21" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.11.24", "@swc/core-darwin-x64": "1.11.24", "@swc/core-linux-arm-gnueabihf": "1.11.24", "@swc/core-linux-arm64-gnu": "1.11.24", "@swc/core-linux-arm64-musl": "1.11.24", "@swc/core-linux-x64-gnu": "1.11.24", "@swc/core-linux-x64-musl": "1.11.24", "@swc/core-win32-arm64-msvc": "1.11.24", "@swc/core-win32-ia32-msvc": "1.11.24", "@swc/core-win32-x64-msvc": "1.11.24" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" }, "optionalPeers": ["@swc/helpers"] }, "sha512-MaQEIpfcEMzx3VWWopbofKJvaraqmL6HbLlw2bFZ7qYqYw3rkhM0cQVEgyzbHtTWwCwPMFZSC2DUbhlZgrMfLg=="], + "@swc/core": ["@swc/core@1.13.3", "", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.23" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.13.3", "@swc/core-darwin-x64": "1.13.3", "@swc/core-linux-arm-gnueabihf": "1.13.3", "@swc/core-linux-arm64-gnu": "1.13.3", "@swc/core-linux-arm64-musl": "1.13.3", "@swc/core-linux-x64-gnu": "1.13.3", "@swc/core-linux-x64-musl": "1.13.3", "@swc/core-win32-arm64-msvc": "1.13.3", "@swc/core-win32-ia32-msvc": "1.13.3", "@swc/core-win32-x64-msvc": "1.13.3" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" }, "optionalPeers": ["@swc/helpers"] }, "sha512-ZaDETVWnm6FE0fc+c2UE8MHYVS3Fe91o5vkmGfgwGXFbxYvAjKSqxM/j4cRc9T7VZNSJjriXq58XkfCp3Y6f+w=="], - "@swc/core-darwin-arm64": ["@swc/core-darwin-arm64@1.11.24", "", { "os": "darwin", "cpu": "arm64" }, "sha512-dhtVj0PC1APOF4fl5qT2neGjRLgHAAYfiVP8poJelhzhB/318bO+QCFWAiimcDoyMgpCXOhTp757gnoJJrheWA=="], + "@swc/core-darwin-arm64": ["@swc/core-darwin-arm64@1.13.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ux0Ws4pSpBTqbDS9GlVP354MekB1DwYlbxXU3VhnDr4GBcCOimpocx62x7cFJkSpEBF8bmX8+/TTCGKh4PbyXw=="], - "@swc/core-darwin-x64": ["@swc/core-darwin-x64@1.11.24", "", { "os": "darwin", "cpu": "x64" }, "sha512-H/3cPs8uxcj2Fe3SoLlofN5JG6Ny5bl8DuZ6Yc2wr7gQFBmyBkbZEz+sPVgsID7IXuz7vTP95kMm1VL74SO5AQ=="], + "@swc/core-darwin-x64": ["@swc/core-darwin-x64@1.13.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-p0X6yhxmNUOMZrbeZ3ZNsPige8lSlSe1llllXvpCLkKKxN/k5vZt1sULoq6Nj4eQ7KeHQVm81/+AwKZyf/e0TA=="], - "@swc/core-linux-arm-gnueabihf": ["@swc/core-linux-arm-gnueabihf@1.11.24", "", { "os": "linux", "cpu": "arm" }, "sha512-PHJgWEpCsLo/NGj+A2lXZ2mgGjsr96ULNW3+T3Bj2KTc8XtMUkE8tmY2Da20ItZOvPNC/69KroU7edyo1Flfbw=="], + "@swc/core-linux-arm-gnueabihf": ["@swc/core-linux-arm-gnueabihf@1.13.3", "", { "os": "linux", "cpu": "arm" }, "sha512-OmDoiexL2fVWvQTCtoh0xHMyEkZweQAlh4dRyvl8ugqIPEVARSYtaj55TBMUJIP44mSUOJ5tytjzhn2KFxFcBA=="], - "@swc/core-linux-arm64-gnu": ["@swc/core-linux-arm64-gnu@1.11.24", "", { "os": "linux", "cpu": "arm64" }, "sha512-C2FJb08+n5SD4CYWCTZx1uR88BN41ZieoHvI8A55hfVf2woT8+6ZiBzt74qW2g+ntZ535Jts5VwXAKdu41HpBg=="], + "@swc/core-linux-arm64-gnu": ["@swc/core-linux-arm64-gnu@1.13.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-STfKku3QfnuUj6k3g9ld4vwhtgCGYIFQmsGPPgT9MK/dI3Lwnpe5Gs5t1inoUIoGNP8sIOLlBB4HV4MmBjQuhw=="], - "@swc/core-linux-arm64-musl": ["@swc/core-linux-arm64-musl@1.11.24", "", { "os": "linux", "cpu": "arm64" }, "sha512-ypXLIdszRo0re7PNNaXN0+2lD454G8l9LPK/rbfRXnhLWDBPURxzKlLlU/YGd2zP98wPcVooMmegRSNOKfvErw=="], + "@swc/core-linux-arm64-musl": ["@swc/core-linux-arm64-musl@1.13.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-bc+CXYlFc1t8pv9yZJGus372ldzOVscBl7encUBlU1m/Sig0+NDJLz6cXXRcFyl6ABNOApWeR4Yl7iUWx6C8og=="], - "@swc/core-linux-x64-gnu": ["@swc/core-linux-x64-gnu@1.11.24", "", { "os": "linux", "cpu": "x64" }, "sha512-IM7d+STVZD48zxcgo69L0yYptfhaaE9cMZ+9OoMxirNafhKKXwoZuufol1+alEFKc+Wbwp+aUPe/DeWC/Lh3dg=="], + "@swc/core-linux-x64-gnu": ["@swc/core-linux-x64-gnu@1.13.3", "", { "os": "linux", "cpu": "x64" }, "sha512-dFXoa0TEhohrKcxn/54YKs1iwNeW6tUkHJgXW33H381SvjKFUV53WR231jh1sWVJETjA3vsAwxKwR23s7UCmUA=="], - "@swc/core-linux-x64-musl": ["@swc/core-linux-x64-musl@1.11.24", "", { "os": "linux", "cpu": "x64" }, "sha512-DZByJaMVzSfjQKKQn3cqSeqwy6lpMaQDQQ4HPlch9FWtDx/dLcpdIhxssqZXcR2rhaQVIaRQsCqwV6orSDGAGw=="], + "@swc/core-linux-x64-musl": ["@swc/core-linux-x64-musl@1.13.3", "", { "os": "linux", "cpu": "x64" }, "sha512-ieyjisLB+ldexiE/yD8uomaZuZIbTc8tjquYln9Quh5ykOBY7LpJJYBWvWtm1g3pHv6AXlBI8Jay7Fffb6aLfA=="], - "@swc/core-win32-arm64-msvc": ["@swc/core-win32-arm64-msvc@1.11.24", "", { "os": "win32", "cpu": "arm64" }, "sha512-Q64Ytn23y9aVDKN5iryFi8mRgyHw3/kyjTjT4qFCa8AEb5sGUuSj//AUZ6c0J7hQKMHlg9do5Etvoe61V98/JQ=="], + "@swc/core-win32-arm64-msvc": ["@swc/core-win32-arm64-msvc@1.13.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-elTQpnaX5vESSbhCEgcwXjpMsnUbqqHfEpB7ewpkAsLzKEXZaK67ihSRYAuAx6ewRQTo7DS5iTT6X5aQD3MzMw=="], - "@swc/core-win32-ia32-msvc": ["@swc/core-win32-ia32-msvc@1.11.24", "", { "os": "win32", "cpu": "ia32" }, "sha512-9pKLIisE/Hh2vJhGIPvSoTK4uBSPxNVyXHmOrtdDot4E1FUUI74Vi8tFdlwNbaj8/vusVnb8xPXsxF1uB0VgiQ=="], + "@swc/core-win32-ia32-msvc": ["@swc/core-win32-ia32-msvc@1.13.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-nvehQVEOdI1BleJpuUgPLrclJ0TzbEMc+MarXDmmiRFwEUGqj+pnfkTSb7RZyS1puU74IXdK/YhTirHurtbI9w=="], - "@swc/core-win32-x64-msvc": ["@swc/core-win32-x64-msvc@1.11.24", "", { "os": "win32", "cpu": "x64" }, "sha512-sybnXtOsdB+XvzVFlBVGgRHLqp3yRpHK7CrmpuDKszhj/QhmsaZzY/GHSeALlMtLup13M0gqbcQvsTNlAHTg3w=="], + "@swc/core-win32-x64-msvc": ["@swc/core-win32-x64-msvc@1.13.3", "", { "os": "win32", "cpu": "x64" }, "sha512-A+JSKGkRbPLVV2Kwx8TaDAV0yXIXm/gc8m98hSkVDGlPBBmydgzNdWy3X7HTUBM7IDk7YlWE7w2+RUGjdgpTmg=="], "@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="], - "@swc/types": ["@swc/types@0.1.21", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-2YEtj5HJVbKivud9N4bpPBAyZhj4S2Ipe5LkUG94alTpr7in/GU/EARgPAd3BwU+YOmFVJC2+kjqhGRi3r0ZpQ=="], + "@swc/types": ["@swc/types@0.1.24", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-tjTMh3V4vAORHtdTprLlfoMptu1WfTZG9Rsca6yOKyNYsRr+MUXutKmliB17orgSZk5DpnDxs8GUdd/qwYxOng=="], "@tailwindcss/node": ["@tailwindcss/node@4.1.5", "", { "dependencies": { "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "lightningcss": "1.29.2", "tailwindcss": "4.1.5" } }, "sha512-CBhSWo0vLnWhXIvpD0qsPephiaUYfHUX3U9anwDaHZAeuGpTiB3XmsxPAN6qX7bFhipyGBqOa1QYQVVhkOUGxg=="], @@ -488,6 +547,8 @@ "@types/mdx": ["@types/mdx@2.0.13", "", {}, "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw=="], + "@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="], + "@types/normalize-package-data": ["@types/normalize-package-data@2.4.4", "", {}, "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA=="], "@types/react": ["@types/react@19.0.10", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g=="], @@ -502,8 +563,12 @@ "@typescript-eslint/parser": ["@typescript-eslint/parser@8.32.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.32.0", "@typescript-eslint/types": "8.32.0", "@typescript-eslint/typescript-estree": "8.32.0", "@typescript-eslint/visitor-keys": "8.32.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-B2MdzyWxCE2+SqiZHAjPphft+/2x2FlO9YBx7eKE1BCb+rqBlQdhtAEhzIEdozHd55DXPmxBdpMygFJjfjjA9A=="], + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.39.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.39.1", "@typescript-eslint/types": "^8.39.1", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-8fZxek3ONTwBu9ptw5nCKqZOSkXshZB7uAxuFF0J/wTMkKydjXCzqqga7MlFMpHi9DoG4BadhmTkITBcg8Aybw=="], + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.32.0", "", { "dependencies": { "@typescript-eslint/types": "8.32.0", "@typescript-eslint/visitor-keys": "8.32.0" } }, "sha512-jc/4IxGNedXkmG4mx4nJTILb6TMjL66D41vyeaPWvDUmeYQzF3lKtN15WsAeTr65ce4mPxwopPSo1yUUAWw0hQ=="], + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.39.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-ePUPGVtTMR8XMU2Hee8kD0Pu4NDE1CN9Q1sxGSGd/mbOtGZDM7pnhXNJnzW63zk/q+Z54zVzj44HtwXln5CvHA=="], + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.32.0", "", { "dependencies": { "@typescript-eslint/typescript-estree": "8.32.0", "@typescript-eslint/utils": "8.32.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-t2vouuYQKEKSLtJaa5bB4jHeha2HJczQ6E5IXPDPgIty9EqcJxpr1QHQ86YyIPwDwxvUmLfP2YADQ5ZY4qddZg=="], "@typescript-eslint/types": ["@typescript-eslint/types@8.32.0", "", {}, "sha512-O5Id6tGadAZEMThM6L9HmVf5hQUXNSxLVKeGJYWNhhVseps/0LddMkp7//VDkzwJ69lPL0UmZdcZwggj9akJaA=="], @@ -514,7 +579,9 @@ "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.32.0", "", { "dependencies": { "@typescript-eslint/types": "8.32.0", "eslint-visitor-keys": "^4.2.0" } }, "sha512-1rYQTCLFFzOI5Nl0c8LUpJT8HxpwVRn9E4CkMsYfuN6ctmQqExjSTzzSk0Tz2apmXy7WU6/6fyaZVVA/thPN+w=="], - "@vitejs/plugin-react-swc": ["@vitejs/plugin-react-swc@3.9.0", "", { "dependencies": { "@swc/core": "^1.11.21" }, "peerDependencies": { "vite": "^4 || ^5 || ^6" } }, "sha512-jYFUSXhwMCYsh/aQTgSGLIN3Foz5wMbH9ahb0Zva//UzwZYbMiZd7oT3AU9jHT9DLswYDswsRwPU9jVF3yA48Q=="], + "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], + + "@vitejs/plugin-react-swc": ["@vitejs/plugin-react-swc@4.0.0", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-beta.30", "@swc/core": "^1.13.2" }, "peerDependencies": { "vite": "^4 || ^5 || ^6 || ^7" } }, "sha512-4A1dThI578v07mpG4M+ziNn6lmlMlhtVCheL+2WLvClnLvEULi8rpAZThn2oEKn3GtFXFTOeko6eLRhx2V2kgA=="], "@vitest/expect": ["@vitest/expect@3.1.3", "", { "dependencies": { "@vitest/spy": "3.1.3", "@vitest/utils": "3.1.3", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-7FTQQuuLKmN1Ig/h+h/GO+44Q1IlglPlR2es4ab7Yvfx+Uk5xsv+Ykk+MEt/M2Yn/xGmzaLKxGw2lgy2bwuYqg=="], @@ -578,6 +645,8 @@ "argv-formatter": ["argv-formatter@1.0.0", "", {}, "sha512-F2+Hkm9xFaRg+GkaNnbwXNDV5O6pnCFEmqyhvfC/Ic5LbgOWjJh3L+mN/s91rxVL3znE7DYVpW0GJFT+4YBgWw=="], + "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], + "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], "array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="], @@ -606,6 +675,8 @@ "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], + "axe-core": ["axe-core@4.11.1", "", {}, "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A=="], + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "before-after-hook": ["before-after-hook@3.0.2", "", {}, "sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A=="], @@ -750,6 +821,8 @@ "detect-libc": ["detect-libc@2.0.4", "", {}, "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA=="], + "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], + "dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="], "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], @@ -932,6 +1005,8 @@ "get-intrinsic": ["get-intrinsic@1.2.7", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "function-bind": "^1.1.2", "get-proto": "^1.0.0", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA=="], + "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], "get-stream": ["get-stream@7.0.1", "", {}, "sha512-3M8C1EOFN6r8AMUhwUAACIoXZJEOufDU5+0gFFN5uNs6XYOralD2Pqkl7m046va6x77FwposWXbAhPPIOus7mQ=="], @@ -1366,6 +1441,10 @@ "pkg-types": ["pkg-types@2.1.0", "", { "dependencies": { "confbox": "^0.2.1", "exsolve": "^1.0.1", "pathe": "^2.0.3" } }, "sha512-wmJwA+8ihJixSoHKxZJRBQG1oY8Yr9pGLzRmSsNms0iNWyHHAlZCa7mmKiFR10YPZuz/2k169JiS/inOjBCZ2A=="], + "playwright": ["playwright@1.54.2", "", { "dependencies": { "playwright-core": "1.54.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-Hu/BMoA1NAdRUuulyvQC0pEqZ4vQbGfn8f7wPXcnqQmM+zct9UliKxsIkLNmz/ku7LElUNqmaiv1TG/aL5ACsw=="], + + "playwright-core": ["playwright-core@1.54.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-n5r4HFbMmWsB4twG7tJLDN9gmBUeSPcsBZiWSE4DnYz9mJMAFqr2ID7+eGC9kpEnxExJ1epttwR59LEWCk8mtA=="], + "polished": ["polished@4.3.1", "", { "dependencies": { "@babel/runtime": "^7.17.8" } }, "sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA=="], "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], @@ -1418,6 +1497,14 @@ "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], + + "react-remove-scroll": ["react-remove-scroll@2.7.1", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA=="], + + "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], + + "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], + "read-package-up": ["read-package-up@11.0.0", "", { "dependencies": { "find-up-simple": "^1.0.0", "read-pkg": "^9.0.0", "type-fest": "^4.6.0" } }, "sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ=="], "read-pkg": ["read-pkg@9.0.1", "", { "dependencies": { "@types/normalize-package-data": "^2.4.3", "normalize-package-data": "^6.0.0", "parse-json": "^8.0.0", "type-fest": "^4.6.0", "unicorn-magic": "^0.1.0" } }, "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA=="], @@ -1662,12 +1749,16 @@ "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], + "typescript-eslint": ["typescript-eslint@8.39.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.39.1", "@typescript-eslint/parser": "8.39.1", "@typescript-eslint/typescript-estree": "8.39.1", "@typescript-eslint/utils": "8.39.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-GDUv6/NDYngUlNvwaHM1RamYftxf782IyEDbdj3SeaIHHv8fNQVRC++fITT7kUJV/5rIA/tkoRSSskt6osEfqg=="], + "ufo": ["ufo@1.6.1", "", {}, "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA=="], "uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="], "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], + "undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], + "unicode-emoji-modifier-base": ["unicode-emoji-modifier-base@1.0.0", "", {}, "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g=="], "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], @@ -1688,6 +1779,10 @@ "url-join": ["url-join@5.0.0", "", {}, "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA=="], + "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], + + "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], + "util": ["util@0.12.5", "", { "dependencies": { "inherits": "^2.0.3", "is-arguments": "^1.0.4", "is-generator-function": "^1.0.7", "is-typed-array": "^1.1.3", "which-typed-array": "^1.1.2" } }, "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA=="], "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], @@ -1768,20 +1863,42 @@ "zod-to-json-schema": ["zod-to-json-schema@3.24.5", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g=="], - "@babel/core/@babel/code-frame": ["@babel/code-frame@7.26.2", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.25.9", "js-tokens": "^4.0.0", "picocolors": "^1.0.0" } }, "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ=="], + "@babel/core/@babel/traverse": ["@babel/traverse@7.28.0", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/types": "^7.28.0", "debug": "^4.3.1" } }, "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg=="], + + "@babel/core/@babel/types": ["@babel/types@7.28.2", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ=="], "@babel/core/semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@babel/generator/@babel/types": ["@babel/types@7.28.2", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ=="], + + "@babel/generator/@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@babel/generator/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.30", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q=="], + "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@babel/helper-module-transforms/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.25.9", "", {}, "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ=="], + "@babel/helper-module-imports/@babel/traverse": ["@babel/traverse@7.28.0", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/types": "^7.28.0", "debug": "^4.3.1" } }, "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg=="], + + "@babel/helper-module-imports/@babel/types": ["@babel/types@7.28.2", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ=="], + + "@babel/helper-module-transforms/@babel/traverse": ["@babel/traverse@7.28.0", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/types": "^7.28.0", "debug": "^4.3.1" } }, "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg=="], + + "@babel/helpers/@babel/types": ["@babel/types@7.28.2", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ=="], + + "@babel/parser/@babel/types": ["@babel/types@7.28.2", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ=="], - "@babel/template/@babel/code-frame": ["@babel/code-frame@7.26.2", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.25.9", "js-tokens": "^4.0.0", "picocolors": "^1.0.0" } }, "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ=="], + "@babel/template/@babel/types": ["@babel/types@7.28.2", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ=="], "@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.26.2", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.25.9", "js-tokens": "^4.0.0", "picocolors": "^1.0.0" } }, "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ=="], + "@babel/traverse/@babel/generator": ["@babel/generator@7.26.9", "", { "dependencies": { "@babel/parser": "^7.26.9", "@babel/types": "^7.26.9", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" } }, "sha512-kEWdzjOAUMW4hAyrzJ0ZaTOu9OmpyDIQicIh0zg0EEcEkYXZb2TjtBhnHi2ViX7PKwZqF4xwqfAm299/QMP3lg=="], + + "@babel/traverse/@babel/parser": ["@babel/parser@7.26.9", "", { "dependencies": { "@babel/types": "^7.26.9" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A=="], + + "@babel/traverse/@babel/template": ["@babel/template@7.26.9", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "@babel/parser": "^7.26.9", "@babel/types": "^7.26.9" } }, "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA=="], + "@babel/traverse/globals": ["globals@11.12.0", "", {}, "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA=="], "@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.25.9", "", {}, "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ=="], @@ -1810,6 +1927,20 @@ "@pnpm/network.ca-file/graceful-fs": ["graceful-fs@4.2.10", "", {}, "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA=="], + "@radix-ui/react-arrow/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-focus-scope/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-popover/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ=="], + + "@radix-ui/react-popover/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-popper/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-portal/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-y7TBO4xN4Y94FvcWIOIh18fM4R1A8S4q1jhoz4PNzOoHsFcN8pogcFmZrTYAm4F9VRUrWP/Mw7xSKybIeRI+CQ=="], + "@rushstack/node-core-library/ajv": ["ajv@8.13.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.4.1" } }, "sha512-PRA911Blj99jR5RMeTunVbNXMF6Lp4vZXnk5GQjcnUWUTsrXtekg/pnmFFI2u/I36Y/2bITGS30GZCXei6uNkA=="], "@rushstack/node-core-library/semver": ["semver@7.5.4", "", { "dependencies": { "lru-cache": "^6.0.0" }, "bin": { "semver": "bin/semver.js" } }, "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA=="], @@ -1850,10 +1981,18 @@ "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + "@types/babel__core/@babel/parser": ["@babel/parser@7.26.9", "", { "dependencies": { "@babel/types": "^7.26.9" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A=="], + + "@types/babel__template/@babel/parser": ["@babel/parser@7.26.9", "", { "dependencies": { "@babel/types": "^7.26.9" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A=="], + + "@typescript-eslint/project-service/@typescript-eslint/types": ["@typescript-eslint/types@8.39.1", "", {}, "sha512-7sPDKQQp+S11laqTrhHqeAbsCfMkwJMrV7oTDvtDds4mEofJYir414bYKUEb8YPUm9QL3U+8f6L6YExSoAGdQw=="], + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], "@typescript-eslint/utils/@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.7.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw=="], + "@vitejs/plugin-react/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], + "@vitest/mocker/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], "@vue/compiler-core/@babel/parser": ["@babel/parser@7.27.1", "", { "dependencies": { "@babel/types": "^7.27.1" }, "bin": "./bin/babel-parser.js" }, "sha512-I0dZ3ZpCrJ1c04OqlNsQcKiZlsrXf/kkE4FXzID9rIOYICsAbA8mMDzhW/luRNAHdCNt7os/u8wenklZDlUVUQ=="], @@ -2292,12 +2431,16 @@ "pkg-conf/find-up": ["find-up@2.1.0", "", { "dependencies": { "locate-path": "^2.0.0" } }, "sha512-NWzkk0jSJtTt08+FBFMvXoeZnOJD+jTtsRmBYbAIzJdX6l7dLgR7CTubCM5/eDdPUBvLCeVasP1brfVR/9/EZQ=="], + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "pretty-format/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], "pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + "react-docgen/@babel/core": ["@babel/core@7.26.9", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.2", "@babel/generator": "^7.26.9", "@babel/helper-compilation-targets": "^7.26.5", "@babel/helper-module-transforms": "^7.26.0", "@babel/helpers": "^7.26.9", "@babel/parser": "^7.26.9", "@babel/template": "^7.26.9", "@babel/traverse": "^7.26.9", "@babel/types": "^7.26.9", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-lWBYIrF7qK5+GjY5Uy+/hEgp8OJWOD/rpy74GplYRhEauvbHDeFB8t5hPOZxCZ0Oxf4Cc36tK51/l3ymJysrKw=="], + "react-docgen/doctrine": ["doctrine@3.0.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w=="], "read-pkg/unicorn-magic": ["unicorn-magic@0.1.0", "", {}, "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ=="], @@ -2338,17 +2481,35 @@ "tempy/type-fest": ["type-fest@2.19.0", "", {}, "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA=="], + "typescript-eslint/@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.39.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.39.1", "@typescript-eslint/type-utils": "8.39.1", "@typescript-eslint/utils": "8.39.1", "@typescript-eslint/visitor-keys": "8.39.1", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.39.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-yYegZ5n3Yr6eOcqgj2nJH8cH/ZZgF+l0YIdKILSDjYFRjgYQMgv/lRjV5Z7Up04b9VYUondt8EPMqg7kTWgJ2g=="], + + "typescript-eslint/@typescript-eslint/parser": ["@typescript-eslint/parser@8.39.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.39.1", "@typescript-eslint/types": "8.39.1", "@typescript-eslint/typescript-estree": "8.39.1", "@typescript-eslint/visitor-keys": "8.39.1", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-pUXGCuHnnKw6PyYq93lLRiZm3vjuslIy7tus1lIQTYVK9bL8XBgJnCWm8a0KcTtHC84Yya1Q6rtll+duSMj0dg=="], + + "typescript-eslint/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.39.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.39.1", "@typescript-eslint/tsconfig-utils": "8.39.1", "@typescript-eslint/types": "8.39.1", "@typescript-eslint/visitor-keys": "8.39.1", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-EKkpcPuIux48dddVDXyQBlKdeTPMmALqBUbEk38McWv0qVEZwOpVJBi7ugK5qVNgeuYjGNQxrrnoM/5+TI/BPw=="], + + "typescript-eslint/@typescript-eslint/utils": ["@typescript-eslint/utils@8.39.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.39.1", "@typescript-eslint/types": "8.39.1", "@typescript-eslint/typescript-estree": "8.39.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-VF5tZ2XnUSTuiqZFXCZfZs1cgkdd3O/sSYmdo2EpSyDlC86UM/8YytTmKnehOW3TGAlivqTDT6bS87B/GQ/jyg=="], + "wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="], "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "@babel/core/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.25.9", "", {}, "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ=="], + "@babel/core/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/generator/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - "@babel/template/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.25.9", "", {}, "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ=="], + "@babel/helper-module-imports/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/helper-module-transforms/@babel/traverse/@babel/types": ["@babel/types@7.28.2", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ=="], + + "@babel/helpers/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/template/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], "@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.25.9", "", {}, "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ=="], @@ -2480,6 +2641,22 @@ "pkg-conf/find-up/locate-path": ["locate-path@2.0.0", "", { "dependencies": { "p-locate": "^2.0.0", "path-exists": "^3.0.0" } }, "sha512-NCI2kiDkyR7VeEKm27Kda/iQHyKJe1Bu0FlTbYp3CqJu+9IFe9bLyAjMxf5ZDDbEg+iMPzB5zYyUTSm8wVTKmA=="], + "react-docgen/@babel/core/@babel/code-frame": ["@babel/code-frame@7.26.2", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.25.9", "js-tokens": "^4.0.0", "picocolors": "^1.0.0" } }, "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ=="], + + "react-docgen/@babel/core/@babel/generator": ["@babel/generator@7.26.9", "", { "dependencies": { "@babel/parser": "^7.26.9", "@babel/types": "^7.26.9", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" } }, "sha512-kEWdzjOAUMW4hAyrzJ0ZaTOu9OmpyDIQicIh0zg0EEcEkYXZb2TjtBhnHi2ViX7PKwZqF4xwqfAm299/QMP3lg=="], + + "react-docgen/@babel/core/@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.26.5", "", { "dependencies": { "@babel/compat-data": "^7.26.5", "@babel/helper-validator-option": "^7.25.9", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-IXuyn5EkouFJscIDuFF5EsiSolseme1s0CZB+QxVugqJLYmKdxI1VfIBOst0SUu4rnk2Z7kqTwmoO1lp3HIfnA=="], + + "react-docgen/@babel/core/@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.26.0", "", { "dependencies": { "@babel/helper-module-imports": "^7.25.9", "@babel/helper-validator-identifier": "^7.25.9", "@babel/traverse": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw=="], + + "react-docgen/@babel/core/@babel/helpers": ["@babel/helpers@7.26.9", "", { "dependencies": { "@babel/template": "^7.26.9", "@babel/types": "^7.26.9" } }, "sha512-Mz/4+y8udxBKdmzt/UjPACs4G3j5SshJJEFFKxlCGPydG4JAHXxjWjAwjd09tf6oINvl1VfMJo+nB7H2YKQ0dA=="], + + "react-docgen/@babel/core/@babel/parser": ["@babel/parser@7.26.9", "", { "dependencies": { "@babel/types": "^7.26.9" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A=="], + + "react-docgen/@babel/core/@babel/template": ["@babel/template@7.26.9", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "@babel/parser": "^7.26.9", "@babel/types": "^7.26.9" } }, "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA=="], + + "react-docgen/@babel/core/semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "semantic-release/aggregate-error/clean-stack": ["clean-stack@5.2.0", "", { "dependencies": { "escape-string-regexp": "5.0.0" } }, "sha512-TyUIUJgdFnCISzG5zu3291TAsE77ddchd0bepon1VVQrKLGKFED4iXFEDQ24mIPdPBbyE16PK3F8MYE1CmcBEQ=="], "semantic-release/aggregate-error/indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], @@ -2510,10 +2687,38 @@ "string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.39.1", "", { "dependencies": { "@typescript-eslint/types": "8.39.1", "@typescript-eslint/visitor-keys": "8.39.1" } }, "sha512-RkBKGBrjgskFGWuyUGz/EtD8AF/GW49S21J8dvMzpJitOF1slLEbbHnNEtAHtnDAnx8qDEdRrULRnWVx27wGBw=="], + + "typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.39.1", "", { "dependencies": { "@typescript-eslint/types": "8.39.1", "@typescript-eslint/typescript-estree": "8.39.1", "@typescript-eslint/utils": "8.39.1", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-gu9/ahyatyAdQbKeHnhT4R+y3YLtqqHyvkfDxaBYk97EcbfChSJXyaJnIL3ygUv7OuZatePHmQvuH5ru0lnVeA=="], + + "typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.39.1", "", { "dependencies": { "@typescript-eslint/types": "8.39.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-W8FQi6kEh2e8zVhQ0eeRnxdvIoOkAp/CPAahcNio6nO9dsIwb9b34z90KOlheoyuVf6LSOEdjlkxSkapNEc+4A=="], + + "typescript-eslint/@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.4", "", {}, "sha512-gJzzk+PQNznz8ysRrC0aOkBNVRBDtE1n53IqyqEf3PXrYwomFs5q4pGMizBMJF+ykh03insJ27hB8gSrD2Hn8A=="], + + "typescript-eslint/@typescript-eslint/parser/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.39.1", "", { "dependencies": { "@typescript-eslint/types": "8.39.1", "@typescript-eslint/visitor-keys": "8.39.1" } }, "sha512-RkBKGBrjgskFGWuyUGz/EtD8AF/GW49S21J8dvMzpJitOF1slLEbbHnNEtAHtnDAnx8qDEdRrULRnWVx27wGBw=="], + + "typescript-eslint/@typescript-eslint/parser/@typescript-eslint/types": ["@typescript-eslint/types@8.39.1", "", {}, "sha512-7sPDKQQp+S11laqTrhHqeAbsCfMkwJMrV7oTDvtDds4mEofJYir414bYKUEb8YPUm9QL3U+8f6L6YExSoAGdQw=="], + + "typescript-eslint/@typescript-eslint/parser/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.39.1", "", { "dependencies": { "@typescript-eslint/types": "8.39.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-W8FQi6kEh2e8zVhQ0eeRnxdvIoOkAp/CPAahcNio6nO9dsIwb9b34z90KOlheoyuVf6LSOEdjlkxSkapNEc+4A=="], + + "typescript-eslint/@typescript-eslint/typescript-estree/@typescript-eslint/types": ["@typescript-eslint/types@8.39.1", "", {}, "sha512-7sPDKQQp+S11laqTrhHqeAbsCfMkwJMrV7oTDvtDds4mEofJYir414bYKUEb8YPUm9QL3U+8f6L6YExSoAGdQw=="], + + "typescript-eslint/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.39.1", "", { "dependencies": { "@typescript-eslint/types": "8.39.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-W8FQi6kEh2e8zVhQ0eeRnxdvIoOkAp/CPAahcNio6nO9dsIwb9b34z90KOlheoyuVf6LSOEdjlkxSkapNEc+4A=="], + + "typescript-eslint/@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + + "typescript-eslint/@typescript-eslint/utils/@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.7.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw=="], + + "typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.39.1", "", { "dependencies": { "@typescript-eslint/types": "8.39.1", "@typescript-eslint/visitor-keys": "8.39.1" } }, "sha512-RkBKGBrjgskFGWuyUGz/EtD8AF/GW49S21J8dvMzpJitOF1slLEbbHnNEtAHtnDAnx8qDEdRrULRnWVx27wGBw=="], + + "typescript-eslint/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.39.1", "", {}, "sha512-7sPDKQQp+S11laqTrhHqeAbsCfMkwJMrV7oTDvtDds4mEofJYir414bYKUEb8YPUm9QL3U+8f6L6YExSoAGdQw=="], + "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.4.0", "", {}, "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw=="], + "@babel/helper-module-transforms/@babel/traverse/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + "@semantic-release/github/aggregate-error/clean-stack/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], "@semantic-release/npm/aggregate-error/clean-stack/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], @@ -2564,6 +2769,18 @@ "pkg-conf/find-up/locate-path/path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="], + "react-docgen/@babel/core/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.25.9", "", {}, "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ=="], + + "react-docgen/@babel/core/@babel/helper-compilation-targets/@babel/compat-data": ["@babel/compat-data@7.26.8", "", {}, "sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ=="], + + "react-docgen/@babel/core/@babel/helper-compilation-targets/@babel/helper-validator-option": ["@babel/helper-validator-option@7.25.9", "", {}, "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw=="], + + "react-docgen/@babel/core/@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "react-docgen/@babel/core/@babel/helper-module-transforms/@babel/helper-module-imports": ["@babel/helper-module-imports@7.25.9", "", { "dependencies": { "@babel/traverse": "^7.25.9", "@babel/types": "^7.25.9" } }, "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw=="], + + "react-docgen/@babel/core/@babel/helper-module-transforms/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.25.9", "", {}, "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ=="], + "semantic-release/aggregate-error/clean-stack/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], "semantic-release/execa/npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], @@ -2572,10 +2789,32 @@ "signale/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], + "typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.39.1", "", {}, "sha512-7sPDKQQp+S11laqTrhHqeAbsCfMkwJMrV7oTDvtDds4mEofJYir414bYKUEb8YPUm9QL3U+8f6L6YExSoAGdQw=="], + + "typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils/@typescript-eslint/types": ["@typescript-eslint/types@8.39.1", "", {}, "sha512-7sPDKQQp+S11laqTrhHqeAbsCfMkwJMrV7oTDvtDds4mEofJYir414bYKUEb8YPUm9QL3U+8f6L6YExSoAGdQw=="], + + "typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.39.1", "", {}, "sha512-7sPDKQQp+S11laqTrhHqeAbsCfMkwJMrV7oTDvtDds4mEofJYir414bYKUEb8YPUm9QL3U+8f6L6YExSoAGdQw=="], + + "typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + + "typescript-eslint/@typescript-eslint/parser/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + + "typescript-eslint/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + + "typescript-eslint/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], + + "typescript-eslint/@typescript-eslint/utils/@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.39.1", "", { "dependencies": { "@typescript-eslint/types": "8.39.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-W8FQi6kEh2e8zVhQ0eeRnxdvIoOkAp/CPAahcNio6nO9dsIwb9b34z90KOlheoyuVf6LSOEdjlkxSkapNEc+4A=="], + "cli-highlight/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "pkg-conf/find-up/locate-path/p-locate/p-limit": ["p-limit@1.3.0", "", { "dependencies": { "p-try": "^1.0.0" } }, "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q=="], + "react-docgen/@babel/core/@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "signale/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], + + "typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], } } diff --git a/eslint.config.mjs b/eslint.config.mjs index 40a38ad..4ef4693 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -20,7 +20,7 @@ export default [ rules: { 'prettier/prettier': 'error', 'react/prop-types': 'off', - '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-explicit-any': 'error', '@typescript-eslint/explicit-module-boundary-types': 'off', 'unused-imports/no-unused-imports': 'error', 'unused-imports/no-unused-vars': [ diff --git a/landing/bun.lock b/landing/bun.lock index 836d35b..3b3b077 100644 --- a/landing/bun.lock +++ b/landing/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "landing", @@ -9,6 +10,7 @@ "@radix-ui/react-dropdown-menu": "^2.1.14", "@radix-ui/react-tabs": "^1.1.11", "lucide-react": "^0.507.0", + "motion": "^12.26.2", "react": "^19.1.0", "react-dom": "^19.1.0", "vike-react": "^0.6.1", @@ -499,6 +501,8 @@ "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + "framer-motion": ["framer-motion@12.26.2", "", { "dependencies": { "motion-dom": "^12.26.2", "motion-utils": "^12.24.10", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-lflOQEdjquUi9sCg5Y1LrsZDlsjrHw7m0T9Yedvnk7Bnhqfkc89/Uha10J3CFhkL+TCZVCRw9eUGyM/lyYhXQA=="], + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], @@ -633,6 +637,12 @@ "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "motion": ["motion@12.26.2", "", { "dependencies": { "framer-motion": "^12.26.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-2Q6g0zK1gUJKhGT742DAe42LgietcdiJ3L3OcYAHCQaC1UkLnn6aC8S/obe4CxYTLAgid2asS1QdQ/blYfo5dw=="], + + "motion-dom": ["motion-dom@12.26.2", "", { "dependencies": { "motion-utils": "^12.24.10" } }, "sha512-KLMT1BroY8oKNeliA3JMNJ+nbCIsTKg6hJpDb4jtRAJ7nCKnnpg/LTq/NGqG90Limitz3kdAnAVXecdFVGlWTw=="], + + "motion-utils": ["motion-utils@12.24.10", "", {}, "sha512-x5TFgkCIP4pPsRLpKoI86jv/q8t8FQOiM/0E8QKBzfMozWHfkKap2gA1hOki+B5g3IsBNpxbUnfOum1+dgvYww=="], + "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], diff --git a/landing/components/chrono-example.tsx b/landing/components/chrono-example.tsx new file mode 100644 index 0000000..33c42ad --- /dev/null +++ b/landing/components/chrono-example.tsx @@ -0,0 +1,188 @@ +import React from 'react' +import { Chrono } from '@lib' +import type { DateRange } from '@lib/chrono' +import { ChevronDown } from 'lucide-react' +import { + differenceInYears, + differenceInMonths, + differenceInWeeks, + differenceInDays, + differenceInHours, + differenceInMinutes, + differenceInSeconds +} from 'date-fns' + +const colors = { + bg: '#0A0A0A', + surface: '#111111', + border: '#222222', + text: '#FFFFFF', + textMuted: '#888888', + cyan: '#00F0FF' +} + +export default function ChronoExample() { + const [dateRange, setDateRange] = React.useState({ + startDate: new Date(Date.now() - 1000 * 60 * 15), // 15 minutes ago + endDate: new Date() + }) + const [isOpen, setIsOpen] = React.useState(false) + + const onDateRangeChange = (range: DateRange) => { + setDateRange(range) + } + + const getDurationLabel = (start: Date, end: Date) => { + const diffSeconds = differenceInSeconds(end, start) + const diffMinutes = differenceInMinutes(end, start) + const diffHours = differenceInHours(end, start) + const diffDays = differenceInDays(end, start) + const diffWeeks = differenceInWeeks(end, start) + const diffMonths = differenceInMonths(end, start) + const diffYears = differenceInYears(end, start) + + if (diffYears > 0) return `${diffYears}y` + if (diffMonths > 0) return `${diffMonths}mo` + if (diffWeeks > 0) return `${diffWeeks}w` + if (diffDays > 0) return `${diffDays}d` + if (diffHours > 0) return `${diffHours}h` + if (diffMinutes > 0) return `${diffMinutes}m` + + return `${diffSeconds}s` + } + + const activeDurationLabel = React.useMemo(() => { + if (!dateRange.startDate || !dateRange.endDate) return '-' + return getDurationLabel(dateRange.startDate, dateRange.endDate) + }, [dateRange]) + + return ( + + + + + + + {activeDurationLabel} + + + + + + + + + + + + + + Quick select + + + + + (e.currentTarget.style.backgroundColor = `${colors.cyan}10`) + } + onMouseLeave={(e) => + (e.currentTarget.style.backgroundColor = 'transparent') + } + > + 15 minutes + + 15m + + + + + + (e.currentTarget.style.backgroundColor = `${colors.cyan}10`) + } + onMouseLeave={(e) => + (e.currentTarget.style.backgroundColor = 'transparent') + } + > + 1 hour + + 1h + + + + + + (e.currentTarget.style.backgroundColor = `${colors.cyan}10`) + } + onMouseLeave={(e) => + (e.currentTarget.style.backgroundColor = 'transparent') + } + > + 1 day + + 1d + + + + + + (e.currentTarget.style.backgroundColor = `${colors.cyan}10`) + } + onMouseLeave={(e) => + (e.currentTarget.style.backgroundColor = 'transparent') + } + > + 1 month + + 1mo + + + + + + ) +} diff --git a/landing/components/code-example-panel.tsx b/landing/components/code-example-panel.tsx index 1e4f27d..6a856ef 100644 --- a/landing/components/code-example-panel.tsx +++ b/landing/components/code-example-panel.tsx @@ -18,6 +18,16 @@ interface CodeExamplePanelProps { supportingText?: string } +const colors = { + cream: '#FAF7F2', + creamDark: '#F0EBE3', + ink: '#1a1816', + inkLight: '#3d3835', + inkMuted: '#6b6460', + coral: '#E85D4C', + sage: '#7D9F8E' +} + export default function CodeExamplePanel({ title = 'Example Code', defaultOpen = false, @@ -33,13 +43,11 @@ export default function CodeExamplePanel({ const activeCode = tabs.find((tab) => tab.value === activeTab)?.code || '' - // Handle when content refs update const updateTabHeight = ( tabValue: string, element: HTMLDivElement | null ) => { if (element) { - // Small delay to ensure content has fully rendered setTimeout(() => { const height = element.scrollHeight setTabHeights((prev) => ({ @@ -50,7 +58,6 @@ export default function CodeExamplePanel({ } } - // Update heights when tab changes or content loads useEffect(() => { const currentRef = contentRefs.current[activeTab] if (currentRef && highlighterLoadedRef.current[activeTab]) { @@ -67,43 +74,89 @@ export default function CodeExamplePanel({ return ( - + - - {title} + + + {title} + - + - + - + {tabs.map((tab) => ( {tab.label} ))} - + {copied ? ( - - Copied + + + Copied + ) : ( @@ -132,7 +185,6 @@ export default function CodeExamplePanel({ load={() => import('../components/shiki-highlighter').then( (m) => { - // Mark this highlighter as loaded highlighterLoadedRef.current[tab.value] = true return m.default } @@ -141,12 +193,12 @@ export default function CodeExamplePanel({ fallback={ - {/* Generate lines based on code length */} {tab.code.split('\n').map((_, i) => ( @@ -170,7 +222,14 @@ export default function CodeExamplePanel({ {supportingText && ( - + {supportingText} )} diff --git a/landing/components/code-examples.ts b/landing/components/code-examples.ts index 64f52fa..cb43528 100644 --- a/landing/components/code-examples.ts +++ b/landing/components/code-examples.ts @@ -1,5 +1,5 @@ -// Basic usage example for TimeSlice -export const timeSliceBasicExample = `import { TimeSlice } from "@bizarre/ui" +// Basic usage example for Chrono +export const chronoBasicExample = `import { Chrono } from "@bizarre/ui" export function TimeRangePicker() { const handleDateRangeChange = (range) => { @@ -7,29 +7,29 @@ export function TimeRangePicker() { } return ( - - - - + + + + 15 minutes - - + + 1 hour - - + + 1 day - - + + 1 month - - - + + + ) }` -// Implementation example for TimeSlice - similar to the actual implementation but simplified -export const timeSliceImplementationExample = `import React from 'react' -import { TimeSlice } from '@bizarre/ui' +// Implementation example for Chrono - similar to the actual implementation but simplified +export const chronoImplementationExample = `import React from 'react' +import { Chrono } from '@bizarre/ui' import { ChevronDown } from 'lucide-react' export function CustomTimeRangePicker() { @@ -67,56 +67,56 @@ export function CustomTimeRangePicker() { }, [dateRange]) return ( - - + {activeDurationLabel} - + - + - + Quick select - + 15 minutes 15m - - + + 1 hour 1h - - + + 1 day 1d - - + + 1 month 1mo - - - + + + ) }` diff --git a/landing/components/component-showcase-dialog.tsx b/landing/components/component-showcase-dialog.tsx index c4003e9..8fbcfa9 100644 --- a/landing/components/component-showcase-dialog.tsx +++ b/landing/components/component-showcase-dialog.tsx @@ -19,6 +19,14 @@ interface ComponentShowcaseDialogProps { docsLink?: string } +const colors = { + cream: '#FAF7F2', + creamDark: '#F0EBE3', + ink: '#1a1816', + inkMuted: '#6b6460', + coral: '#E85D4C' +} + export default function ComponentShowcaseDialog({ title, description, @@ -33,7 +41,14 @@ export default function ComponentShowcaseDialog({ {trigger || ( - + View demo & code @@ -41,12 +56,30 @@ export default function ComponentShowcaseDialog({ - + - + - - + + {title} @@ -56,7 +89,8 @@ export default function ComponentShowcaseDialog({ href={docsLink} target="_blank" rel="noopener noreferrer" - className="inline-flex items-center gap-1.5 text-zinc-400 hover:text-white text-sm transition-colors" + className="inline-flex items-center gap-1.5 text-sm transition-colors hover:opacity-80" + style={{ color: colors.inkMuted }} > Docs @@ -64,7 +98,8 @@ export default function ComponentShowcaseDialog({ )} @@ -75,13 +110,25 @@ export default function ComponentShowcaseDialog({ {description && ( - + {description} )} - + diff --git a/landing/components/inlay-example.tsx b/landing/components/inlay-example.tsx new file mode 100644 index 0000000..c164fd2 --- /dev/null +++ b/landing/components/inlay-example.tsx @@ -0,0 +1,170 @@ +import React from 'react' +import { Inlay } from '@lib' +import { mentions } from '../../src/inlay/structured/plugins/mentions' + +const colors = { + bg: '#0A0A0A', + surface: '#111111', + surfaceLight: '#1A1A1A', + border: '#222222', + borderLight: '#333333', + text: '#FFFFFF', + textMuted: '#888888', + lime: '#B8FF00' +} + +const MOCK_USERS = [ + { id: 'alexadewole', name: 'Alex' }, + { id: 'samantha', name: 'Samantha' }, + { id: 'jordan_dev', name: 'Jordan' }, + { id: 'taylor_ui', name: 'Taylor' } +] + +const MentionAutocomplete = ({ + query, + onSelect +}: { + query: string + onSelect: (user: { id: string; name: string }) => void +}) => { + const results = React.useMemo(() => { + const searchTerm = query.slice(1).toLowerCase() + return MOCK_USERS.filter( + (user) => + user.name.toLowerCase().includes(searchTerm) || + user.id.toLowerCase().includes(searchTerm) + ) + }, [query]) + + if (results.length === 0) { + return ( + + + No users found + + + ) + } + + return ( + onSelect(user)} + className="rounded-lg p-1.5 text-sm w-48 shadow-2xl" + style={{ + backgroundColor: colors.surface, + border: `1px solid ${colors.border}` + }} + > + {results.map((user) => ( + + + + {user.name[0]} + + + {user.name} + + @{user.id} + + + + + ))} + + ) +} + +const MentionToken = ({ + token, + update +}: { + token: { mention: string; name?: string } + update: (data: Partial<{ mention: string; name?: string }>) => void +}) => { + React.useEffect(() => { + if (token.mention.startsWith('@') && !token.name) { + const timeout = setTimeout(() => { + const id = token.mention.slice(1) + const user = MOCK_USERS.find((u) => u.id === id) + if (user) { + update({ name: user.name }) + } + }, 100) + return () => clearTimeout(timeout) + } + }, [token.mention, token.name, update]) + + if (token.name) { + return ( + + {token.name} + + ) + } + return {token.mention} +} + +export default function InlayExample() { + return ( + + ( + + ), + portal: ({ token, replace }) => { + if (token.name) return null + + return ( + { + replace(`@${user.id} `) + }} + /> + ) + } + }) + ]} + placeholder="Type @ to mention someone..." + className="w-full text-sm p-4 focus:outline-none overflow-hidden text-ellipsis" + style={{ color: colors.text }} + portalProps={{ + align: 'start', + side: 'bottom', + alignOffset: -5, + sideOffset: 8 + }} + /> + + ) +} diff --git a/landing/components/package-manager-tabs.tsx b/landing/components/package-manager-tabs.tsx index 61e7f07..7373a24 100644 --- a/landing/components/package-manager-tabs.tsx +++ b/landing/components/package-manager-tabs.tsx @@ -11,6 +11,11 @@ const commands: Record = { npm: 'npm install @bizarre/ui' } +const colors = { + coral: '#E85D4C', + sage: '#7D9F8E' +} + export default function PackageManagerTabs() { const [activeTab, setActiveTab] = useState('bun') const [copied, setCopied] = useState(false) @@ -27,12 +32,21 @@ export default function PackageManagerTabs() { value={activeTab} onValueChange={(value) => setActiveTab(value as PackageManager)} > - + {Object.keys(commands).map((pm) => ( {pm} @@ -41,18 +55,28 @@ export default function PackageManagerTabs() { {Object.entries(commands).map(([pm, command]) => ( - - {command} + + + $ {command} + {copied ? ( - - Copied + + Copied ) : ( diff --git a/landing/components/time-slice-example.tsx b/landing/components/time-slice-example.tsx deleted file mode 100644 index eb6f3f9..0000000 --- a/landing/components/time-slice-example.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import React from 'react' -import { TimeSlice } from '@lib' -import type { DateRange } from '@lib/timeslice' -import { ChevronDown } from 'lucide-react' -import { - differenceInYears, - differenceInMonths, - differenceInWeeks, - differenceInDays, - differenceInHours, - differenceInMinutes, - differenceInSeconds -} from 'date-fns' - -export default function TimeSliceExample() { - const [dateRange, setDateRange] = React.useState({ - startDate: new Date(Date.now() - 1000 * 60 * 15), // 15 minutes ago - endDate: new Date() - }) - const [isOpen, setIsOpen] = React.useState(false) - - const onDateRangeChange = (range: DateRange) => { - setDateRange(range) - } - - const getDurationLabel = (start: Date, end: Date) => { - const diffSeconds = differenceInSeconds(end, start) - const diffMinutes = differenceInMinutes(end, start) - const diffHours = differenceInHours(end, start) - const diffDays = differenceInDays(end, start) - const diffWeeks = differenceInWeeks(end, start) - const diffMonths = differenceInMonths(end, start) - const diffYears = differenceInYears(end, start) - - if (diffYears > 0) return `${diffYears}y` - if (diffMonths > 0) return `${diffMonths}mo` - if (diffWeeks > 0) return `${diffWeeks}w` - if (diffDays > 0) return `${diffDays}d` - if (diffHours > 0) return `${diffHours}h` - if (diffMinutes > 0) return `${diffMinutes}m` - - return `${diffSeconds}s` - } - - const activeDurationLabel = React.useMemo(() => { - if (!dateRange.startDate || !dateRange.endDate) return '-' - return getDurationLabel(dateRange.startDate, dateRange.endDate) - }, [dateRange]) - - return ( - - - - - - - {activeDurationLabel} - - - - - - - - - - - - - Quick select - - - - 15 minutes - 15m - - - - - 1 hour - 1h - - - - - 1 day - 1d - - - - - 1 month - 1mo - - - - - ) -} diff --git a/landing/globals.css b/landing/globals.css index 6d51a12..fc41a06 100644 --- a/landing/globals.css +++ b/landing/globals.css @@ -1,86 +1,171 @@ @import 'tailwindcss'; +@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=JetBrains+Mono:wght@400;500&display=swap'); +@theme { + --color-bg: #0A0A0A; + --color-surface: #111111; + --color-surface-light: #1A1A1A; + --color-border: #222222; + --color-border-light: #333333; + + --color-text: #FFFFFF; + --color-text-muted: #888888; + --color-text-dim: #555555; + + --color-magenta: #FF2D92; + --color-cyan: #00F0FF; + --color-lime: #B8FF00; + + --font-sans: 'DM Sans', system-ui, sans-serif; + --font-mono: 'JetBrains Mono', monospace; +} + +* { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + font-family: var(--font-sans); + background-color: var(--color-bg); + color: var(--color-text); +} + +.font-mono { + font-family: var(--font-mono); +} + +/* Selection */ +::selection { + background-color: var(--color-magenta); + color: white; +} + +/* Smooth scrolling */ +html { + scroll-behavior: smooth; +} + +/* Custom scrollbar */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: var(--color-bg); +} + +::-webkit-scrollbar-thumb { + background: var(--color-border-light); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--color-text-dim); +} + +/* Collapsible animations for Radix */ @keyframes collapsible-down { - from { - height: 0; - } - to { - height: var(--radix-collapsible-content-height); - } + from { height: 0; } + to { height: var(--radix-collapsible-content-height); } } @keyframes collapsible-up { - from { - height: var(--radix-collapsible-content-height); - } - to { - height: 0; - } + from { height: var(--radix-collapsible-content-height); } + to { height: 0; } } .animate-collapsible-down { - animation: collapsible-down 300ms ease-out; + animation: collapsible-down 300ms cubic-bezier(0.22, 1, 0.36, 1); } .animate-collapsible-up { - animation: collapsible-up 300ms ease-out; + animation: collapsible-up 300ms cubic-bezier(0.22, 1, 0.36, 1); } -/* Radix UI animations */ +/* Dialog/Portal animations */ @keyframes fade-in { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - -@keyframes fade-out { - from { - opacity: 1; - } - to { - opacity: 0; - } + from { opacity: 0; } + to { opacity: 1; } } @keyframes zoom-in { - from { - transform: scale(0.95); - } - to { - transform: scale(1); - } -} - -@keyframes zoom-out { - from { - transform: scale(1); - } - to { - transform: scale(0.95); - } + from { transform: scale(0.95); opacity: 0; } + to { transform: scale(1); opacity: 1; } } .animate-in { - animation-duration: 150ms; - animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1); - will-change: transform, opacity; + animation-duration: 200ms; + animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1); } .fade-in-0 { animation-name: fade-in; } -.fade-out-0 { - animation-name: fade-out; -} - .zoom-in-95 { animation-name: zoom-in; } -.zoom-out-95 { - animation-name: zoom-out; +/* Pulse for cursor */ +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} + +.animate-pulse { + animation: pulse 1s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + +/* Bounce animation */ +@keyframes bounce { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(25%); } +} + +.animate-bounce { + animation: bounce 1s ease-in-out infinite; +} + +/* Scroll snap utilities */ +.snap-y { + scroll-snap-type: y mandatory; +} + +.snap-mandatory { + scroll-snap-type: y mandatory; +} + +.snap-start { + scroll-snap-align: start; +} + +.snap-always { + scroll-snap-stop: always; +} + +/* Prose styles for documentation */ +.prose { + line-height: 1.75; +} + +.prose p { + margin-bottom: 1rem; +} + +.prose code { + font-family: var(--font-mono); + font-size: 0.875em; + padding: 0.125rem 0.375rem; + border-radius: 0.25rem; + background-color: var(--color-surface-light); +} + +.prose-invert { + color: var(--color-text-muted); +} + +.prose-sm { + font-size: 0.875rem; } diff --git a/landing/package.json b/landing/package.json index 31da8f9..c0f4c47 100644 --- a/landing/package.json +++ b/landing/package.json @@ -14,6 +14,7 @@ "@radix-ui/react-dropdown-menu": "^2.1.14", "@radix-ui/react-tabs": "^1.1.11", "lucide-react": "^0.507.0", + "motion": "^12.26.2", "react": "^19.1.0", "react-dom": "^19.1.0", "vike-react": "^0.6.1" diff --git a/landing/pages/index/+Page.tsx b/landing/pages/index/+Page.tsx index 0468143..cff1f24 100644 --- a/landing/pages/index/+Page.tsx +++ b/landing/pages/index/+Page.tsx @@ -1,570 +1,1526 @@ +import { useRef, useState, useEffect } from 'react' +import { createPortal } from 'react-dom' import { - ArrowRight, Github, Clock, + TextCursorInput, + Copy, + Check, + ArrowUpRight, Sparkles, - Code, - Calendar, MessageSquare, - ChevronUp, - ChevronDown, - ArrowLeftRight, - ExternalLink, - CornerDownRight + Keyboard, + Globe, + Zap, + X, + ChevronDown } from 'lucide-react' +import type { LucideIcon } from 'lucide-react' +import { motion, useScroll, useTransform, AnimatePresence } from 'motion/react' import { ClientOnly } from 'vike-react/ClientOnly' import packageJson from '../../../package.json' -import { - timeSliceBasicExample, - timeSliceImplementationExample -} from '../../components/code-examples' -import * as Collapsible from '@radix-ui/react-collapsible' -export default function Page() { +// ============================================================================ +// Design Tokens +// ============================================================================ + +const colors = { + // Base + bg: '#0A0A0A', + surface: '#111111', + surfaceLight: '#1A1A1A', + border: '#222222', + borderLight: '#333333', + + // Text + text: '#FFFFFF', + textMuted: '#888888', + textDim: '#555555', + + // Neon accents + magenta: '#FF2D92', + cyan: '#00F0FF', + lime: '#B8FF00' +} as const + +// ============================================================================ +// Shared Components +// ============================================================================ + +function GutterText({ + children, + side, + top +}: { + children: React.ReactNode + side: 'left' | 'right' + top?: string +}) { return ( - - {/* Subtle background gradient */} - + + {children} + + ) +} + +function IconBox({ icon: Icon, color }: { icon: LucideIcon; color: string }) { + return ( + + + + ) +} - {/* Animated gradient accents */} +function FeatureIcon({ + icon: Icon, + color +}: { + icon: LucideIcon + color: string +}) { + return ( + + + + ) +} + +function FeatureItem({ + icon, + title, + desc, + color +}: { + icon: LucideIcon + title: string + desc: string + color: string +}) { + return ( + + + + + {title} + + + {desc} + + + + ) +} + +function TagList({ tags, color }: { tags: string[]; color: string }) { + return ( + + {tags.map((tag) => ( + + {tag} + + ))} + + ) +} + +function CopyButton({ + onCopy, + copied, + size = 'sm' +}: { + onCopy: () => void + copied: boolean + size?: 'sm' | 'xs' +}) { + const iconSize = size === 'sm' ? 'w-4 h-4' : 'w-3 h-3' + return ( + { + e.stopPropagation() + onCopy() + }} + className="opacity-40 hover:opacity-100 transition-opacity cursor-pointer" + style={{ color: copied ? colors.lime : 'inherit' }} + > + {copied ? : } + + ) +} + +// ============================================================================ +// Code Preview Component +// ============================================================================ + +// Syntax highlighting as React elements (avoids HTML injection issues) +// Syntax theme - cohesive dark mode with neon accents +const syntaxColors = { + keyword: '#FF6B9D', // soft pink - import, from, const, etc. + tag: '#7DD3FC', // sky blue - JSX tags + tagBracket: '#5EADD5', // slightly darker blue - < > / + attribute: '#C4B5FD', // soft purple - prop names + string: '#A3E635', // lime green - strings + punctuation: '#6B7280', // gray - braces, parens, equals + text: '#E5E5E5', // off-white - default text + comment: '#6B7280' // gray - comments +} + +function SyntaxHighlight({ + code, + multiline = false +}: { + code: string + multiline?: boolean +}) { + type TokenType = + | 'keyword' + | 'string' + | 'tag' + | 'tagBracket' + | 'attribute' + | 'punctuation' + | 'text' + const tokens: Array<{ type: TokenType; value: string }> = [] + + let i = 0 + while (i < code.length) { + // Whitespace + if (/\s/.test(code[i])) { + let ws = '' + while (i < code.length && /\s/.test(code[i])) { + ws += code[i++] + } + tokens.push({ type: 'text', value: ws }) + continue + } + + // JSX tags: + if (code[i] === '<') { + // Opening bracket + tokens.push({ type: 'tagBracket', value: '<' }) + i++ + + // Check for closing slash + if (code[i] === '/') { + tokens.push({ type: 'tagBracket', value: '/' }) + i++ + } + + // Tag name (PascalCase or lowercase html tags) + let tagName = '' + while (i < code.length && /[a-zA-Z0-9.]/.test(code[i])) { + tagName += code[i++] + } + if (tagName) { + tokens.push({ type: 'tag', value: tagName }) + } + continue + } + + // Self-closing or closing bracket + if (code[i] === '/' && code[i + 1] === '>') { + tokens.push({ type: 'tagBracket', value: '/>' }) + i += 2 + continue + } + + if (code[i] === '>') { + tokens.push({ type: 'tagBracket', value: '>' }) + i++ + continue + } + + // Strings + if (code[i] === '"' || code[i] === "'" || code[i] === '`') { + const quote = code[i] + let str = quote + i++ + while (i < code.length && code[i] !== quote) { + str += code[i++] + } + if (i < code.length) str += code[i++] + tokens.push({ type: 'string', value: str }) + continue + } + + // Arrow function (check before punctuation to catch => as a unit) + if (code.slice(i, i + 2) === '=>') { + tokens.push({ type: 'keyword', value: '=>' }) + i += 2 + continue + } + + // Braces and punctuation + if (/[{}()=,;]/.test(code[i])) { + tokens.push({ type: 'punctuation', value: code[i++] }) + continue + } + + // Words (identifiers, keywords) + let word = '' + while (i < code.length && /[a-zA-Z0-9_$.]/.test(code[i])) { + word += code[i++] + } + + if (word) { + const keywords = [ + 'import', + 'from', + 'export', + 'const', + 'let', + 'var', + 'function', + 'return', + 'default', + 'async', + 'await' + ] + if (keywords.includes(word)) { + tokens.push({ type: 'keyword', value: word }) + } else { + // Check if this looks like an attribute (followed by =) + let lookahead = i + while (lookahead < code.length && /\s/.test(code[lookahead])) + lookahead++ + if (code[lookahead] === '=') { + tokens.push({ type: 'attribute', value: word }) + } else { + tokens.push({ type: 'text', value: word }) + } + } + continue + } + + // Fallback: single character + tokens.push({ type: 'text', value: code[i++] }) + } + + const getColor = (type: TokenType) => { + return syntaxColors[type] || syntaxColors.text + } + + const content = tokens.map((token, idx) => ( + + {token.value} + + )) + + return multiline ? ( + {content} + ) : ( + {content} + ) +} + +function CodePreview({ + importStatement, + usageSnippet, + color +}: { + importStatement: string + usageSnippet: string + color: string +}) { + const [copiedImport, setCopiedImport] = useState(false) + const [copiedUsage, setCopiedUsage] = useState(false) + + const handleCopyImport = () => { + navigator.clipboard.writeText(importStatement) + setCopiedImport(true) + setTimeout(() => setCopiedImport(false), 2000) + } + + const handleCopyUsage = () => { + navigator.clipboard.writeText(usageSnippet) + setCopiedUsage(true) + setTimeout(() => setCopiedUsage(false), 2000) + } + + return ( + + {/* Import statement */} + className="group flex items-center justify-between gap-4 px-3 py-2.5 rounded-lg font-mono text-[11px]" + style={{ + backgroundColor: colors.surface, + border: `1px solid ${colors.border}` + }} + > + + + + + + + {/* Usage snippet */} - - {/* Subtle dot pattern */} - - - - {/* Header */} - - - - v{packageJson.version} - + className="group relative px-3 py-3 rounded-lg font-mono text-[11px]" + style={{ + backgroundColor: colors.surface, + border: `1px solid ${color}30` + }} + > + + + + + + + + + ) +} + +// ============================================================================ +// Component Cards +// ============================================================================ + +interface ComponentData { + id: string + index: string + name: string + tagline: string + description: string + icon: LucideIcon + color: string + features: Array<{ icon: LucideIcon; title: string; desc: string }> + tags: string[] + storybookPath: string + importStatement: string + usageSnippet: string + fullExample: string + documentation: string +} - - @bizarre/ - - ui - - +interface ComponentCardProps extends ComponentData { + demoContent: React.ReactNode + demoPosition?: 'left' | 'right' + indexPosition?: 'left' | 'right' +} + +function ComponentCard({ + index, + name, + tagline, + description, + icon, + color, + features, + tags, + storybookPath, + importStatement, + usageSnippet, + demoContent, + demoPosition = 'right', + indexPosition = 'left' +}: ComponentCardProps) { + const ref = useRef(null) + const { scrollYProgress } = useScroll({ + target: ref, + offset: ['start end', 'end start'] + }) + + const y = useTransform(scrollYProgress, [0, 1], [40, -40]) + const scale = useTransform( + scrollYProgress, + [0, 0.3, 0.7, 1], + [0.98, 1, 1, 0.98] + ) - - Headless components nobody asked for + const infoSection = ( + + + + + + {name} + + + {tagline} + + - - - - GitHub - - - Storybook - - - - - + + {description} + - - Wrote these so I could ship weird stuff faster. You can too. - - - - {/* Installation */} - - - - Installation - - - - - - - - - - Terminal + + {features.map((feature) => ( + + ))} + + + + + ) + + const demoSection = ( + + {/* Top section: Storybook link */} + + + Storybook + + + + {/* Middle section: Demo content - vertically centered with equal padding */} + + {demoContent} + + + {/* Bottom section: Code preview */} + + + + + ) + + return ( + + {/* Component index label */} + + {index} + + + + {demoPosition === 'left' ? ( + <> + + {demoSection} + + {infoSection} + + > + ) : ( + <> + {infoSection} + {demoSection} + > + )} + + + ) +} - - import('../../../landing/components/package-manager-tabs') - } - fallback={ - - - - } - > - {(PackageManagerTabs) => } - - - - - {/* Components */} - - - - Components - - - - {/* Component Accordion */} - - {/* TimeSlice Component Accordion */} - - - - - - +// ============================================================================ +// Fullscreen Modal +// ============================================================================ + +interface ComponentModalProps { + isOpen: boolean + onClose: () => void + components: ComponentData[] + activeIndex: number + setActiveIndex: (index: number) => void + demoContents: Record +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function _ComponentModal({ + isOpen, + onClose, + components, + activeIndex, + setActiveIndex, + demoContents +}: ComponentModalProps) { + const scrollContainerRef = useRef(null) + const [copiedInstall, setCopiedInstall] = useState(false) + const [expandedExample, setExpandedExample] = useState(null) + const [copiedCode, setCopiedCode] = useState(false) + + const handleCopyCode = (code: string) => { + navigator.clipboard.writeText(code) + setCopiedCode(true) + setTimeout(() => setCopiedCode(false), 2000) + } + + // Handle ESC key and arrow navigation + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose() + } else if (e.key === 'ArrowDown' || e.key === 'ArrowRight') { + e.preventDefault() + if (activeIndex < components.length - 1) { + setActiveIndex(activeIndex + 1) + } + } else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') { + e.preventDefault() + if (activeIndex > 0) { + setActiveIndex(activeIndex - 1) + } + } + } + + if (isOpen) { + document.addEventListener('keydown', handleKeyDown) + document.body.style.overflow = 'hidden' + } + + return () => { + document.removeEventListener('keydown', handleKeyDown) + document.body.style.overflow = '' + } + }, [isOpen, onClose, activeIndex, setActiveIndex, components.length]) + + // Scroll to active component + useEffect(() => { + if (isOpen && scrollContainerRef.current) { + const sections = scrollContainerRef.current.querySelectorAll( + '[data-component-section]' + ) + sections[activeIndex]?.scrollIntoView({ behavior: 'smooth' }) + } + }, [activeIndex, isOpen]) + + const handleCopyInstall = () => { + navigator.clipboard.writeText('bun add @bizarre/ui') + setCopiedInstall(true) + setTimeout(() => setCopiedInstall(false), 2000) + } + + if (typeof window === 'undefined') return null + + return createPortal( + + {isOpen && ( + + {/* Scrollable content with snap */} + + {components.map((comp, idx) => ( + + {/* Full-width header with component info */} + + + {/* Left: Close + Component info */} + + + + Close + + + + + + + + + + + {comp.name} + + + {comp.tagline} + + + - - - TimeSlice - - - A flexible time range picker with built-in intelligence - + + {/* Right: Navigation + Links */} + + {/* Component navigation */} + + {components.map((c, i) => ( + setActiveIndex(i)} + className="px-2 py-1 text-xs font-mono uppercase tracking-wider rounded transition-all" + style={{ + backgroundColor: + i === activeIndex + ? `${c.color}20` + : 'transparent', + color: + i === activeIndex ? c.color : colors.textMuted, + border: + i === activeIndex + ? `1px solid ${c.color}40` + : '1px solid transparent' + }} + > + {c.name} + + ))} + + + + + + + Storybook + + + + + - - - - - - - - - {/* Subtle component divider */} - - - {/* Compact Features and Use Cases - Above Example */} - - - {/* Features Section */} - - - - - - - Features - - - - - - Keyboard navigation - - - - - Natural language - - - - - Timezone-aware - - - - - Accessible - - - - + {/* Main content area */} + + {/* Component showcase - same layout as cards */} + + + {/* Info side */} + + + {comp.description} + - {/* Perfect For Section */} - - - - - - - Perfect For - - + + {comp.features.map((feature) => ( + + ))} + - - - - Analytics dashboards - - - - - Log explorers - - - - - Data visualization - - - - - Monitoring tools - - - + + {comp.tags.map((tag) => ( + + {tag} + + ))} - - {/* Clean, flat demo card */} - - - {/* Top bar */} - - - - - DEMO - + {/* Demo side */} + + + + {demoContents[comp.id]} - - - import( - '../../components/component-showcase-dialog' - ) - } - fallback={ - - } - > - {(ComponentShowcaseDialog) => ( - - import('../../components/time-slice-example') - } - fallback={} - > - {(TimeSliceExample) => ( - - - View code - - } - /> - )} - - )} - - {/* Demo container with ample space for dropdown visibility */} - - - - import('../../components/time-slice-example') - } - fallback={ - - - + {/* Code preview in demo */} + + + + + navigator.clipboard.writeText( + comp.importStatement + ) } - > - {(TimeSliceExample) => } - + copied={false} + size="xs" + /> - {/* Key Features with visual interest */} - - - {/* Feature 1: Natural Language */} - - {/* Feature header */} - - - - - Natural Language - - - - Smart - + {/* Extended content - two column layout */} + + {/* Left: Code Examples (3 cols) */} + + {/* Header with toggle */} + + + setExpandedExample(null)} + className="px-3 py-1.5 text-xs font-mono uppercase tracking-wider rounded-md transition-all" + style={{ + backgroundColor: + expandedExample !== comp.id + ? colors.bg + : 'transparent', + color: + expandedExample !== comp.id + ? colors.text + : colors.textMuted, + border: + expandedExample !== comp.id + ? `1px solid ${colors.border}` + : '1px solid transparent' + }} + > + Minimal + + setExpandedExample(comp.id)} + className="px-3 py-1.5 text-xs font-mono uppercase tracking-wider rounded-md transition-all" + style={{ + backgroundColor: + expandedExample === comp.id + ? colors.bg + : 'transparent', + color: + expandedExample === comp.id + ? colors.text + : colors.textMuted, + border: + expandedExample === comp.id + ? `1px solid ${colors.border}` + : '1px solid transparent' + }} + > + Full Example + + + handleCopyCode( + expandedExample === comp.id + ? comp.fullExample + : comp.usageSnippet + ) + } + copied={copiedCode} + size="sm" + /> + - {/* Feature content */} - - - - - - - " - - last 2 weeks - - " - - - - - - - - - - {new Date( - Date.now() - 14 * 24 * 60 * 60 * 1000 - ).toLocaleDateString()}{' '} - - {new Date().toLocaleDateString()} - - - - - - - - - - " - - yesterday to tomorrow - - " - - - - - - - - - - {new Date( - Date.now() - 24 * 60 * 60 * 1000 - ).toLocaleDateString()}{' '} - -{' '} - {new Date( - Date.now() + 24 * 60 * 60 * 1000 - ).toLocaleDateString()} - - - - + {/* Code block with file indicator */} + + {/* File tab */} + + + + + - - - Parse natural language expressions using{' '} - - chrono-node - {' '} - for intuitive input. - + + {expandedExample === comp.id + ? `${comp.id}-example.tsx` + : 'usage.tsx'} + + + {/* Code content */} + + - {/* Feature 2: Keyboard Navigation */} - - {/* Feature header */} - - - - - - - - Keyboard Navigation - - - - Accessible - + {/* Install command */} + + + Install + + + + ${' '} + + bun add @bizarre/ui + + + + + - {/* Feature content */} - - - - - - 2023- - - - 06 - - - -12 - - - - - - - - - - - Arrow keys - - - - - - - ↑↓ - - - - Modify values - - - - - - - Tab - - - - Jump dates - - - - + {/* Right: About (2 cols) */} + + + About {comp.name} + + + {comp.documentation} + - - Edit day, month, year, hour, and minute segments - with intuitive keyboard shortcuts. - - + {/* Links */} + + + Storybook + + + Source + - - - - - {/* - - - - - - - - - Future Component - - - Description of the future component goes here - - - - - - - - - - - - - - - - - More components coming soon - + + {/* Next component indicator */} + {idx < components.length - 1 && ( + + setActiveIndex(idx + 1)} + className="inline-flex flex-col items-center gap-2 text-xs font-mono uppercase tracking-wider transition-colors hover:opacity-70" + style={{ color: colors.textDim }} + > + Next: {components[idx + 1].name} + + - + )} - - */} - - - - {/* Footer */} - +// ============================================================================ +// Demo Contents +// ============================================================================ + +function InlayDemo() { + return ( + import('../../components/inlay-example')} + fallback={ + + + + } + > + {(InlayExample) => } + + ) +} + +function ChronoDemo() { + return ( + import('../../components/chrono-example')} + fallback={ + + + + } + > + {(ChronoExample) => } + + ) +} + +// ============================================================================ +// Component Data +// ============================================================================ + +const componentsData: ComponentData[] = [ + { + id: 'inlay', + index: '01 / INLAY', + name: 'Inlay', + tagline: 'Structured text input', + description: + 'A composable input primitive for building rich text experiences with tokens, mentions, search filters, and more. Fully headless, fully accessible.', + icon: TextCursorInput, + color: colors.lime, + features: [ + { + icon: Sparkles, + title: 'Token rendering', + desc: 'React components as tokens' + }, + { + icon: MessageSquare, + title: 'Mentions', + desc: '@mention support built-in' + }, + { icon: Keyboard, title: 'Native UX', desc: 'Feels like a real input' }, + { icon: Globe, title: 'Accessible', desc: 'WCAG 2.1 compliant' } + ], + tags: ['Mentions', 'Search filters', 'AI inputs', 'Tags'], + storybookPath: '/storybook/?path=/story/inlay', + importStatement: `import { Inlay } from '@bizarre/ui'`, + usageSnippet: ` + + + {tokens.map(t => )} + +`, + fullExample: `import { useState } from 'react' +import { Inlay } from '@bizarre/ui' + +function MentionInput() { + const [tokens, setTokens] = useState([]) + const [value, setValue] = useState('') + + const handleMention = (user) => { + setTokens([...tokens, { + id: user.id, + type: 'mention', + label: user.name + }]) + } + + return ( + + + + {tokens.map(token => ( + removeToken(token.id)} + > + @{token.label} + + ))} + + + + + + ) +}`, + documentation: + 'Inlay is a headless, composable input component designed for building rich text experiences. It handles the complex state management of tokens, cursor position, selection, and keyboard navigation while giving you complete control over the visual presentation. Perfect for building mention systems, tag inputs, search filters with structured tokens, or AI chat interfaces with inline components.' + }, + { + id: 'chrono', + index: '02 / CHRONO', + name: 'Chrono', + tagline: 'Intelligent time picker', + description: + 'A time range picker that understands natural language. Type "last 2 weeks" or "yesterday to tomorrow" and watch it parse your intent.', + icon: Clock, + color: colors.cyan, + features: [ + { + icon: MessageSquare, + title: 'Natural language', + desc: 'Powered by chrono-node' + }, + { icon: Keyboard, title: 'Keyboard nav', desc: 'Arrow keys to modify' }, + { icon: Globe, title: 'Timezone aware', desc: 'Handles TZ correctly' }, + { icon: Zap, title: 'Quick shortcuts', desc: '15m, 1h, 1d presets' } + ], + tags: ['Analytics', 'Logs', 'Dashboards', 'Monitoring'], + storybookPath: '/storybook/?path=/story/chrono', + importStatement: `import { Chrono } from '@bizarre/ui'`, + usageSnippet: ` + + + + + + +`, + fullExample: `import { useState } from 'react' +import { Chrono } from '@bizarre/ui' + +function LogsFilter() { + const [dateRange, setDateRange] = useState({ + startDate: new Date(Date.now() - 1000 * 60 * 15), + endDate: new Date() + }) + + return ( + + + + + + + + + + + Last 15 minutes + + + Last hour + + + Last 24 hours + + + Last week + + + + + ) +}`, + documentation: + 'Chrono is a time range picker component with natural language parsing capabilities. It uses chrono-node under the hood to parse human-readable time expressions like "last 2 weeks", "yesterday", or "past 30 minutes". The component supports keyboard navigation for quick adjustments, timezone-aware date handling, and customizable shortcut presets. Ideal for analytics dashboards, log viewers, monitoring tools, or any application that needs flexible time range selection.' + } +] + +const demoContents: Record = { + inlay: , + chrono: +} + +// ============================================================================ +// Layout Components +// ============================================================================ + +function Header() { + return ( + + + + + bizarre/ui + + + v{packageJson.version} + + + + + + Storybook + + + + Source + + + + + ) +} + +function SectionLabel({ children }: { children: React.ReactNode }) { + return ( + + + + {children} + + + + ) +} + +function InstallCTA() { + const [copied, setCopied] = useState(false) + const command = 'bun add @bizarre/ui' + + const handleCopy = () => { + navigator.clipboard.writeText(command) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + return ( + + + + Install + + + $ + {command} + + + + + github.com/bizarre/ui + + + + + ) +} + +function Footer() { + return ( + + ) +} + +// ============================================================================ +// Page +// ============================================================================ + +export default function Page() { + return ( + + {/* Fixed gutter elements */} + + @bizarre/ui · v{packageJson.version} + + + + Headless · Accessible · Composable + + + + + Components + + {/* Component Cards */} + + + + + + + + + ) } diff --git a/landing/pages/v2/+Page.tsx b/landing/pages/v2/+Page.tsx new file mode 100644 index 0000000..ce437e9 --- /dev/null +++ b/landing/pages/v2/+Page.tsx @@ -0,0 +1,1166 @@ +import type React from 'react' +import { useEffect, useState, useRef } from 'react' +import { + Github, + ExternalLink, + Clock, + TextCursorInput, + Copy as CopyIcon, + Check as CheckIcon, + Link as LinkIcon +} from 'lucide-react' +import packageJson from '../../../package.json' + +function BackgroundGrid({ visible }: { visible: boolean }) { + return ( + + ) +} + +function Kbd({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +function Divider() { + return +} + +type Accent = 'emerald' | 'violet' | 'zinc' + +function accentBarClass(accent?: Accent) { + switch (accent) { + case 'emerald': + return 'from-emerald-400/60 to-teal-400/60' + case 'violet': + return 'from-violet-500/60 to-blue-500/60' + default: + return 'from-zinc-300 to-zinc-300' + } +} + +function Panel({ + title, + subtitle, + icon, + tag, + accent, + children +}: { + title: string + subtitle?: string + icon?: React.ReactNode + tag?: string + accent?: Accent + children?: React.ReactNode +}) { + return ( + + + + + {icon ? ( + + {icon} + + ) : null} + + + {title} + + {subtitle ? ( + + {subtitle} + + ) : null} + + + {tag ? ( + + {tag} + + ) : null} + + {children} + + ) +} + +function Terminal({ + lines, + label = 'shell', + showCaret = true +}: { + lines: string[] + label?: string + showCaret?: boolean +}) { + return ( + + + + + + + + {label} + + + {lines.map((l, i) => { + const isLast = i === lines.length - 1 + return ( + + $ + {l} + {showCaret && isLast ? ( + + ) : null} + + ) + })} + + + ) +} + +function Placeholder({ + label, + height = 420 +}: { + label: string + height?: number +}) { + return ( + + {label} + + ) +} + +function AnnotatedPlaceholder({ + label, + height = 420, + markers +}: { + label: string + height?: number + markers: Array<{ xPercent: number; yPercent: number; label: string }> +}) { + return ( + + + {markers.map((m, i) => ( + + {i + 1} + + ))} + + ) +} + +function Code({ code }: { code: string }) { + const [copied, setCopied] = useState(false) + const timeoutRef = useRef(null) + useEffect( + () => () => { + if (timeoutRef.current) window.clearTimeout(timeoutRef.current) + }, + [] + ) + return ( + + { + navigator.clipboard.writeText(code) + setCopied(true) + timeoutRef.current = window.setTimeout(() => setCopied(false), 1200) + }} + className="absolute top-2 right-2 inline-flex items-center gap-1 text-[11px] h-7 px-2 border border-zinc-200 rounded-[2px] bg-white hover:bg-zinc-50 transition-colors" + aria-label="Copy code" + > + {copied ? ( + + ) : ( + + )} + {copied ? 'Copied' : 'Copy'} + + + {code} + + + ) +} + +function InlineCodeCopy({ text }: { text: string }) { + const [copied, setCopied] = useState(false) + const timeoutRef = useRef(null) + useEffect( + () => () => { + if (timeoutRef.current) window.clearTimeout(timeoutRef.current) + }, + [] + ) + return ( + + + {text} + + { + navigator.clipboard.writeText(text) + setCopied(true) + timeoutRef.current = window.setTimeout(() => setCopied(false), 1200) + }} + className="inline-flex items-center gap-1 text-[11px] h-7 px-2 border border-zinc-200 rounded-[2px] bg-white hover:bg-zinc-50 transition-colors" + aria-label="Copy" + > + {copied ? ( + + ) : ( + + )} + {copied ? 'Copied' : 'Copy'} + + + ) +} + +function SectionHeading({ + id, + index, + icon, + title, + badge +}: { + id: string + index: string + icon: React.ReactNode + title: string + badge: React.ReactNode +}) { + const [copied, setCopied] = useState(false) + return ( + + + + {index} + + {icon} + {title} + { + const url = `${window.location.origin}${window.location.pathname}#${id}` + navigator.clipboard.writeText(url) + setCopied(true) + setTimeout(() => setCopied(false), 1200) + }} + className="opacity-0 group-hover:opacity-100 transition-opacity inline-flex items-center h-6 px-1 text-[11px] border border-zinc-200 rounded-[2px] ml-1" + aria-label="Copy link" + > + {copied ? ( + + ) : ( + + )} + + + {badge} + + ) +} + +function Figure({ + number, + title, + children +}: { + number: number + title: string + children: React.ReactNode +}) { + return ( + + {children} + + Figure {number}. {title} + + + ) +} + +function ApiTable({ + rows +}: { + rows: Array<{ name: string; type: string; def?: string; desc: string }> +}) { + return ( + + + Prop + Type + Default + Description + + + {rows.map((r) => ( + + + {r.name} + navigator.clipboard.writeText(r.name)} + className="inline-flex items-center h-6 px-1 border border-zinc-200 rounded-[2px]" + aria-label="Copy prop" + > + + + + + + {r.type} + + + + {r.def ?? '—'} + + {r.desc} + + ))} + + + ) +} + +function InfoTable({ + rows +}: { + rows: Array<{ label: string; value: React.ReactNode }> +}) { + return ( + + {rows.map((r, i) => ( + + + {r.label} + + {r.value} + + ))} + + ) +} + +function AnchorRail({ activeId }: { activeId: string }) { + return ( + + + Overview + + + Inlay + + + Chrono + + + ) +} + +function useActiveSection(ids: string[]) { + const [activeId, setActiveId] = useState(ids[0] || '') + useEffect(() => { + const observers: IntersectionObserver[] = [] + ids.forEach((id) => { + const el = document.getElementById(id) + if (!el) return + const obs = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) setActiveId(id) + }) + }, + { rootMargin: '-30% 0px -60% 0px', threshold: [0, 0.2, 0.5, 1] } + ) + obs.observe(el) + observers.push(obs) + }) + return () => observers.forEach((o) => o.disconnect()) + }, [ids]) + return activeId +} + +function useScrollProgress() { + const [progress, setProgress] = useState(0) + useEffect(() => { + const handler = () => { + const scrollTop = + document.documentElement.scrollTop || document.body.scrollTop + const scrollHeight = + document.documentElement.scrollHeight - + document.documentElement.clientHeight + const p = scrollHeight > 0 ? scrollTop / scrollHeight : 0 + setProgress(p) + } + handler() + window.addEventListener('scroll', handler, { passive: true }) + return () => window.removeEventListener('scroll', handler) + }, []) + return progress +} + +function ApiEventsTabs({ + propsRows, + eventRows, + accent +}: { + propsRows: Array<{ name: string; type: string; def?: string; desc: string }> + eventRows: Array<{ name: string; type: string; def?: string; desc: string }> + accent: 'emerald' | 'violet' | 'zinc' +}) { + const [tab, setTab] = useState<'props' | 'events'>('props') + const accentBorder = + accent === 'emerald' + ? 'border-emerald-300' + : accent === 'violet' + ? 'border-violet-300' + : 'border-zinc-300' + return ( + + + setTab('props')} + className={`text-[11px] h-7 px-2 rounded-[2px] border ${tab === 'props' ? accentBorder + ' bg-zinc-50' : 'border-zinc-200 hover:bg-zinc-50'}`} + > + Props + + setTab('events')} + className={`text-[11px] h-7 px-2 rounded-[2px] border ${tab === 'events' ? accentBorder + ' bg-zinc-50' : 'border-zinc-200 hover:bg-zinc-50'}`} + > + Events + + + + ({ + ...r, + type: r.type + }))} + /> + + + ) +} + +function SpecRail({ + sections +}: { + sections: Array<{ + title: string + rows: Array<{ label: string; value: React.ReactNode }> + }> +}) { + return ( + + {sections.map((s, i) => ( + + + {s.title} + + + + ))} + + ) +} + +export default function Page() { + const activeId = useActiveSection(['overview', 'inlay', 'chrono']) + const progress = useScrollProgress() + const [showGrid, setShowGrid] = useState(false) + return ( + + + + + + + + + + @bizarre/ui + + + v{packageJson.version} + + + + + {activeId === 'overview' + ? 'Overview' + : activeId === 'inlay' + ? 'Inlay' + : 'Chrono'} + + setShowGrid((v) => !v)} + className={`inline-flex items-center gap-1 text-[11px] h-8 px-2 border rounded-[2px] ${showGrid ? 'border-zinc-400 bg-zinc-50' : 'border-zinc-200'} hover:bg-zinc-50`} + > + Grid + + + Storybook + + + + + GitHub + + + + + + + + + + + + + + 00 + + + @bizarre/ui + + + + Focused building blocks for edge‑case UX. + + + Two modules, designed for speed and clarity. + + + + + + Storybook + + + + + GitHub + + + + + + + + Version + + v{packageJson.version} + + + + Stack + + React + Vike · Bun + + + + License + + {(packageJson as { license?: string }).license ?? '—'} + + + + + + + + + + + {activeId !== 'overview' ? ( + + ) : null} + + + + {/* Vertical spine linking heading to spec rail */} + + } + title="Inlay" + badge={ + + Component + + } + /> + + + {/* Left lane: Exhibit (A), Mechanics (B), Interfaces (C) */} + + {/* Band A: Exhibit */} + + + + + + + + (1) Segment selection and movement + (2) Token insertion and suggestions + (3) Cursor position + + + {/* Band B: Mechanics drawer */} + + + Interaction map + + + + Key + + + Scope + + + Effect + + + → + + Segment + + Move to next segment + + + ← + + Segment + + Move to previous segment + + + Tab + + Global + + Jump to next focusable + + + + {/* Band C: Interfaces (API/Events tabs) */} + void', + desc: 'Change handler' + }, + { + name: 'tokens', + type: 'Array', + desc: 'Available token components' + }, + { + name: 'placeholder', + type: 'string', + def: '""', + desc: 'Input placeholder' + } + ]} + eventRows={[ + { + name: 'onTokenAdd', + type: '(token: Token) => void', + desc: 'When a token is created' + }, + { + name: 'onTokenRemove', + type: '(token: Token) => void', + desc: 'When a token is removed' + } + ]} + /> + + {/* Right lane: continuous Spec Rail */} + + + textbox + + ) + }, + { label: 'Name', value: 'Label or aria-label' }, + { + label: 'Keyboard', + value: 'Arrow navigation, Tab jump' + } + ] + }, + { + title: 'Performance', + rows: [ + { label: 'Bundle target', value: '< 8KB gz' }, + { label: 'Interaction', value: '< 16ms key handling' } + ] + }, + { + title: 'Links', + rows: [ + { + label: 'Storybook', + value: ( + + Open + + ) + }, + { + label: 'Repository', + value: ( + + GitHub + + ) + } + ] + } + ]} + /> + + + + + + + } + title="Chrono" + badge={ + + Component + + } + /> + + + + + + + + + + + (1) Start date field + (2) End date field + (3) Preset selector + + + + + Interaction map + + + + Key + + + Scope + + + Effect + + + →/← + + Field + + Navigate fields + + + ↑/↓ + + Field + Adjust values + + Enter + + Global + + Confirm selection + + + + void', + desc: 'Change handler' + }, + { + name: 'presets', + type: 'Array', + desc: 'Available presets' + }, + { + name: 'timezone', + type: 'string', + def: 'local', + desc: 'Display timezone' + } + ]} + eventRows={[ + { + name: 'onPresetSelect', + type: '(name: string) => void', + desc: 'When a preset is chosen' + }, + { + name: 'onInputFocus', + type: '(field: "from" | "to") => void', + desc: 'When a field receives focus' + } + ]} + /> + + + + group + + ) + }, + { label: 'Name', value: 'Form labels for fields' }, + { + label: 'Keyboard', + value: 'Arrow/Tab navigation across segments' + } + ] + }, + { + title: 'Performance', + rows: [ + { label: 'Bundle target', value: '< 10KB gz' }, + { label: 'Interaction', value: '< 16ms key handling' } + ] + }, + { + title: 'Links', + rows: [ + { + label: 'Storybook', + value: ( + + Open + + ) + }, + { + label: 'Repository', + value: ( + + GitHub + + ) + } + ] + } + ]} + /> + + + + + + + + + + + + Import + + + + + + Links + + + + Storybook + + + + GitHub + + + + + + + + + + + + + + + ) +} diff --git a/landing/pages/v2/+config.ts b/landing/pages/v2/+config.ts new file mode 100644 index 0000000..63de0fa --- /dev/null +++ b/landing/pages/v2/+config.ts @@ -0,0 +1,3 @@ +export default { + prerender: true +} diff --git a/landing/renderer/+onRenderHtml.tsx b/landing/renderer/+onRenderHtml.tsx index 767ce53..b3a178b 100644 --- a/landing/renderer/+onRenderHtml.tsx +++ b/landing/renderer/+onRenderHtml.tsx @@ -5,6 +5,7 @@ import type { PageContextServer } from 'vike/types' import type { ComponentType } from 'react' export function onRenderHtml(pageContext: PageContextServer) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any const Page = pageContext.Page as ComponentType const { pageProps } = pageContext as PageContextServer & { @@ -27,7 +28,7 @@ export function onRenderHtml(pageContext: PageContextServer) { @bizarre/ui - + ${dangerouslySkipEscape(pageHtml)} ` diff --git a/landing/tailwind.config.ts b/landing/tailwind.config.ts index 88fbb88..3f5844a 100644 --- a/landing/tailwind.config.ts +++ b/landing/tailwind.config.ts @@ -1,4 +1,3 @@ -// landing/tailwind.config.ts import type { Config } from 'tailwindcss' export default { @@ -6,10 +5,56 @@ export default { './pages/**/*.{js,ts,jsx,tsx}', './renderer/**/*.{js,ts,jsx,tsx}', './components/**/*.{js,ts,jsx,tsx}' - // Add other paths to your components if needed ], theme: { - extend: {} + extend: { + colors: { + cream: { + DEFAULT: '#FAF7F2', + dark: '#F0EBE3', + darker: '#E5DFD5' + }, + ink: { + DEFAULT: '#1a1816', + light: '#3d3835', + muted: '#6b6460', + faint: '#a39d98' + }, + coral: { + DEFAULT: '#E85D4C', + dark: '#C94D3E', + light: '#F18B7E' + }, + navy: { + DEFAULT: '#1E3A5F', + light: '#2D5A8A', + dark: '#152A45' + }, + sage: { + DEFAULT: '#7D9F8E', + light: '#9BB8A9', + dark: '#5F7D6C' + }, + amber: { + DEFAULT: '#D4A853', + light: '#E5C47A', + dark: '#B88F3D' + } + }, + fontFamily: { + serif: ['Instrument Serif', 'Georgia', 'serif'], + sans: ['DM Sans', 'system-ui', 'sans-serif'], + mono: ['JetBrains Mono', 'monospace'] + }, + animation: { + 'fade-up': 'fade-up 0.6s cubic-bezier(0.22, 1, 0.36, 1) forwards', + 'fade-in': 'fade-in 0.5s ease-out forwards', + 'slide-in': + 'slide-in-right 0.5s cubic-bezier(0.22, 1, 0.36, 1) forwards', + float: 'float 3s ease-in-out infinite', + 'pulse-soft': 'pulse-soft 2s ease-in-out infinite' + } + } }, plugins: [] } satisfies Config diff --git a/package.json b/package.json index 9f84fc7..ed093c8 100644 --- a/package.json +++ b/package.json @@ -17,16 +17,21 @@ "types": "./dist/index.d.ts", "require": "./dist/index/index.cjs.js" }, - "./timeslice": { - "import": "./dist/timeslice/index.es.js", - "types": "./dist/timeslice.d.ts", - "require": "./dist/timeslice/index.cjs.js" + "./chrono": { + "import": "./dist/chrono/index.es.js", + "types": "./dist/chrono.d.ts", + "require": "./dist/chrono/index.cjs.js" + }, + "./inlay": { + "import": "./dist/inlay/index.es.js", + "types": "./dist/inlay.d.ts", + "require": "./dist/inlay/index.cjs.js" } }, "typesVersions": { "*": { - "timeslice": [ - "./dist/timeslice.d.ts" + "chrono": [ + "./dist/chrono.d.ts" ] } }, @@ -53,7 +58,7 @@ "license": "MIT", "scripts": { "build": "vite build --config vite.lib.config.ts", - "storybook": "storybook dev -p 6006", + "storybook": "storybook dev -p 6006 --host 0.0.0.0", "build-storybook": "storybook build", "typecheck": "tsc --emitDeclarationOnly", "lint": "eslint ./src --ext .ts,.tsx", @@ -61,24 +66,34 @@ "test": "vitest", "test:run": "vitest run", "test:ui": "vitest --ui", - "lint-staged": "lint-staged" + "lint-staged": "lint-staged", + "test:ct": "playwright test -c playwright-ct.config.mts", + "test:ct:ui": "playwright test -c playwright-ct.config.mts --ui", + "playwright:install": "playwright install --with-deps", + "test-ct": "playwright test -c playwright-ct.config.mts" }, "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" + "react": "^19.0.0", + "react-dom": "^19.0.0" }, "dependencies": { "@formatjs/intl-datetimeformat": "^6.18.0", "@radix-ui/react-compose-refs": "^1.1.2", "@radix-ui/react-context": "^1.1.2", "@radix-ui/react-dismissable-layer": "^1.1.9", - "@radix-ui/react-slot": "^1.2.2", + "@radix-ui/react-popover": "^1.1.14", + "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-use-controllable-state": "^1.2.2", "chrono-node": "^2.8.0", "date-fns": "^4.1.0", + "graphemer": "^1.4.0", "timezone-enum": "^1.0.4" }, "devDependencies": { + "@axe-core/playwright": "^4.11.0", + "@heroicons/react": "^2.2.0", + "@playwright/experimental-ct-react": "^1.54.2", + "@playwright/test": "^1.54.2", "@semantic-release/changelog": "^6.0.3", "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/git": "^10.0.1", @@ -93,11 +108,12 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^13.5.0", + "@types/node": "^24.2.1", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", "@typescript-eslint/eslint-plugin": "^8.32.0", "@typescript-eslint/parser": "^8.32.0", - "@vitejs/plugin-react-swc": "^3.9.0", + "@vitejs/plugin-react-swc": "^4.0.0", "@vitest/ui": "^3.1.3", "ajv": "^8.17.1", "autoprefixer": "^10.4.21", @@ -117,6 +133,7 @@ "storybook": "^8.6.12", "tailwindcss": "^4.1.5", "typescript": "^5.3.3", + "typescript-eslint": "^8.39.1", "vite": "^6.3.5", "vite-plugin-dts": "^4.5.3", "vitest": "^3.1.3" diff --git a/playwright-ct.config.mts b/playwright-ct.config.mts new file mode 100644 index 0000000..af56f0f --- /dev/null +++ b/playwright-ct.config.mts @@ -0,0 +1,64 @@ +import { defineConfig, devices } from '@playwright/experimental-ct-react' +import react from '@vitejs/plugin-react-swc' + +export default defineConfig({ + testDir: './', + testMatch: /.*\.spec\.tsx?$/, + snapshotDir: './__snapshots__', + timeout: 10 * 1000, + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + + reporter: 'list', // never open the HTML reporter + + use: { + trace: 'on-first-retry', + ctPort: 3100, + + ctViteConfig: { + plugins: [react()] + } + }, + + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + permissions: ['clipboard-read', 'clipboard-write'] + } + }, + { + name: 'firefox', + use: { + ...devices['Desktop Firefox'], + launchOptions: { + firefoxUserPrefs: { + // Enable clipboard testing in Firefox + 'dom.events.testing.asyncClipboard': true, + 'dom.events.asyncClipboard.readText': true, + 'dom.events.asyncClipboard.clipboardItem': true + } + } + } + }, + { + name: 'webkit', + use: { ...devices['Desktop Safari'] } + }, + // Mobile device projects for touch/mobile testing + { + name: 'mobile-chrome', + use: { + ...devices['Pixel 5'], + permissions: ['clipboard-read', 'clipboard-write'] + } + }, + { + name: 'mobile-safari', + use: { ...devices['iPhone 14'] } + } + ] +}) diff --git a/playwright/index.html b/playwright/index.html new file mode 100644 index 0000000..610ddf8 --- /dev/null +++ b/playwright/index.html @@ -0,0 +1,12 @@ + + + + + + Testing Page + + + + + + diff --git a/playwright/index.tsx b/playwright/index.tsx new file mode 100644 index 0000000..ac6de14 --- /dev/null +++ b/playwright/index.tsx @@ -0,0 +1,2 @@ +// Import styles, initialize component theme here. +// import '../src/common.css'; diff --git a/src/timeslice/time-slice.stories.tsx b/src/chrono/chrono.stories.tsx similarity index 53% rename from src/timeslice/time-slice.stories.tsx rename to src/chrono/chrono.stories.tsx index 8c9df43..97ae3fe 100644 --- a/src/timeslice/time-slice.stories.tsx +++ b/src/chrono/chrono.stories.tsx @@ -1,57 +1,52 @@ import type { Meta } from '@storybook/react' -import { TimeSlice } from '..' -import type { TimeSliceProps } from '.' +import { Chrono } from '..' +import type { ChronoProps } from '.' import * as React from 'react' -const meta: Meta = { - component: TimeSlice.Root +const meta: Meta = { + component: Chrono.Root } export default meta export const Basic = () => { - const [dateRange, setDateRange] = React.useState( - { - startDate: undefined, - endDate: undefined - } - ) + const [dateRange, setDateRange] = React.useState({ + startDate: undefined, + endDate: undefined + }) - const onDateRangeChange = (dateRange: TimeSliceProps['dateRange']) => { + const onDateRangeChange = (dateRange: ChronoProps['dateRange']) => { setDateRange(dateRange) } return ( <> - - - + + - + 15 minutes - - + 1 hour - - + + 1 day - - + 1 year - - - + + + {JSON.stringify(dateRange, null, 2)} > @@ -59,47 +54,42 @@ export const Basic = () => { } export const Absolute = () => { - const [dateRange, setDateRange] = React.useState( - { - startDate: undefined, - endDate: undefined - } - ) + const [dateRange, setDateRange] = React.useState({ + startDate: undefined, + endDate: undefined + }) - const onDateRangeChange = (dateRange: TimeSliceProps['dateRange']) => { + const onDateRangeChange = (dateRange: ChronoProps['dateRange']) => { setDateRange(dateRange) } return ( <> - - - + + - + 15 minutes - - + 1 hour - - + + 1 day - - + 1 year - - - + + + {JSON.stringify(dateRange, null, 2)} > @@ -107,31 +97,29 @@ export const Absolute = () => { } export const WithFutureShortcuts = () => { - const [dateRange, setDateRange] = React.useState( - { - startDate: undefined, - endDate: undefined - } - ) + const [dateRange, setDateRange] = React.useState({ + startDate: undefined, + endDate: undefined + }) return ( <> - - - + + - + 15 minutes - - + Next hour - - - + + + {JSON.stringify(dateRange, null, 2)} > @@ -139,17 +127,15 @@ export const WithFutureShortcuts = () => { } export const Controlled = () => { - const [dateRange, setDateRange] = React.useState( - { - startDate: undefined, - endDate: undefined - } - ) + const [dateRange, setDateRange] = React.useState({ + startDate: undefined, + endDate: undefined + }) return ( <> Prevents future dates via controlled state - { // prevent future dates if (startDate && endDate && endDate > new Date()) { @@ -160,33 +146,30 @@ export const Controlled = () => { }} dateRange={dateRange} > - - + - + 15 minutes - - + 1 hour - - + + 1 day - - + 1 year - - - + + + {JSON.stringify(dateRange, null, 2)} > @@ -194,14 +177,12 @@ export const Controlled = () => { } export const DataDog = () => { - const [dateRange, setDateRange] = React.useState( - { - startDate: new Date(Date.now() - 1000 * 60 * 5), - endDate: new Date() - } - ) + const [dateRange, setDateRange] = React.useState({ + startDate: new Date(Date.now() - 1000 * 60 * 5), + endDate: new Date() + }) - const onDateRangeChange = (dateRange: TimeSliceProps['dateRange']) => { + const onDateRangeChange = (dateRange: ChronoProps['dateRange']) => { setDateRange(dateRange) } @@ -236,11 +217,11 @@ export const DataDog = () => { return ( - - + UTC-04:00 @@ -252,24 +233,24 @@ export const DataDog = () => { - + - - - + + + 15 minutes - - + + 1 hour - - + + 1 day - - + + 1 year - - - + + + ) } diff --git a/src/timeslice/time-slice.test.tsx b/src/chrono/chrono.test.tsx similarity index 92% rename from src/timeslice/time-slice.test.tsx rename to src/chrono/chrono.test.tsx index 95a31b7..0406baa 100644 --- a/src/timeslice/time-slice.test.tsx +++ b/src/chrono/chrono.test.tsx @@ -1,25 +1,19 @@ import React from 'react' import { render, screen, fireEvent } from '@testing-library/react' -import { - Root as TimeSlice, - Input, - Portal, - Trigger, - Shortcut -} from './time-slice' +import { Root as Chrono, Input, Portal, Trigger, Shortcut } from './chrono' import '@testing-library/jest-dom' import { vi } from 'vitest' -describe('TimeSlice Component Family', () => { - describe('TimeSlice (Root)', () => { +describe('Chrono Component Family', () => { + describe('Chrono (Root)', () => { it('should render without crashing with minimal props', () => { render( - + Portal Content - + ) expect(screen.getByRole('combobox')).toBeInTheDocument() @@ -27,12 +21,12 @@ describe('TimeSlice Component Family', () => { it('should be closed by default', () => { render( - + Portal Content - + ) expect(screen.getByTestId('portal-closed-default')).toHaveStyle({ display: 'none' @@ -41,12 +35,12 @@ describe('TimeSlice Component Family', () => { it('should respect defaultOpen prop', () => { render( - + Portal Content - + ) expect(screen.getByText('Portal Content')).toBeVisible() }) @@ -55,7 +49,7 @@ describe('TimeSlice Component Family', () => { const handleOpenChange = vi.fn() const mockOnDateRangeChange = vi.fn() const { rerender } = render( - { Portal Content - + ) expect(screen.getByTestId('portal-controlled-open')).toHaveStyle({ display: 'none' }) rerender( - { Portal Content - + ) expect(screen.getByTestId('portal-controlled-open')).not.toHaveStyle({ display: 'none' @@ -93,13 +87,13 @@ describe('TimeSlice Component Family', () => { const endDate = new Date('2024-01-01T01:00:00Z') const handleDateRangeChange = vi.fn() render( - - + ) expect(screen.getByRole('combobox')).toHaveValue( @@ -114,7 +108,7 @@ describe('TimeSlice Component Family', () => { const mockOnOpenChange = vi.fn() const { rerender } = render( - { Portal - + ) expect(screen.getByRole('combobox')).toHaveValue( 'Feb 10, 10:00\u202FAM – Feb 10, 12:00\u202FPM' ) rerender( - { Portal - + ) expect(handleDateRangeConfirm).toHaveBeenCalledWith({ startDate, @@ -154,12 +148,12 @@ describe('TimeSlice Component Family', () => { const startDateNY = new Date('2024-01-01T12:00:00Z') const endDateNY = new Date('2024-01-01T14:00:00Z') render( - - + ) expect(screen.getByRole('combobox')).toHaveValue( @@ -198,11 +192,11 @@ describe('TimeSlice Component Family', () => { const expectedDisplayValue = `${expectedStartString} – ${expectedEndString}` render( - - + ) expect(screen.getByRole('combobox')).toHaveValue(expectedDisplayValue) @@ -217,12 +211,9 @@ describe('TimeSlice Component Family', () => { return 'Custom Empty' }) render( - + - + ) expect(customFormat).toHaveBeenCalledWith({ @@ -235,15 +226,15 @@ describe('TimeSlice Component Family', () => { }) }) - describe('TimeSliceTrigger', () => { + describe('ChronoTrigger', () => { it('should render a div by default and focus input on click', () => { render( - + Click Me - + ) const triggerElement = screen.getByText('Click Me').parentElement @@ -257,12 +248,12 @@ describe('TimeSlice Component Family', () => { it('should render as child and forward props when asChild is true', () => { render( - + Custom Button - + ) const triggerButton = screen.getByRole('button', { @@ -278,14 +269,14 @@ describe('TimeSlice Component Family', () => { }) }) - describe('TimeSliceInput', () => { + describe('ChronoInput', () => { const initialStartDate = new Date('2024-07-04T10:00:00Z') const initialEndDate = new Date('2024-07-04T12:00:00Z') const initialFormattedValue = 'Jul 4, 10:00\u202FAM – Jul 4, 12:00\u202FPM' it('should render with initial value from context and open portal on focus', () => { render( - { Portal Content For Input - + ) const inputEl = screen.getByTestId('input-control') expect(inputEl).toHaveValue(initialFormattedValue) @@ -310,9 +301,9 @@ describe('TimeSlice Component Family', () => { it('should update dateRange on valid input change', () => { const handleDateRangeChange = vi.fn() render( - + - + ) const inputEl = screen.getByTestId('input-change') fireEvent.change(inputEl, { @@ -328,7 +319,7 @@ describe('TimeSlice Component Family', () => { it('should clear dateRange on empty input change', () => { const handleDateRangeChange = vi.fn() render( - { onDateRangeChange={handleDateRangeChange} > - + ) const inputEl = screen.getByTestId('input-clear') fireEvent.change(inputEl, { target: { value: '' } }) @@ -348,14 +339,14 @@ describe('TimeSlice Component Family', () => { it('should call useSegmentNavigation handleKeyDown on key press', () => { render( - - + ) const inputEl = screen.getByTestId('input-keydown') fireEvent.focus(inputEl) @@ -365,11 +356,11 @@ describe('TimeSlice Component Family', () => { it('should render as child and forward props, maintaining functionality', () => { const handleDateRangeChange = vi.fn() render( - + - + ) const textareaEl = screen.getByTestId('custom-input-aschild') expect(textareaEl.tagName).toBe('TEXTAREA') @@ -387,15 +378,15 @@ describe('TimeSlice Component Family', () => { }) }) - describe('TimeSlicePortal', () => { + describe('ChronoPortal', () => { it('should not render if open is false', () => { render( - + Portal Content Here - + ) expect(screen.getByTestId('portal-visibility-test')).toHaveStyle({ display: 'none' @@ -404,21 +395,21 @@ describe('TimeSlice Component Family', () => { it('should render if open is true', () => { render( - + {' '} {/* Or open={true} */} Portal Visible - + ) expect(screen.getByText('Portal Visible')).toBeVisible() }) it('Escape key should close portal and focus input', () => { render( - + {/* Ensure there's a genuinely focusable child for the event target */} @@ -430,7 +421,7 @@ describe('TimeSlice Component Family', () => { Some other content - + ) const focusableChild = screen.getByTestId('focusable-child-in-portal') const inputEl = screen.getByTestId('portal-input-escape') @@ -448,7 +439,7 @@ describe('TimeSlice Component Family', () => { const setupPortalWithItems = () => { const onItemClick = vi.fn() render( - + { Item 3 - + ) return { item1: screen.getByTestId('item1'), @@ -550,12 +541,12 @@ describe('TimeSlice Component Family', () => { it('should render as child and forward props, maintaining functionality', () => { render( - + Custom Portal Section - + ) const portalSection = screen.getByTestId('custom-portal-aschild') expect(portalSection.tagName).toBe('SECTION') @@ -563,7 +554,7 @@ describe('TimeSlice Component Family', () => { }) }) - describe('TimeSliceShortcut', () => { + describe('ChronoShortcut', () => { const mockSetDateRange = vi.fn() const mockSetIsRelative = vi.fn() const mockSetOpen = vi.fn() @@ -598,7 +589,7 @@ describe('TimeSlice Component Family', () => { mockInputRef.current.blur = mockInputBlur render( - { - + ) const inputElement = screen.getByTestId(inputTestId) inputElement.focus() @@ -662,7 +653,7 @@ describe('TimeSlice Component Family', () => { const mockSetDateRange = vi.fn() const mockSetOpen = vi.fn() render( - { Past Hour Custom - + ) const textareaElement = screen.getByTestId(inputId) textareaElement.focus() diff --git a/src/timeslice/time-slice.tsx b/src/chrono/chrono.tsx similarity index 79% rename from src/timeslice/time-slice.tsx rename to src/chrono/chrono.tsx index 791e0d4..8546ce4 100644 --- a/src/timeslice/time-slice.tsx +++ b/src/chrono/chrono.tsx @@ -6,7 +6,7 @@ import { DismissableLayer } from '@radix-ui/react-dismissable-layer' import { Slot } from '@radix-ui/react-slot' import { sub, add, Duration } from 'date-fns' import React, { useCallback, useMemo, useId, useState, useEffect } from 'react' -import { useTimeSliceState, type DateRange } from './hooks/use-time-slice-state' +import { useChronoState, type DateRange } from './hooks/use-chrono-state' import { useSegmentNavigation, buildSegments @@ -16,12 +16,12 @@ import { formatTimeRange } from './utils/time-range' export type TimeZone = keyof typeof Timezone -const COMPONENT_NAME = 'TimeSlice' +const COMPONENT_NAME = 'Chrono' type ScopedProps = P & { __scope?: Scope } -const [createTimeSliceContext] = createContextScope(COMPONENT_NAME) +const [createChronoContext] = createContextScope(COMPONENT_NAME) -type TimeSliceContextValue = { +type ChronoContextValue = { timeZone: TimeZone inputRef: React.RefObject open: boolean @@ -42,10 +42,10 @@ type TimeSliceContextValue = { portalId?: string } -const [TimeSliceProvider, useTimeSliceContext] = - createTimeSliceContext(COMPONENT_NAME) +const [ChronoProvider, useChronoContext] = + createChronoContext(COMPONENT_NAME) -type TimeSliceProps = ScopedProps<{ +type ChronoProps = ScopedProps<{ children: React.ReactNode timeZone?: TimeZone open?: boolean @@ -65,7 +65,7 @@ type TimeSliceProps = ScopedProps<{ onDateRangeConfirm?: (range: DateRange) => void }> -const TimeSlice: React.FC = ({ +const Chrono: React.FC = ({ children, __scope, formatInput: formatInputProp, @@ -80,7 +80,7 @@ const TimeSlice: React.FC = ({ setOpen, dateRange, setDateRange: setDateRangeInternal - } = useTimeSliceState(stateProps) + } = useChronoState(stateProps) const calculateIsRelative = useCallback((range: DateRange): boolean => { let shouldBeRelative = false @@ -213,7 +213,7 @@ const TimeSlice: React.FC = ({ return ( - = ({ portalId={portalId} > {children} - + ) } -type TimeSliceTriggerProps = ScopedProps<{ +type ChronoTriggerProps = ScopedProps<{ children: React.ReactNode asChild?: boolean }> & Omit, 'onClick'> -const TimeSliceTrigger = React.forwardRef< - HTMLDivElement, - TimeSliceTriggerProps ->(({ asChild, children, __scope, ...props }, forwardedRef) => { - const { inputRef } = useTimeSliceContext(COMPONENT_NAME, __scope) +const ChronoTrigger = React.forwardRef( + ({ asChild, children, __scope, ...props }, forwardedRef) => { + const { inputRef } = useChronoContext(COMPONENT_NAME, __scope) - const onClick = useCallback(() => { - if (inputRef.current) { - inputRef.current.focus() - } - }, [inputRef]) + const onClick = useCallback(() => { + if (inputRef.current) { + inputRef.current.focus() + } + }, [inputRef]) - const Comp = asChild ? Slot : 'div' + const Comp = asChild ? Slot : 'div' - return ( - - {children} - - ) -}) + return ( + + {children} + + ) + } +) -type TimeSliceInputProps = ScopedProps<{ +type ChronoInputProps = ScopedProps<{ children?: React.ReactNode asChild?: boolean }> & @@ -271,9 +270,9 @@ type TimeSliceInputProps = ScopedProps<{ 'onChange' | 'onKeyDown' | 'onFocus' | 'onClick' > -const TimeSliceInput = React.forwardRef( +const ChronoInput = React.forwardRef( ({ asChild, children, __scope, ...props }, forwardedRef) => { - const context = useTimeSliceContext(COMPONENT_NAME, __scope) + const context = useChronoContext(COMPONENT_NAME, __scope) const internalInputRef = React.useRef(null) const composedInputRef = composeRefs( forwardedRef, @@ -394,16 +393,16 @@ const TimeSliceInput = React.forwardRef( } ) -type TimeSlicePortalProps = ScopedProps<{ +type ChronoPortalProps = ScopedProps<{ children: React.ReactNode asChild?: boolean ariaLabel?: string }> & React.HTMLAttributes -const TimeSlicePortal = React.forwardRef( +const ChronoPortal = React.forwardRef( ({ asChild, children, __scope, ariaLabel, ...props }, forwardedRef) => { - const context = useTimeSliceContext(COMPONENT_NAME, __scope) + const context = useChronoContext(COMPONENT_NAME, __scope) const handleKeyDownInPortal = useCallback( (e: React.KeyboardEvent) => { @@ -488,7 +487,7 @@ const TimeSlicePortal = React.forwardRef( } ) -type TimeSliceShortcutProps = ScopedProps<{ +type ChronoShortcutProps = ScopedProps<{ children: React.ReactNode duration: Partial<{ years: number @@ -502,73 +501,72 @@ type TimeSliceShortcutProps = ScopedProps<{ }> & Omit, 'onClick'> -const TimeSliceShortcut = React.forwardRef< - HTMLDivElement, - TimeSliceShortcutProps ->(({ asChild, children, __scope, duration, ...props }, forwardedRef) => { - const { setDateRange, setInternalIsRelative, setOpen, inputRef } = - useTimeSliceContext(COMPONENT_NAME, __scope) - - const handleClick = useCallback( - (e: React.MouseEvent) => { - e.preventDefault() - const now = new Date() - let finalStartDate: Date - let finalEndDate: Date - - const isFutureIntent = Object.values(duration).some( - (val) => val !== undefined && val < 0 - ) - - const normalizedDuration: Duration = {} - ;(Object.keys(duration) as Array).forEach( - (key) => { - const value = duration[key] - if (value !== undefined) { - normalizedDuration[key] = Math.abs(value) +const ChronoShortcut = React.forwardRef( + ({ asChild, children, __scope, duration, ...props }, forwardedRef) => { + const { setDateRange, setInternalIsRelative, setOpen, inputRef } = + useChronoContext(COMPONENT_NAME, __scope) + + const handleClick = useCallback( + (e: React.MouseEvent) => { + e.preventDefault() + const now = new Date() + let finalStartDate: Date + let finalEndDate: Date + + const isFutureIntent = Object.values(duration).some( + (val) => val !== undefined && val < 0 + ) + + const normalizedDuration: Duration = {} + ;(Object.keys(duration) as Array).forEach( + (key) => { + const value = duration[key] + if (value !== undefined) { + normalizedDuration[key] = Math.abs(value) + } } + ) + + if (isFutureIntent) { + finalStartDate = now + finalEndDate = add(now, normalizedDuration) + } else { + finalStartDate = sub(now, normalizedDuration) + finalEndDate = now } - ) - - if (isFutureIntent) { - finalStartDate = now - finalEndDate = add(now, normalizedDuration) - } else { - finalStartDate = sub(now, normalizedDuration) - finalEndDate = now - } - setInternalIsRelative(true) - setDateRange({ startDate: finalStartDate, endDate: finalEndDate }) - setOpen(false) - if (inputRef.current) { - inputRef.current.blur() - } - }, - [setDateRange, setInternalIsRelative, setOpen, duration, inputRef] - ) + setInternalIsRelative(true) + setDateRange({ startDate: finalStartDate, endDate: finalEndDate }) + setOpen(false) + if (inputRef.current) { + inputRef.current.blur() + } + }, + [setDateRange, setInternalIsRelative, setOpen, duration, inputRef] + ) - const optionPropsAria = { - ...props, - role: 'option', - 'aria-selected': false, - ref: forwardedRef, - onClick: handleClick, - tabIndex: -1, - 'data-shortcut-item': 'true', - style: { cursor: 'pointer', ...props.style } - } + const optionPropsAria = { + ...props, + role: 'option', + 'aria-selected': false, + ref: forwardedRef, + onClick: handleClick, + tabIndex: -1, + 'data-shortcut-item': 'true', + style: { cursor: 'pointer', ...props.style } + } - const Comp = asChild ? Slot : 'div' + const Comp = asChild ? Slot : 'div' - return {children} -}) + return {children} + } +) -const Root = TimeSlice -const Trigger = TimeSliceTrigger -const Input = TimeSliceInput -const Portal = TimeSlicePortal -const Shortcut = TimeSliceShortcut +const Root = Chrono +const Trigger = ChronoTrigger +const Input = ChronoInput +const Portal = ChronoPortal +const Shortcut = ChronoShortcut export { Root, @@ -576,9 +574,9 @@ export { Input, Portal, Shortcut, - type TimeSliceProps, - type TimeSliceInputProps, - type TimeSlicePortalProps, - type TimeSliceShortcutProps, + type ChronoProps, + type ChronoInputProps, + type ChronoPortalProps, + type ChronoShortcutProps, type DateRange } diff --git a/src/timeslice/hooks/use-time-slice-state.ts b/src/chrono/hooks/use-chrono-state.ts similarity index 92% rename from src/timeslice/hooks/use-time-slice-state.ts rename to src/chrono/hooks/use-chrono-state.ts index 537b0a8..405f1ad 100644 --- a/src/timeslice/hooks/use-time-slice-state.ts +++ b/src/chrono/hooks/use-chrono-state.ts @@ -6,7 +6,7 @@ export type DateRange = { endDate?: Date } -type UseTimeSliceStateProps = { +type UseChronoStateProps = { open?: boolean defaultOpen?: boolean onOpenChange?: (open: boolean) => void @@ -15,10 +15,10 @@ type UseTimeSliceStateProps = { onDateRangeChange?: (range: DateRange) => void } -export function useTimeSliceState({ +export function useChronoState({ onDateRangeChange, ...props -}: UseTimeSliceStateProps) { +}: UseChronoStateProps) { const [open, setOpen] = useControllableState({ prop: props.open, defaultProp: props.defaultOpen ?? false, diff --git a/src/timeslice/hooks/use-segment-navigation/__tests__/date-adjustments.test.ts b/src/chrono/hooks/use-segment-navigation/__tests__/date-adjustments.test.ts similarity index 100% rename from src/timeslice/hooks/use-segment-navigation/__tests__/date-adjustments.test.ts rename to src/chrono/hooks/use-segment-navigation/__tests__/date-adjustments.test.ts diff --git a/src/timeslice/hooks/use-segment-navigation/__tests__/environment.test.ts b/src/chrono/hooks/use-segment-navigation/__tests__/environment.test.ts similarity index 100% rename from src/timeslice/hooks/use-segment-navigation/__tests__/environment.test.ts rename to src/chrono/hooks/use-segment-navigation/__tests__/environment.test.ts diff --git a/src/timeslice/hooks/use-segment-navigation/__tests__/initial-state.test.ts b/src/chrono/hooks/use-segment-navigation/__tests__/initial-state.test.ts similarity index 97% rename from src/timeslice/hooks/use-segment-navigation/__tests__/initial-state.test.ts rename to src/chrono/hooks/use-segment-navigation/__tests__/initial-state.test.ts index cc87c15..5816dda 100644 --- a/src/timeslice/hooks/use-segment-navigation/__tests__/initial-state.test.ts +++ b/src/chrono/hooks/use-segment-navigation/__tests__/initial-state.test.ts @@ -5,7 +5,7 @@ import { buildSegments // type Segment // Not used } from '../use-segment-navigation' -// import type { DateRange } from '../../use-time-slice-state' // Not used +// import type { DateRange } from '../../use-chrono-state' // Not used import { // createMockInputRef, getHook as getHookFromUtils diff --git a/src/timeslice/hooks/use-segment-navigation/__tests__/keyboard-navigation.test.ts b/src/chrono/hooks/use-segment-navigation/__tests__/keyboard-navigation.test.ts similarity index 100% rename from src/timeslice/hooks/use-segment-navigation/__tests__/keyboard-navigation.test.ts rename to src/chrono/hooks/use-segment-navigation/__tests__/keyboard-navigation.test.ts diff --git a/src/timeslice/hooks/use-segment-navigation/__tests__/keydown-interactions.test.ts b/src/chrono/hooks/use-segment-navigation/__tests__/keydown-interactions.test.ts similarity index 99% rename from src/timeslice/hooks/use-segment-navigation/__tests__/keydown-interactions.test.ts rename to src/chrono/hooks/use-segment-navigation/__tests__/keydown-interactions.test.ts index cbb9dde..e396111 100644 --- a/src/timeslice/hooks/use-segment-navigation/__tests__/keydown-interactions.test.ts +++ b/src/chrono/hooks/use-segment-navigation/__tests__/keydown-interactions.test.ts @@ -6,7 +6,7 @@ import { // type DateSegmentType, // Not used type Segment // Used in Tab suite helper } from '../use-segment-navigation' -// import type { DateRange } from '../../use-time-slice-state'; // Not used +// import type { DateRange } from '../../use-chrono-state'; // Not used import { createMockKeyboardEvent, getHook as getHookFromUtils diff --git a/src/timeslice/hooks/use-segment-navigation/__tests__/manual-input.test.ts b/src/chrono/hooks/use-segment-navigation/__tests__/manual-input.test.ts similarity index 100% rename from src/timeslice/hooks/use-segment-navigation/__tests__/manual-input.test.ts rename to src/chrono/hooks/use-segment-navigation/__tests__/manual-input.test.ts diff --git a/src/timeslice/hooks/use-segment-navigation/__tests__/segment-selection.test.ts b/src/chrono/hooks/use-segment-navigation/__tests__/segment-selection.test.ts similarity index 100% rename from src/timeslice/hooks/use-segment-navigation/__tests__/segment-selection.test.ts rename to src/chrono/hooks/use-segment-navigation/__tests__/segment-selection.test.ts diff --git a/src/timeslice/hooks/use-segment-navigation/__tests__/timezone-specific.test.ts b/src/chrono/hooks/use-segment-navigation/__tests__/timezone-specific.test.ts similarity index 100% rename from src/timeslice/hooks/use-segment-navigation/__tests__/timezone-specific.test.ts rename to src/chrono/hooks/use-segment-navigation/__tests__/timezone-specific.test.ts diff --git a/src/timeslice/hooks/use-segment-navigation/index.ts b/src/chrono/hooks/use-segment-navigation/index.ts similarity index 100% rename from src/timeslice/hooks/use-segment-navigation/index.ts rename to src/chrono/hooks/use-segment-navigation/index.ts diff --git a/src/timeslice/hooks/use-segment-navigation/test-utils.ts b/src/chrono/hooks/use-segment-navigation/test-utils.ts similarity index 99% rename from src/timeslice/hooks/use-segment-navigation/test-utils.ts rename to src/chrono/hooks/use-segment-navigation/test-utils.ts index e7415a7..7b8af28 100644 --- a/src/timeslice/hooks/use-segment-navigation/test-utils.ts +++ b/src/chrono/hooks/use-segment-navigation/test-utils.ts @@ -2,7 +2,7 @@ import { vi, type MockedFunction } from 'vitest' import type React from 'react' import { renderHook } from '@testing-library/react' import { useSegmentNavigation } from './use-segment-navigation' -import type { DateRange } from '../use-time-slice-state' +import type { DateRange } from '../use-chrono-state' import { act } from '@testing-library/react' import { buildSegments as buildSegmentsInternal, diff --git a/src/timeslice/hooks/use-segment-navigation/use-segment-navigation.ts b/src/chrono/hooks/use-segment-navigation/use-segment-navigation.ts similarity index 99% rename from src/timeslice/hooks/use-segment-navigation/use-segment-navigation.ts rename to src/chrono/hooks/use-segment-navigation/use-segment-navigation.ts index 748ba3b..2d7a22a 100644 --- a/src/timeslice/hooks/use-segment-navigation/use-segment-navigation.ts +++ b/src/chrono/hooks/use-segment-navigation/use-segment-navigation.ts @@ -1,5 +1,5 @@ import React, { useCallback, useMemo, useEffect, useState } from 'react' -import type { DateRange } from '../use-time-slice-state' +import type { DateRange } from '../use-chrono-state' import { addMonths, addDays, addHours, addMinutes, addYears } from 'date-fns' import '@formatjs/intl-datetimeformat/polyfill' import '@formatjs/intl-datetimeformat/locale-data/en' diff --git a/src/chrono/index.ts b/src/chrono/index.ts new file mode 100644 index 0000000..0d9927d --- /dev/null +++ b/src/chrono/index.ts @@ -0,0 +1,13 @@ +export { + Root, + Trigger, + Input, + Portal, + Shortcut, + type ChronoProps, + type ChronoInputProps, + type ChronoPortalProps, + type ChronoShortcutProps, + type DateRange, + type TimeZone +} from './chrono' diff --git a/src/timeslice/utils/date-parser.ts b/src/chrono/utils/date-parser.ts similarity index 96% rename from src/timeslice/utils/date-parser.ts rename to src/chrono/utils/date-parser.ts index d0a8928..365d8e7 100644 --- a/src/timeslice/utils/date-parser.ts +++ b/src/chrono/utils/date-parser.ts @@ -1,6 +1,6 @@ import * as chrono from 'chrono-node' import { fromUnixTime, isValid } from 'date-fns' -import type { DateRange } from '../hooks/use-time-slice-state' +import type { DateRange } from '../hooks/use-chrono-state' export function parseDateInput(value: string): DateRange { let parsed = chrono.parse(value, new Date()) diff --git a/src/timeslice/utils/time-range.ts b/src/chrono/utils/time-range.ts similarity index 100% rename from src/timeslice/utils/time-range.ts rename to src/chrono/utils/time-range.ts diff --git a/src/index.ts b/src/index.ts index 247e6ac..7e27701 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,10 @@ -export * as TimeSlice from './timeslice' +export * as Chrono from './chrono' +export { Inlay } from './inlay' +export type { + InlayProps, + InlayRef, + TokenState, + Plugin, + Matcher, + Match +} from './inlay' diff --git a/src/inlay/.gitignore b/src/inlay/.gitignore new file mode 100644 index 0000000..68c7b8c --- /dev/null +++ b/src/inlay/.gitignore @@ -0,0 +1,4 @@ +# Ignore local development notes +TODO.md +TODO.local.md +*.private.md \ No newline at end of file diff --git a/src/inlay/ARCHITECTURE.md b/src/inlay/ARCHITECTURE.md new file mode 100644 index 0000000..d9502c3 --- /dev/null +++ b/src/inlay/ARCHITECTURE.md @@ -0,0 +1,351 @@ +# Inlay Architecture + +Inlay is a React-based rich text editor primitive built on `contentEditable`. It provides controlled text input with support for embedded tokens—inline elements that represent structured data (mentions, tags, links) while maintaining a clean string-based value model. + +## Core Concept: Token Divergence + +The key architectural decision is **token divergence**: a token's visual representation can differ from its raw value. + +``` +Raw value: "Hello @alice_123, meet @bob_456" +Visual DOM: "Hello Alice, meet Bob" +``` + +This enables readable UI while preserving machine-readable identifiers in the underlying value. All cursor movement, selection, copy/paste, and deletion operations must account for this divergence. + +## Component Hierarchy + +``` +StructuredInlay (high-level, plugin-based) + └── Inlay.Root (core contentEditable wrapper) + ├── Inlay.Token (inline token markers) + └── Inlay.Portal (positioned overlays via Radix Popover) +``` + +### Exports + +All components are exported under the `Inlay` namespace: + +- `Inlay.Root` — Main editor component, wraps contentEditable +- `Inlay.Token` — Declares a token with `value` (raw) and `children` (visual) +- `Inlay.Portal` — Positioned popover anchored to selection or editor + - `Inlay.Portal.List` — Keyboard-navigable list container + - `Inlay.Portal.Item` — Selectable item within a Inlay.Portal.List +- `Inlay.StructuredInlay` — Higher-level component with plugin system + +Types are also exported: `InlayProps`, `InlayRef`, `TokenState`, `Plugin`, `Matcher`, `Match`. + +## Directory Structure + +``` +src/inlay/ +├── inlay.tsx # Core Root/Token/Portal components +├── portal-list.tsx # Portal.List/Item compound components +├── index.ts # Public exports +├── hooks/ +│ ├── use-clipboard.ts # Copy/cut/paste with token awareness +│ ├── use-composition.ts # IME composition handling (incl. iOS quirks) +│ ├── use-history.ts # Undo/redo stack +│ ├── use-key-handlers.ts# Keyboard input processing (incl. Android GBoard) +│ ├── use-placeholder-sync.ts +│ ├── use-selection.ts # Selection state tracking (incl. iOS selectionchange) +│ ├── use-selection-snap.ts # Cursor snapping to token boundaries +│ ├── use-token-weaver.tsx # Two-pass token rendering +│ ├── use-touch-selection.ts # Touch-based selection handling +│ └── use-virtual-keyboard.ts # Virtual keyboard detection (visualViewport) +├── internal/ +│ ├── dom-utils.ts # DOM traversal, offset calculation +│ └── string-utils.ts # Token matching/scanning +├── structured/ +│ ├── structured-inlay.tsx # Plugin-based wrapper +│ └── plugins/ +│ ├── plugin.ts # Plugin type definition +│ └── mentions.tsx # Example mentions plugin +├── __ct__/ # Playwright component tests +└── __tests__/ # Vitest unit tests +``` + +## Key Hooks + +### `useTokenWeaver` +Two-pass rendering system: +1. First pass: Children render invisibly to register tokens +2. Second pass: Tokens are "weaved" into the text at correct positions + +This solves the chicken-and-egg problem of needing to know token positions before rendering while also needing to render to know what tokens exist. + +**Empty state:** When the value is empty, a zero-width space (`\u200B`) is rendered to maintain consistent caret height. Without this, the caret position can shift vertically when transitioning between empty and non-empty states (especially with styled tokens that have padding). + +### `useKeyHandlers` +Intercepts all keyboard input via `onBeforeInput` and `onKeyDown`. Prevents default browser behavior and manually updates the controlled value. Handles: +- Text insertion (with multi-char insert tracking for iOS swipe-text) +- Backspace/Delete (with grapheme cluster awareness) +- iOS swipe-text word deletion (deletes entire swiped word, preserves auto-inserted spaces) +- Enter/Space +- Undo/Redo (Ctrl+Z, Ctrl+Y) + +**iOS DOM sync:** On iOS, text insertions bypass `preventDefault()` to avoid multi-word suggestion bugs. The `input` event handler syncs DOM content to React state using `serializeRawFromDom()`. A `valueRef` provides synchronous access to the current value for decisions that must be made before React re-renders (e.g., detecting newlines to avoid `` reconciliation crashes). + +### `useComposition` +Manages IME (Input Method Editor) composition for CJK languages. Tracks composition state to avoid interfering with in-progress input. Handles composition commit via Space/Enter. + +### `useClipboard` +Token-aware clipboard operations. When copying/cutting a token, extracts the raw value (not visual text). When pasting, inserts at correct raw offset position. + +### `useSelectionSnap` +Snaps cursor and selection to token boundaries. Prevents cursor from landing inside a token's visual representation—it either sits before or after the token in raw-value terms. + +### `useHistory` +Simple undo/redo with snapshot-based history. Coalesces rapid edits into single undo steps. + +### `useSelection` +Tracks current selection as raw offsets. Provides `activeToken` when cursor is adjacent to or within a token. + +## Internal Utilities + +### `dom-utils.ts` +Core DOM traversal functions that account for token divergence: + +- `getAbsoluteOffset(root, node, offset)` — Converts DOM selection position to raw string offset +- `getTextNodeAtOffset(root, offset)` — Converts raw offset to DOM position +- `setDomSelection(root, start, end?)` — Sets browser selection from raw offsets +- `getClosestTokenEl(node)` — Finds containing token element +- `getTokenRawRange(root, tokenEl)` — Gets raw offset range for a token + +### `string-utils.ts` +Token matching and scanning: + +- `Matcher` — Interface for token matchers (regex, prefix, custom) +- `scan(text, matchers)` — Finds all token matches in a string, with overlap resolution +- `Match` — Represents a found token with position and parsed data + +**Overlap Resolution:** When multiple matchers produce overlapping matches, `scan()` uses a longest-match-wins strategy: +1. Matches are sorted by start position, then by length (longest first) +2. A greedy algorithm accepts non-overlapping matches, preferring longer ones +3. When matches have the same range, the first matcher in the array wins + +This prevents duplicate tokens when plugins have overlapping patterns (e.g., `@alice` vs `@alice_vip`). + +## Portal Navigation + +Portal content often needs keyboard navigation (e.g., autocomplete lists). `Portal.List` and `Portal.Item` provide this with built-in keyboard handling. + +```tsx +portal: ({ replace }) => ( + replace(`@${user.id} `)}> + {users.map(user => ( + + {user.name} + + ))} + +) +``` + +**Keyboard behavior:** +- `ArrowUp/Down` — Navigate items (wraps around) +- `Enter` — Select active item +- `Escape` — Dismiss portal + +**Virtual focus:** The editor retains DOM focus while Portal.List tracks the "active" item via state. This avoids contentEditable focus issues. + +**Styling:** Use `data-active` attribute for highlighting: +```css +[data-portal-item][data-active] { background: var(--highlight); } +``` + +**Single-item pattern:** For confirmations or actions, use a single Inlay.Portal.Item: +```tsx + deleteToken()}> + Delete? Press Enter to confirm. + +``` + +**Positioning:** Portal uses manual DOM positioning instead of Radix's built-in anchor. This ensures the popover follows the caret on iOS Safari, where Radix's cached anchor position doesn't update correctly after text changes. The anchor rect is passed via `AnchorRectContext` and applied via `useLayoutEffect` on each render. + +## Plugin System (StructuredInlay) + +Plugins define token types with: + +```typescript +type Plugin = { + props: P // Plugin configuration + matcher: Matcher // How to find tokens in text + render: (ctx) => ReactNode // Token visual representation + portal: (ctx) => ReactNode // Optional popover content + onInsert: (value: T) => void + onKeyDown: (event) => boolean +} +``` + +Example: A mentions plugin matches `@username` patterns, renders styled chips, and shows a user card popover on focus. + +## Browser Compatibility + +- Handles Firefox's element-node selections (Ctrl+A sets selection on element, not text nodes) +- WebKit composition quirks (extra `beforeInput` events after `compositionend`) +- Cross-platform keyboard shortcuts via `ControlOrMeta` + +## Mobile Support + +Inlay provides full mobile device support with touch interactions, virtual keyboard handling, and platform-specific fixes. + +### Mobile Input Attributes + +The editor automatically sets mobile-friendly attributes: + +```tsx + +``` + +All attributes are configurable via props: + +```tsx +type InlayProps = { + inputMode?: 'text' | 'search' | 'email' | 'tel' | 'url' | 'numeric' | 'decimal' | 'none' + autoCapitalize?: 'off' | 'none' | 'on' | 'sentences' | 'words' | 'characters' + autoCorrect?: 'on' | 'off' + enterKeyHint?: 'enter' | 'done' | 'go' | 'next' | 'previous' | 'search' | 'send' + onVirtualKeyboardChange?: (open: boolean) => void +} +``` + +### Touch Event Handling + +The `useTouchSelection` hook handles touch-based interactions: + +- **Tap to focus:** Positions caret at touch location +- **Long press:** Triggers native selection mode +- **Token snapping:** Touch inside tokens snaps to token boundaries +- **Debouncing:** Prevents rapid touch event issues + +### Virtual Keyboard Detection + +The `useVirtualKeyboard` hook uses the `visualViewport` API to detect keyboard visibility: + +```tsx + { + console.log('Keyboard:', open ? 'open' : 'closed') + }} +/> +``` + +When the keyboard opens, the editor automatically scrolls into view. + +### Portal Touch Navigation + +`Portal.List` and `Portal.Item` support touch interactions: + +- **Touch start:** Activates item (like hover on desktop) +- **Touch end:** Selects item if touch didn't move (tap detection) +- **Scroll vs tap:** Movement >10px cancels selection + +```tsx +// Portal items work the same on touch and desktop + + + {item.label} + + +``` + +### iOS-Specific Handling + +- **Selection events:** iOS fires `selectionchange` on `document`, not the element. Added document-level listener in `useSelection`. +- **Anchor rect updates:** iOS can return stale caret rects after text changes. The `useSelection` hook listens for `input` and `visualViewport` events, using `requestAnimationFrame` to read the rect after layout stabilizes. This ensures popovers follow the caret correctly. +- **Composition data:** iOS Safari sometimes omits data in `compositionend`. Tracked via `compositionupdate` as fallback. +- **iPad detection:** Includes modern iPads that report as "MacIntel" with touch. +- **Multi-word suggestions prevention:** Calling `preventDefault()` on `beforeinput` for text insertions triggers iOS to show multi-word predictions (e.g., "I am going to the" as a single suggestion). However, iOS doesn't send usable event data for these predictions. By NOT calling `preventDefault()` on iOS for `insertText`, iOS shows only single-word suggestions which work correctly. The DOM is modified natively and synced to React state via the `input` event handler. +- **Token context exception:** When the cursor is inside a token, we use the controlled path (with `preventDefault`) even on iOS. This avoids issues where `data-token-text` attributes become stale after edits, causing `serializeRawFromDom` to return incorrect values. +- **Newline handling with React:** When content contains newlines, React renders `` elements. If iOS modifies the DOM directly around `` elements, React reconciliation fails with "NotFoundError". Solution: use a `valueRef` to detect newlines synchronously (before React re-renders) and call `preventDefault()` when newlines exist, handling the input via the controlled path. +- **Swipe-text after newlines:** iOS sends swipe data with a leading space even at the start of a line (after `\n`). This space is stripped. iOS may also send the space as a SEPARATE event before the word—single-space insertions at line start are skipped entirely. +- **Swipe-text word deletion:** When user swipe-types a word and presses backspace, iOS sends a single `deleteContentBackward` event with a targetRange covering only the last character. However, if we don't `preventDefault()`, iOS fires 5 rapid delete events and deletes the whole word natively. Since we need to `preventDefault()` to maintain controlled state, we track multi-char inserts and delete the entire chunk when backspace is pressed immediately after. +- **Swipe-text space preservation:** iOS auto-inserts a leading space when swipe-typing after existing text (e.g., "hello" + swipe "world" → "hello world"). When deleting, only the word is removed, preserving the auto-inserted space. +- **Autocomplete suggestions:** For `insertReplacementText` (autocomplete), iOS may not provide the replacement data when `preventDefault()` is called. On iOS, autocomplete is always handled via DOM sync regardless of newlines. +- **Autocomplete state reset:** After pressing Enter, the `autocomplete` attribute is briefly toggled to reset iOS's autocomplete context. This prevents iOS from suggesting merged words like "helloworld" when the actual text is "hello\nworld". + +### Android-Specific Handling + +- **GBoard predictions:** Handles `insertReplacementText` input type for word predictions. Replacement text is in `event.data`. +- **Delete variations:** Handles `deleteWordBackward`, `deleteWordForward`, `deleteSoftLineBackward`, `deleteSoftLineForward` input types. + +### iOS Safari Text Suggestions + +When a user taps a keyboard suggestion on iOS Safari: +1. iOS fires `insertReplacementText` with `data: null` and the replacement text in `event.dataTransfer.getData('text/plain')` +2. This differs from Android which puts the text in `event.data` +3. The handler checks both `data` and `dataTransfer` to support both platforms + +### Testing Mobile + +Mobile tests use Playwright with device emulation: + +```bash +# Run mobile-specific tests +bun run test:ct -- --project=mobile-chrome +bun run test:ct -- --project=mobile-safari +``` + +Test files in `__ct__/inlay.mobile.spec.tsx` cover: +- Touch-based caret positioning +- Mobile attribute presence +- Portal touch navigation +- Token interaction on touch + +## Accessibility + +Inlay provides baseline accessibility out of the box: + +- `role="textbox"` and `aria-multiline` are set automatically +- Default `aria-label="Text input"` — consumers should override with context-specific labels +- Placeholder is marked `aria-hidden="true"` to avoid duplicate announcements + +**Automated a11y testing:** Uses `@axe-core/playwright` to catch WCAG violations in CI. Tests cover empty state, with-tokens, and focused states. + +**Consumer responsibilities:** +- Provide meaningful `aria-label` or `aria-labelledby` for the editor context +- Ensure token visual styling meets contrast requirements +- Test with actual screen readers (VoiceOver, NVDA) for announcement quality + +## Testing + +- **`__ct__/`** — Playwright component tests (real browser, keyboard simulation) +- **`__tests__/`** — Vitest unit tests (JSDOM, faster iteration) + +Run with: +```bash +bun run test:ct -- src/inlay/__ct__/ # Playwright +bun run test -- src/inlay/ # Vitest +``` + +## Common Patterns + +### Adding a new keyboard shortcut +1. Add handler in `use-key-handlers.ts` `onKeyDown` +2. Check for modifier keys, prevent default, update value +3. Use `setDomSelection` to position cursor after state update + +### Adding clipboard behavior +1. Modify `use-clipboard.ts` +2. Use `getSelectionFromDom` for token-aware selection +3. Use `cfg.getValue()` to read raw value, `cfg.setValue()` to update + +### Creating a new token type +1. Define a `Matcher` in `string-utils.ts` format +2. Use with `Inlay.StructuredInlay` plugins or manually with `` + +## Known Limitations + +- Single-line by default (`multiline` prop enables multi-line) +- No rich formatting (bold, italic) — tokens only +- No nested tokens +- IME composition with tokens at boundaries can be tricky +- Mobile autocorrect is disabled by default (would interfere with tokens) +- Samsung keyboard may have composition quirks (test thoroughly) + diff --git a/src/inlay/ISSUE-ios-multiword-suggestions.md b/src/inlay/ISSUE-ios-multiword-suggestions.md new file mode 100644 index 0000000..d0a8823 --- /dev/null +++ b/src/inlay/ISSUE-ios-multiword-suggestions.md @@ -0,0 +1,184 @@ +# iOS Multi-Word Keyboard Suggestions Not Working + +## Problem Statement + +When using Inlay on iOS Safari, multi-word keyboard predictions (like "tell" → "tell them") don't work. Tapping a multi-word suggestion either does nothing or only inserts a space. + +Single-word suggestions (like "hel" → "hello") DO work correctly. + +## Root Cause Discovery + +After extensive debugging, we discovered: + +**Calling `preventDefault()` on `beforeinput` events triggers iOS to show multi-word predictions.** + +This is unexpected iOS behavior - when we prevent default, iOS interprets it as "this app wants special input handling" and switches to an advanced keyboard mode with multi-word predictions. + +### Test Results + +| ContentEditable Setup | Multi-Word Predictions? | +|----------------------|------------------------| +| Naked (no JS) | ❌ No | +| With `onInput` only | ❌ No | +| With `onBeforeInput` (no prevent) | ❌ No | +| With `onBeforeInput` + `preventDefault()` | ✅ Yes | +| Inlay.Root | ✅ Yes | +| StructuredInlay | ✅ Yes | + +### The Dilemma + +1. **We need `preventDefault()`** to control input, especially around structured tokens +2. **`preventDefault()` triggers multi-word predictions** on iOS +3. **iOS doesn't send usable events** for multi-word predictions - no `data`, no `dataTransfer`, nothing to intercept + +## What Works + +**Single-word suggestions** work correctly: +- iOS sends `inputType: "insertReplacementText"` with `data: null` +- The actual text is in `event.dataTransfer.getData('text/plain')` +- We handle this in `use-key-handlers.ts` around line 150 + +```typescript +// Get replacement text: try data first (Android), then dataTransfer (iOS Safari) +const replacementText = data ?? event.dataTransfer?.getData('text/plain') +``` + +## What Doesn't Work + +**Multi-word predictions** fail because: +1. When user taps a multi-word suggestion, iOS sends `textInput` with just `data: " "` (space) +2. No actual suggestion text is provided anywhere +3. We can't handle what we can't detect + +## Potential Solutions + +### 1. Allow + Resync on Input +Don't `preventDefault()`. Let iOS modify the DOM, then on `input`: +- Parse the DOM content +- Reconstruct token state +- Sync to React + +**Challenge**: Token integrity - if iOS types into a token span, we need to correctly parse it back. + +### 2. Invisible Input Overlay (iOS only) +Mount a real `` or `` on iOS that: +- Is invisible but captures all native input +- Gets all the proper iOS events +- Syncs to the visible contentEditable display + +**Advantage**: Clean separation - iOS talks to native input, we control display. + +### 3. Hybrid preventDefault +Only call `preventDefault()` when cursor is in/near tokens. Let iOS handle plain text areas normally. + +**Challenge**: Detecting "near tokens" reliably, edge cases. + +### 4. Parse-Based Reconciliation +After any DOM mutation: +- Diff the DOM against expected state +- Reconcile differences +- Update React state accordingly + +**Similar to #1** but more focused on diffing. + +## Relevant Code Locations + +- `src/inlay/hooks/use-key-handlers.ts` - Main input handling, `preventDefault()` calls +- `src/inlay/inlay.tsx` - ContentEditable element, attributes +- `src/inlay/stories/structured.stories.tsx` - Test stories including `NakedContentEditable` + +## Test Stories Created + +In `structured.stories.tsx`: + +### `MinimalInlay` +- Minimal Inlay.Root (no plugins) +- StructuredInlay (no plugins) +- Both show multi-word predictions (confirms it's core Inlay, not plugins) + +### `NakedContentEditable` +Key test cases to isolate the cause: + +1. **Naked (no JS)** - Single-word only ✅ +2. **With preventDefault on beforeinput** - Multi-word predictions ❌ +3. **With React controlled state** - Single-word only ✅ +4. **Handle on input instead of beforeinput** - Single-word only ✅ + +The 4th test case is the key insight: if you add a `beforeinput` handler but DON'T call `preventDefault()`, you still get single-word only. It's specifically the `preventDefault()` call that triggers multi-word. + +## Debug Logging (Currently Active) + +Extensive logging is currently in `use-key-handlers.ts` for iOS debugging. These are useful for testing on real devices: + +- `[beforeinput]` - Logs all beforeinput events with inputType, data, dataTransfer, cancelable, etc. +- `[insertText]` - Logs text insertions with position info +- `[insertReplacementText]` - Logs iOS/Android word predictions +- `[MutationObserver]` - Logs DOM changes not caught by events +- `[textInput]` - Logs native textInput events with DOM content +- `[input]` - Logs post-input events +- `[document textInput/input/beforeinput]` - Document-level listeners + +There are also document-level event listeners added in the `useEffect` for catching events iOS might send elsewhere. + +**Note**: These should be removed or made conditional before shipping to production. + +## Current State + +The code has: +1. ✅ **Single-word `insertReplacementText` fix** - Working! Checks `dataTransfer` when `data` is null +2. ✅ **Debug logging** - Active, useful for iOS testing on real devices +3. ⚠️ **Space-handling experiment** - Code at ~line 355 that doesn't `preventDefault()` for space insertions (was testing if iOS sends more events after space) +4. ⚠️ **MutationObserver with debouncing** - Syncs DOM to React state when we don't prevent default +5. ⚠️ **`lastPreventedTimeRef` tracking** - Tracks when we prevented vs didn't, so MutationObserver knows when to sync +6. ⚠️ **`preventAndMark()` helper** - Wrapper around `preventDefault()` that also updates the ref + +## Test Status + +Some tests in `inlay.ios-swipe-text.spec.tsx` are **currently failing** due to experimental changes: + +``` +3 failed: +- backspace after swipe + trailing space should delete swiped word +- backspace after multiple swipes should delete most recent word +- swipe after trailing space should not create double space +11 passed +``` + +The failures are because of the space-handling experiment (line ~355) that doesn't `preventDefault()` for spaces. This breaks the controlled input flow. + +**To fix**: Either revert the space experiment or update the tests. + +## Recommended Next Steps + +1. **Choose an approach** from the solutions above +2. **Prototype** the chosen approach +3. **Test on real iOS device** (critical - simulators may differ) +4. **Fix or update failing tests** +5. **Clean up debug logging** before shipping +6. **Update ARCHITECTURE.md** with final solution + +## iOS Keyboard Behavior Notes + +- `autocorrect="on"` vs omitted behaves the same (both show multi-word when preventDefault is used) +- `spellcheck`, `autocapitalize`, `role`, `inputMode` don't affect multi-word prediction appearance +- The trigger is specifically `preventDefault()` on `beforeinput` events +- iOS System Settings → Keyboard → Predictive controls this at OS level, but we can't control it from web + +## Code Changes Made + +### `inlay.tsx` +- Changed `autoCorrect` default from `'off'` to `undefined` (omit attribute, let iOS decide) + +### `use-key-handlers.ts` +- Added `lastPreventedTimeRef` to track when we prevent default +- Added `preventAndMark()` helper function +- Added extensive debug logging +- Added MutationObserver that syncs DOM to state when we don't prevent +- Added document-level event listeners for debugging +- Added space-handling experiment (line ~355) + +### `structured.stories.tsx` +- Removed `autoCorrect="on"` from main story (using default now) +- Added `MinimalInlay` story +- Added `NakedContentEditable` story with 4 test cases + diff --git a/src/inlay/__ct__/fixtures/auto-update-inlay.tsx b/src/inlay/__ct__/fixtures/auto-update-inlay.tsx new file mode 100644 index 0000000..fe2033b --- /dev/null +++ b/src/inlay/__ct__/fixtures/auto-update-inlay.tsx @@ -0,0 +1,78 @@ +import React from 'react' +import { StructuredInlay } from '../../structured/structured-inlay' +import { createRegexMatcher } from '../../internal/string-utils' +import type { Plugin } from '../../structured/plugins/plugin' + +type TokenData = { raw: string; name?: string } + +/** + * Token component that auto-updates after mount (like the storybook MentionToken). + * This creates divergence: raw "@user" → display "User Name" + */ +function MentionToken({ + token, + update +}: { + token: TokenData + update: (data: Partial) => void +}) { + const ref = React.useRef(null) + + React.useEffect(() => { + // Only run in the visible render (not the hidden registration pass) + // Check if we're inside a display:none container + if (ref.current && ref.current.offsetParent === null) { + return + } + + // If no name yet, "fetch" and update (simulates async user lookup) + if (!token.name && token.raw.startsWith('@')) { + const timeout = setTimeout(() => { + update({ name: 'User Name' }) + }, 10) + return () => clearTimeout(timeout) + } + }, [token.raw, token.name, update]) + + return ( + + {token.name ?? token.raw} + + ) +} + +/** + * Test fixture with auto-updating diverged tokens. + * Each @mention triggers an update() call after mount. + */ +export function AutoUpdateInlay({ initial }: { initial: string }) { + const [value, setValue] = React.useState(initial) + + const plugins: Plugin[] = React.useMemo( + () => [ + { + props: {}, + matcher: createRegexMatcher('mention', { + regex: /@\w+/g, + transform: (m) => ({ raw: m[0] }) + }), + render: ({ token, update }) => ( + + ), + portal: () => null, + onInsert: () => {}, + onKeyDown: () => false + } + ], + [] + ) + + return ( + + ) +} diff --git a/src/inlay/__ct__/fixtures/controlled-token-inlay.tsx b/src/inlay/__ct__/fixtures/controlled-token-inlay.tsx new file mode 100644 index 0000000..9c5877a --- /dev/null +++ b/src/inlay/__ct__/fixtures/controlled-token-inlay.tsx @@ -0,0 +1,17 @@ +import React from 'react' +import { Inlay } from '../..' + +export function ControlledTokenInlay({ initial }: { initial: string }) { + const [value, setValue] = React.useState(initial) + return ( + + {value.includes('@x') ? ( + + @x + + ) : ( + + )} + + ) +} diff --git a/src/inlay/__ct__/fixtures/diverged-token-inlay.tsx b/src/inlay/__ct__/fixtures/diverged-token-inlay.tsx new file mode 100644 index 0000000..aa0b5f7 --- /dev/null +++ b/src/inlay/__ct__/fixtures/diverged-token-inlay.tsx @@ -0,0 +1,35 @@ +import React from 'react' +import { Inlay } from '../..' + +/** + * A fixture with a "diverged" token where the visual display + * differs from the raw value: + * - Raw value: "@alice" (7 chars) + * - Visual display: "Alice" (5 chars) + */ +export function DivergedTokenInlay({ + initial, + onValueChange +}: { + initial: string + onValueChange?: (value: string) => void +}) { + const [value, setValue] = React.useState(initial) + + const handleChange = (newValue: string) => { + setValue(newValue) + onValueChange?.(newValue) + } + + return ( + + {value.includes('@alice') ? ( + + Alice + + ) : ( + + )} + + ) +} diff --git a/src/inlay/__ct__/fixtures/mobile-inlay.tsx b/src/inlay/__ct__/fixtures/mobile-inlay.tsx new file mode 100644 index 0000000..7fb76c5 --- /dev/null +++ b/src/inlay/__ct__/fixtures/mobile-inlay.tsx @@ -0,0 +1,137 @@ +import React from 'react' +import { Inlay } from '../../index' +import { createRegexMatcher } from '../../internal/string-utils' +import type { Plugin } from '../../structured/plugins/plugin' + +const ITEMS = [ + { id: '1', label: 'Apple' }, + { id: '2', label: 'Banana' }, + { id: '3', label: 'Cherry' } +] + +type TokenData = { mention: string } + +const mentionMatcher = createRegexMatcher('mention', { + regex: /@\w+/g, + transform: (match) => ({ mention: match[0] }) +}) + +/** + * A fixture for testing mobile interactions with Inlay. + * Includes portal for testing touch-based portal navigation. + */ +export function MobileInlay({ + initial = '', + onSelect, + onKeyboardChange +}: { + initial?: string + onSelect?: (item: (typeof ITEMS)[number]) => void + onKeyboardChange?: (open: boolean) => void +}) { + const [selectedItem, setSelectedItem] = React.useState(null) + const [rawValue, setRawValue] = React.useState(initial) + const [keyboardOpen, setKeyboardOpen] = React.useState(false) + + const handleKeyboardChange = React.useCallback( + (open: boolean) => { + setKeyboardOpen(open) + onKeyboardChange?.(open) + }, + [onKeyboardChange] + ) + + const plugin: Plugin< + Record, + TokenData, + 'mention' + > = React.useMemo( + () => ({ + props: {}, + matcher: mentionMatcher, + render: ({ token }) => ( + + {token.mention} + + ), + portal: ({ replace }) => { + return ( + { + replace(`@${item.id} `) + setSelectedItem(item.label) + onSelect?.(item) + }} + data-testid="portal-list" + className="portal-list" + > + {ITEMS.map((item) => ( + + {item.label} + + ))} + + ) + }, + onInsert: () => {}, + onKeyDown: () => false + }), + [onSelect] + ) + + return ( + + + {selectedItem || 'none'} + {rawValue} + {keyboardOpen ? 'open' : 'closed'} + + ) +} + +/** + * Simple fixture for basic mobile touch tests without plugins. + */ +export function SimpleMobileInlay({ initial = '' }: { initial?: string }) { + const [value, setValue] = React.useState(initial) + + return ( + + + + User + + + {value} + + ) +} diff --git a/src/inlay/__ct__/fixtures/overlapping-plugins-inlay.tsx b/src/inlay/__ct__/fixtures/overlapping-plugins-inlay.tsx new file mode 100644 index 0000000..24f7beb --- /dev/null +++ b/src/inlay/__ct__/fixtures/overlapping-plugins-inlay.tsx @@ -0,0 +1,191 @@ +import React from 'react' +import { StructuredInlay } from '../../structured/structured-inlay' +import { createRegexMatcher } from '../../internal/string-utils' +import type { Plugin } from '../../structured/plugins/plugin' + +/** + * Plugin A: matches @test exactly + * Uses DIVERGED rendering: raw "@test" → visual "[A:test]" + */ +type PluginAData = { raw: string } + +function createPluginA(): Plugin { + return { + props: {}, + matcher: createRegexMatcher('pluginA', { + regex: /@test(?!\w)/g, + transform: (match) => ({ raw: match[0] }) + }), + render: ({ token }) => ( + + [A:test] + + ), + portal: () => null, + onInsert: () => {}, + onKeyDown: () => false + } +} + +/** + * Plugin B: also matches @test exactly (same range as Plugin A) + * Uses DIVERGED rendering: raw "@test" → visual "[B:test]" + * For exact-same-substring test: we can tell which plugin won by the visual + */ +type PluginBData = { raw: string } + +function createPluginB(): Plugin { + return { + props: {}, + matcher: createRegexMatcher('pluginB', { + regex: /@test(?!\w)/g, + transform: (match) => ({ raw: match[0] }) + }), + render: ({ token }) => ( + + [B:test] + + ), + portal: () => null, + onInsert: () => {}, + onKeyDown: () => false + } +} + +/** + * Plugin Short: matches @alice (shorter pattern) + * This creates a genuine overlap scenario with Plugin Long + * Raw "@alice" → visual "[short:alice]" + */ +type ShortData = { raw: string } + +function createShortPlugin(): Plugin { + return { + props: {}, + matcher: createRegexMatcher('short', { + // Matches @alice even when followed by more characters + regex: /@alice/g, + transform: (match) => ({ raw: match[0] }) + }), + render: ({ token }) => ( + + [short:alice] + + ), + portal: () => null, + onInsert: () => {}, + onKeyDown: () => false + } +} + +/** + * Plugin Long: matches @alice_vip (longer pattern, same start position) + * This should WIN over Short plugin when both match starting at same position + * Raw "@alice_vip" → visual "[long:alice_vip]" + */ +type LongData = { raw: string } + +function createLongPlugin(): Plugin { + return { + props: {}, + matcher: createRegexMatcher('long', { + regex: /@alice_vip/g, + transform: (match) => ({ raw: match[0] }) + }), + render: ({ token }) => ( + + [long:alice_vip] + + ), + portal: () => null, + onInsert: () => {}, + onKeyDown: () => false + } +} + +/** + * Plugin Hashtag: matches #hashtag pattern (for non-overlapping scenarios) + */ +type HashtagData = { raw: string } + +function createHashtagPlugin(): Plugin { + return { + props: {}, + matcher: createRegexMatcher('hashtag', { + regex: /#\w+/g, + transform: (match) => ({ raw: match[0] }) + }), + render: ({ token }) => ( + + {token.raw} + + ), + portal: () => null, + onInsert: () => {}, + onKeyDown: () => false + } +} + +/** + * Plugin Mention: matches @username (for non-overlapping scenarios) + */ +type MentionData = { raw: string } + +function createMentionPlugin(): Plugin { + return { + props: {}, + matcher: createRegexMatcher('mention', { + regex: /@\w+/g, + transform: (match) => ({ raw: match[0] }) + }), + render: ({ token }) => ( + + {token.raw} + + ), + portal: () => null, + onInsert: () => {}, + onKeyDown: () => false + } +} + +export type OverlapScenario = + | 'exact-same' // Two plugins match exact same @test - diverged renders let us see which won + | 'longer-wins' // @alice vs @alice_vip at same start position - longer should win + | 'non-overlapping' // @bob and #hashtag - both should render + +type Props = { + initial: string + scenario: OverlapScenario +} + +export function OverlappingPluginsInlay({ initial, scenario }: Props) { + const [value, setValue] = React.useState(initial) + + const plugins = React.useMemo(() => { + switch (scenario) { + case 'exact-same': + // Both plugins match @test at the same range + // Plugin A renders "[A:test]", Plugin B renders "[B:test]" + // We can tell which won by the visual output + return [createPluginA(), createPluginB()] as const + case 'longer-wins': + // Short matches "@alice" (6 chars), Long matches "@alice_vip" (10 chars) + // Both start at the same position, but Long is longer + // Long should win per longest-match-wins + return [createShortPlugin(), createLongPlugin()] as const + case 'non-overlapping': + // Different patterns that don't overlap + return [createMentionPlugin(), createHashtagPlugin()] as const + } + }, [scenario]) + + return ( + + ) +} diff --git a/src/inlay/__ct__/fixtures/portal-navigation-inlay.tsx b/src/inlay/__ct__/fixtures/portal-navigation-inlay.tsx new file mode 100644 index 0000000..eabd7b3 --- /dev/null +++ b/src/inlay/__ct__/fixtures/portal-navigation-inlay.tsx @@ -0,0 +1,92 @@ +import React from 'react' +import { Inlay } from '../../index' +import { createRegexMatcher } from '../../internal/string-utils' +import type { Plugin } from '../../structured/plugins/plugin' + +const ITEMS = [ + { id: '1', label: 'Apple' }, + { id: '2', label: 'Banana' }, + { id: '3', label: 'Cherry' } +] + +type TokenData = { mention: string; resolved?: boolean } + +const mentionMatcher = createRegexMatcher('mention', { + regex: /@\w+/g, // Use + to require at least one char after @ + transform: (match) => ({ mention: match[0] }) +}) + +/** + * A fixture for testing Inlay.Portal.List keyboard navigation. + * Uses Inlay.StructuredInlay with a mention-style plugin. + */ +export function PortalNavigationInlay({ + initial = '', + onSelect +}: { + initial?: string + onSelect?: (item: (typeof ITEMS)[number]) => void +}) { + const [selectedItem, setSelectedItem] = React.useState(null) + const [rawValue, setRawValue] = React.useState(initial) + + const plugin: Plugin< + Record, + TokenData, + 'mention' + > = React.useMemo( + () => ({ + props: {}, + matcher: mentionMatcher, + render: ({ token }) => ( + + {token.mention} + + ), + portal: ({ replace }) => { + // Always show portal for testing + return ( + { + replace(`@${item.id} `) + setSelectedItem(item.label) + setRawValue((prev) => prev.replace(/@\w*$/, `@${item.id} `)) + onSelect?.(item) + }} + data-testid="portal-list" + className="portal-list" + > + {ITEMS.map((item) => ( + + {item.label} + + ))} + + ) + }, + onInsert: () => {}, + onKeyDown: () => false + }), + [onSelect] + ) + + return ( + + + {selectedItem || 'none'} + {rawValue} + + ) +} diff --git a/src/inlay/__ct__/fixtures/structured-actions-inlay.tsx b/src/inlay/__ct__/fixtures/structured-actions-inlay.tsx new file mode 100644 index 0000000..54f3549 --- /dev/null +++ b/src/inlay/__ct__/fixtures/structured-actions-inlay.tsx @@ -0,0 +1,61 @@ +import React from 'react' +import { StructuredInlay } from '../../structured/structured-inlay' +import { createRegexMatcher } from '../../internal/string-utils' +import type { Plugin } from '../../structured/plugins/plugin' + +type TokenData = { raw: string; label?: string } + +/** + * Test fixture that exposes replace/update via buttons in the portal for easy testing + */ +export function StructuredActionsInlay({ initial }: { initial: string }) { + const [value, setValue] = React.useState(initial) + + const plugins: Plugin[] = React.useMemo( + () => [ + { + props: {}, + matcher: createRegexMatcher('mention', { + regex: /@\w+/g, + transform: (m) => ({ raw: m[0] }) + }), + render: ({ token }) => ( + {token.label ?? token.raw} + ), + portal: ({ replace, update }) => { + return ( + e.preventDefault()}> + update({ label: 'UpdatedLabel' })} + > + Update + + replace('@replaced')} + > + Replace + + + ) + }, + onInsert: () => {}, + onKeyDown: () => false + } + ], + [] + ) + + return ( + + + {value} + + ) +} diff --git a/src/inlay/__ct__/inlay.a11y.spec.tsx b/src/inlay/__ct__/inlay.a11y.spec.tsx new file mode 100644 index 0000000..06f5fcc --- /dev/null +++ b/src/inlay/__ct__/inlay.a11y.spec.tsx @@ -0,0 +1,51 @@ +import { test, expect } from '@playwright/experimental-ct-react' +import AxeBuilder from '@axe-core/playwright' +import { DivergedTokenInlay } from './fixtures/diverged-token-inlay' +import { Inlay } from '../..' + +test.describe('Inlay accessibility', () => { + test('empty state has no a11y violations', async ({ mount, page }) => { + await mount() + const results = await new AxeBuilder({ page }) + .include('[role="textbox"]') + .analyze() + expect(results.violations).toEqual([]) + }) + + test('with tokens has no a11y violations', async ({ mount, page }) => { + await mount() + const results = await new AxeBuilder({ page }) + .include('[role="textbox"]') + .analyze() + expect(results.violations).toEqual([]) + }) + + test('focused state has no a11y violations', async ({ mount, page }) => { + await mount() + await page.getByRole('textbox').focus() + const results = await new AxeBuilder({ page }) + .include('[role="textbox"]') + .analyze() + expect(results.violations).toEqual([]) + }) + + test('has default aria-label', async ({ mount, page }) => { + await mount( + + + + ) + const editor = page.getByRole('textbox') + await expect(editor).toHaveAttribute('aria-label', 'Text input') + }) + + test('aria-label can be overridden', async ({ mount, page }) => { + await mount( + + + + ) + const editor = page.getByRole('textbox') + await expect(editor).toHaveAttribute('aria-label', 'Message composer') + }) +}) diff --git a/src/inlay/__ct__/inlay.basic.spec.tsx b/src/inlay/__ct__/inlay.basic.spec.tsx new file mode 100644 index 0000000..b623d29 --- /dev/null +++ b/src/inlay/__ct__/inlay.basic.spec.tsx @@ -0,0 +1,23 @@ +import { test, expect } from '@playwright/experimental-ct-react' +import { Inlay } from '../' + +test('basic typing/backspace', async ({ mount, page }) => { + await mount( + + + + ) + + const root = page.getByTestId('root') + await expect(root).toHaveCount(1) + + const ed = page.getByRole('textbox') + await ed.click() + await page.keyboard.press('ArrowRight') + await page.keyboard.press('ArrowRight') + await page.keyboard.type('c') + await expect(ed).toHaveText('abc') + for (let i = 0; i < 10; i++) await page.keyboard.press('ArrowRight') + await page.keyboard.press('Backspace') + await expect(ed).toHaveText('ab') +}) diff --git a/src/inlay/__ct__/inlay.clipboard.spec.tsx b/src/inlay/__ct__/inlay.clipboard.spec.tsx new file mode 100644 index 0000000..8f03867 --- /dev/null +++ b/src/inlay/__ct__/inlay.clipboard.spec.tsx @@ -0,0 +1,216 @@ +import { test, expect } from '@playwright/experimental-ct-react' +import { DivergedTokenInlay } from './fixtures/diverged-token-inlay' +import { AutoUpdateInlay } from './fixtures/auto-update-inlay' + +// Run clipboard tests serially to avoid clipboard state pollution between parallel tests +test.describe.serial('Clipboard operations with diverged tokens (CT)', () => { + // Skip on mobile-safari: WebKit's mobile emulation has different clipboard behavior + // that causes copy/paste operations to behave unexpectedly. Desktop webkit passes. + test.beforeEach(async ({}, testInfo) => { + test.skip( + testInfo.project.name === 'mobile-safari', + 'Skipped on mobile-safari: clipboard behavior differs in mobile WebKit emulation' + ) + }) + test('Select all (Ctrl/Cmd+a) selects entire content', async ({ + mount, + page + }) => { + await mount() + + const ed = page.getByRole('textbox') + await ed.click() + await expect(ed).toHaveText('Hello World') + + await page.keyboard.press('ControlOrMeta+a') + await page.keyboard.press('Backspace') + await expect(ed).toHaveText('') + }) + + test('Copy and paste a diverged token preserves raw value', async ({ + mount, + page + }) => { + await mount() + + const ed = page.getByRole('textbox') + await ed.click() + await expect(ed).toHaveText('Hello Alice!') + + // Navigate to token: start → right 6 (past "Hello ") → select token + for (let i = 0; i < 20; i++) await page.keyboard.press('ArrowLeft') + for (let i = 0; i < 6; i++) await page.keyboard.press('ArrowRight') + await page.keyboard.press('Shift+ArrowRight') + + await page.keyboard.press('ControlOrMeta+c') + + // Move to end and paste + for (let i = 0; i < 20; i++) await page.keyboard.press('ArrowRight') + await page.keyboard.press('ControlOrMeta+v') + + await expect(ed).toHaveText('Hello Alice!Alice') + await expect(ed.locator('[data-token-text="@alice"]')).toHaveCount(2) + }) + + test('Copy text containing diverged token includes raw value', async ({ + mount, + page + }) => { + await mount() + + const ed = page.getByRole('textbox') + await ed.click() + await expect(ed).toHaveText('AAliceB') + + await page.keyboard.press('ControlOrMeta+a') + await page.keyboard.press('ControlOrMeta+c') + + // Clear and type X + await page.keyboard.press('Backspace') + await expect(ed).toHaveText('') + await page.keyboard.type('X') + await expect(ed).toHaveText('X') + + // Paste at end + await page.keyboard.press('ArrowRight') + await page.keyboard.press('ControlOrMeta+v') + + await expect(ed).toHaveText('XAAliceB') + await expect(ed.locator('[data-token-text="@alice"]')).toHaveCount(1) + }) + + test('Cut diverged token removes it and pastes raw value', async ({ + mount, + page + }) => { + await mount() + + const ed = page.getByRole('textbox') + await ed.click() + await expect(ed).toHaveText('XAliceY') + + // Navigate past X and select token + for (let i = 0; i < 20; i++) await page.keyboard.press('ArrowLeft') + await page.keyboard.press('ArrowRight') + await page.keyboard.press('Shift+ArrowRight') + + await page.keyboard.press('ControlOrMeta+x') + + await expect(ed).toHaveText('XY') + await expect(ed.locator('[data-token-text="@alice"]')).toHaveCount(0) + + // Paste at end + for (let i = 0; i < 20; i++) await page.keyboard.press('ArrowRight') + await page.keyboard.press('ControlOrMeta+v') + + await expect(ed).toHaveText('XYAlice') + await expect(ed.locator('[data-token-text="@alice"]')).toHaveCount(1) + }) + + test('Paste plain text into editor works correctly', async ({ + mount, + page + }) => { + await mount() + + const ed = page.getByRole('textbox') + await ed.click() + + // Move to end + for (let i = 0; i < 20; i++) await page.keyboard.press('ArrowRight') + + // Write to clipboard and paste using real keyboard shortcut + await page.evaluate(() => navigator.clipboard.writeText('world')) + await page.keyboard.press('ControlOrMeta+v') + + await expect(ed).toHaveText('Hello world') + }) + + test('Paste text that matches token pattern creates new token', async ({ + mount, + page + }) => { + await mount() + + const ed = page.getByRole('textbox') + await ed.click() + + // Move to end + for (let i = 0; i < 20; i++) await page.keyboard.press('ArrowRight') + + // Write to clipboard and paste using real keyboard shortcut + await page.evaluate(() => navigator.clipboard.writeText('@alice')) + await page.keyboard.press('ControlOrMeta+v') + + await expect(ed.locator('[data-token-text="@alice"]')).toHaveCount(1) + await expect(ed).toHaveText('Hi Alice') + }) + + test('Rapid paste maintains caret at end of inserted text', async ({ + mount, + page + }) => { + await mount() + + const ed = page.getByRole('textbox') + await ed.click() + + // Write text to clipboard + await page.evaluate(() => navigator.clipboard.writeText('abc')) + + // Simulate rapid pasting (like holding Ctrl+V) + // Fire multiple paste events in quick succession without waiting + const pasteCount = 5 + for (let i = 0; i < pasteCount; i++) { + await page.keyboard.press('ControlOrMeta+v', { delay: 0 }) + } + + // Expected: "abcabcabcabcabc" with caret at position 15 (end) + await expect(ed).toHaveText('abcabcabcabcabc') + + // Type a character to verify caret position - should appear at the end + await page.keyboard.type('X') + await expect(ed).toHaveText('abcabcabcabcabcX') + }) + + test('Pasting many auto-updating tokens does not cause infinite loop', async ({ + mount, + page + }) => { + // Capture React errors (error #185 = Maximum update depth exceeded) + const errors: string[] = [] + page.on('pageerror', (err) => errors.push(err.message)) + + await mount() + + const ed = page.getByRole('textbox') + await ed.click() + + // Paste many tokens that will each trigger an update() call + const manyTokens = Array(100).fill('@test').join(' ') + ' ' + await page.evaluate( + (text) => navigator.clipboard.writeText(text), + manyTokens + ) + await page.keyboard.press('ControlOrMeta+v') + + // Wait for all updates to process + await page.waitForTimeout(500) + + // Should not have crashed with "Maximum update depth exceeded" + expect(errors).toHaveLength(0) + + // Should render exactly 100 tokens (no duplicates) in the visible editor + // Note: 2-pass rendering means tokens exist in both hidden and visible divs + const editor = page.getByRole('textbox') + await expect(editor.locator('[data-testid="token-render"]')).toHaveCount( + 100, + { timeout: 5000 } + ) + + // Caret should be at end after all updates, not position 0 + await page.keyboard.type('X') + const text = await editor.textContent() + expect(text?.endsWith('X')).toBe(true) + }) +}) diff --git a/src/inlay/__ct__/inlay.composition.cdp.spec.tsx b/src/inlay/__ct__/inlay.composition.cdp.spec.tsx new file mode 100644 index 0000000..63fffcb --- /dev/null +++ b/src/inlay/__ct__/inlay.composition.cdp.spec.tsx @@ -0,0 +1,78 @@ +import { test, expect, type Locator } from '@playwright/experimental-ct-react' +import { Inlay } from '../' + +// Chromium-only: use CDP to simulate IME composition end-to-end +const composeWithCDP = async ( + page: import('@playwright/test').Page, + text: string +) => { + const client = await page.context().newCDPSession(page) + await client.send('Input.imeSetComposition', { + text, + selectionStart: text.length, + selectionEnd: text.length + }) + await client.send('Input.insertText', { text }) + await client.send('Input.imeSetComposition', { + text: '', + selectionStart: 0, + selectionEnd: 0 + }) +} + +// Check that composition didn't leave broken DOM structure (multiple text nodes, etc.) +async function assertCleanTextContent(ed: Locator): Promise { + return ed.evaluate((el: HTMLElement) => { + // The editor should not have stray elements or deeply nested structures + // after composition. Text content check is the primary validation. + const brs = el.querySelectorAll('br') + return brs.length === 0 + }) +} + +test.describe.serial('IME composition via CDP (Chromium)', () => { + test.skip( + ({ browserName }) => browserName !== 'chromium', + 'CDP IME APIs are Chromium-only' + ) + + test('Space commit produces composed text and a trailing space', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + const ed = page.getByRole('textbox') + await ed.click() + + await composeWithCDP(page, 'にほん') + await page.keyboard.press('Space') + + await expect(ed).toHaveText('にほん ') + const ok = await assertCleanTextContent(ed) + expect(ok).toBe(true) + }) + + test('Enter commit composes text and does not add a stray newline immediately', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + const ed = page.getByRole('textbox') + await ed.click() + + await composeWithCDP(page, 'テスト') + await page.keyboard.press('Enter') + + await expect(ed).toHaveText('テスト') + const ok = await assertCleanTextContent(ed) + expect(ok).toBe(true) + }) +}) diff --git a/src/inlay/__ct__/inlay.grapheme.spec.tsx b/src/inlay/__ct__/inlay.grapheme.spec.tsx new file mode 100644 index 0000000..7e73627 --- /dev/null +++ b/src/inlay/__ct__/inlay.grapheme.spec.tsx @@ -0,0 +1,149 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { test, expect } from '@playwright/experimental-ct-react' +import { Inlay } from '../' + +test.describe('Grapheme handling (CT)', () => { + test('Backspace deletes an entire emoji grapheme cluster', async ({ + mount, + page + }) => { + const cluster = '👍🏼' + await mount( + + + + ) + + page.on('console', (msg) => console.log('EMOJI TEST LOG:', msg.text())) + + const ed = page.getByRole('textbox') + await ed.click() + // Normalize caret to start by overshooting ArrowLeft + for (let i = 0; i < 10; i++) await page.keyboard.press('ArrowLeft') + // Move caret to end using ArrowRight (cluster is one grapheme) + await page.keyboard.press('ArrowRight') + + await page.keyboard.press('Backspace') + await expect(ed).toHaveText('') + }) + + test('Backspace with selection slicing through a grapheme removes the whole grapheme', async ({ + mount, + page + }) => { + const cluster = '👍🏼' + const text = `a${cluster}b` + await mount( + + + + ) + + const ed = page.getByRole('textbox') + await ed.click() + // Normalize caret to start by overshooting ArrowLeft + for (let i = 0; i < 10; i++) await page.keyboard.press('ArrowLeft') + // Move to just before the grapheme (after the initial 'a') + await page.keyboard.press('ArrowRight') + // Extend selection across exactly one grapheme + await page.keyboard.press('Shift+ArrowRight') + + await page.keyboard.press('Backspace') + await expect(ed).toHaveText('ab') + }) + + test('Backspace deletes entire flag grapheme (regional indicators) before caret', async ({ + mount, + page + }) => { + const flag = '🇺🇸' + await mount( + + + + ) + + const ed = page.getByRole('textbox') + await ed.click() + // Normalize caret to start by overshooting ArrowLeft + for (let i = 0; i < 10; i++) await page.keyboard.press('ArrowLeft') + // Move to after 'a' and then after the flag grapheme + await page.keyboard.press('ArrowRight') + await page.keyboard.press('ArrowRight') + + await page.keyboard.press('Backspace') + await expect(ed).toHaveText('ab') + }) + + test('Backspace deletes composed character with combining mark as a single grapheme', async ({ + mount, + page + }) => { + const composed = 'e\u0301' + await mount( + + + + ) + + const ed = page.getByRole('textbox') + await ed.click() + // Move cursor to end + await page.keyboard.press('End') + + // Capture page console logs + page.on('console', (msg) => console.log('PAGE:', msg.text())) + + // Test log to verify capturing works + await page.evaluate(() => console.log('TEST LOG FROM PAGE')) + + // Check if React's onBeforeInput is attached + await page.evaluate(() => { + const editor = document.querySelector('[role="textbox"]') as HTMLElement + const reactKey = Object.keys(editor).find((k) => + k.startsWith('__reactProps$') + ) + if (reactKey) { + const props = (editor as any)[reactKey] + console.log( + 'React props keys:', + Object.keys(props || {}).filter((k) => + k.toLowerCase().includes('input') + ) + ) + console.log('Has onBeforeInput:', typeof props?.onBeforeInput) + } else { + console.log('No React props found') + } + }) + + // Check value before backspace + const valueBefore = await page.evaluate(() => { + const editor = document.querySelector('[role="textbox"]') as HTMLElement + return { + textContent: editor.textContent, + innerHTML: editor.innerHTML, + charCodes: Array.from(editor.textContent || '').map((c) => + c.charCodeAt(0) + ) + } + }) + console.log('Before backspace:', JSON.stringify(valueBefore)) + + await page.keyboard.press('Backspace') + + // Check what the actual text content is + const valueAfter = await page.evaluate(() => { + const editor = document.querySelector('[role="textbox"]') as HTMLElement + return { + textContent: editor.textContent, + charCodes: Array.from(editor.textContent || '').map((c) => + c.charCodeAt(0) + ) + } + }) + console.log('After backspace:', JSON.stringify(valueAfter)) + + await expect(ed).toHaveText('') + }) +}) diff --git a/src/inlay/__ct__/inlay.ios-swipe-text.spec.tsx b/src/inlay/__ct__/inlay.ios-swipe-text.spec.tsx new file mode 100644 index 0000000..d7901dd --- /dev/null +++ b/src/inlay/__ct__/inlay.ios-swipe-text.spec.tsx @@ -0,0 +1,1394 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { test, expect } from '@playwright/experimental-ct-react' +import { Inlay } from '../' + +/** + * iOS Swipe-Text Bug Reproduction + * + * THE BUG (from real iOS device testing): + * When user swipe-types "hello" and presses backspace ONCE: + * 1. iOS fires a SINGLE insertText event with data="hello" (whole word) + * 2. iOS fires a SINGLE deleteContentBackward event with targetRanges=4-5 (last char only!) + * 3. If we DON'T preventDefault, iOS fires 5 rapid deleteContentBackward events + * and natively deletes the whole word + * 4. But if we DO preventDefault on the first event, iOS stops sending the + * remaining events, so we only see the range for the last char + * + * EXPECTED: Entire swipe-typed word deleted + * ACTUAL BUG: Only last char deleted, "hello" -> "hell" + * + * THE FIX: Track multi-char inserts, and if deleteContentBackward happens + * immediately after at the end of the inserted chunk, delete the whole chunk. + * + * SPACE PRESERVATION: When swipe-typing after existing text, iOS inserts + * " word" (with leading space). On backspace, only "word" should be deleted, + * preserving the auto-inserted space. + * + * NOTE: These tests use synthetic events to simulate iOS/Android native IME behavior. + * They pass on desktop browsers but fail on Playwright's mobile-safari emulation + * because WebKit in mobile touch mode has different event handling. The mobile-safari + * project is NOT real iOS Safari - it's desktop WebKit with iPhone viewport/user-agent. + * Real iOS testing requires actual devices or cloud device farms. + */ + +test.describe('iOS swipe-text bug', () => { + // Skip on mobile-safari: Playwright's mobile-safari is desktop WebKit with mobile viewport, + // not real iOS Safari. It has bugs with keyboard input (text reversal) and synthetic events. + test.beforeEach(async ({}, testInfo) => { + test.skip( + testInfo.project.name === 'mobile-safari', + 'Skipped on mobile-safari: cannot emulate iOS native IME behavior' + ) + }) + + /** + * Test that input works after deleting content. + * Verifies no crash when typing after deletion. + */ + test('input after deleting swipe-typed word should not crash', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + await page.keyboard.press('End') + await expect(ed).toHaveText('hello') + + // Delete all characters with delay between presses + for (let i = 0; i < 5; i++) { + await page.keyboard.press('Backspace') + await page.waitForTimeout(30) + } + + // Should be empty (or have zero-width space) + await expect(ed).toHaveText(/^[\u200B]?$/) + + // Wait for selection to settle + await page.waitForTimeout(50) + + // Type new text - should not crash + await page.keyboard.type('world', { delay: 30 }) + + await expect(ed).toHaveText('world') + }) + + /** + * Test that selection updates work correctly even after DOM nodes are replaced. + * This tests the robustness of setDomSelection when React re-renders. + */ + test('setDomSelection should not crash when nodes are replaced', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + await page.keyboard.press('End') + await expect(ed).toHaveText('hello') + + // Delete all characters - this causes DOM nodes to be replaced + for (let i = 0; i < 5; i++) { + await page.keyboard.press('Backspace') + await page.waitForTimeout(30) + } + + // Should be empty + await expect(ed).toHaveText(/^[\u200B]?$/) + + // Wait for selection to settle + await page.waitForTimeout(50) + + // Insert new text - should work without crash + await page.keyboard.type('x') + + await expect(ed).toHaveText('x') + }) + + /** + * CRASH BUG VARIANT: Selection points outside editor after deletion. + * On iOS, after rapid deletions the selection may end up pointing to + * a node that's no longer in the editor, causing getAbsoluteOffset to fail. + */ + test('insert should handle selection outside editor gracefully', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + await page.keyboard.press('End') + + const result = await page.evaluate(async () => { + const editor = document.querySelector('[role="textbox"]') as HTMLElement + + const reactPropsKey = Object.keys(editor).find((k) => + k.startsWith('__reactProps$') + ) + if (!reactPropsKey) return { error: 'No React props' } + + const reactProps = (editor as any)[reactPropsKey] + const onBeforeInput = reactProps?.onBeforeInput + if (!onBeforeInput) return { error: 'No onBeforeInput' } + + // Force selection to be outside the editor (simulating iOS bug) + const bodyTextNode = document.createTextNode('outside') + document.body.appendChild(bodyTextNode) + const sel = window.getSelection() + sel?.removeAllRanges() + const badRange = document.createRange() + badRange.setStart(bodyTextNode, 0) + badRange.setEnd(bodyTextNode, 0) + sel?.addRange(badRange) + + let insertError: string | undefined + + const insertEvent = { + type: 'beforeinput', + inputType: 'insertText', + data: 'x', + preventDefault: () => {}, + stopPropagation: () => {}, + nativeEvent: { + type: 'beforeinput', + inputType: 'insertText', + data: 'x', + getTargetRanges: () => [] + } + } + + try { + onBeforeInput(insertEvent) + } catch (e) { + insertError = String(e) + } + + // Cleanup + document.body.removeChild(bodyTextNode) + + await new Promise((r) => setTimeout(r, 50)) + + return { + insertError, + finalText: editor.textContent + } + }) + + console.log('Result:', JSON.stringify(result, null, 2)) + + // Should NOT crash - should handle gracefully + expect(result.insertError).toBeUndefined() + }) + + /** + * iOS VALUE DIVERGENCE BUG: + * After swipe-typing and pressing backspace, the React value state + * must stay in sync with the DOM. + * + * This test verifies that backspace via native beforeinput correctly + * updates the value even when coming from swipe-text scenarios. + */ + test('backspace via beforeinput after insert should update value', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + + // Ensure we're at the end of the content + await page.keyboard.press('End') + await expect(ed).toHaveText('hello') + + // Press backspace - this fires native beforeinput which our handler catches + await page.keyboard.press('Backspace') + + // Value should update from "hello" to "hell" + await expect(ed).toHaveText('hell') + }) + + /** + * iOS Safari crash: backspace then swipe-type causes "The object can not be found here" + * + * This test verifies that pressing backspace then typing doesn't crash + * after DOM updates from deletion. + */ + test('iOS Safari: backspace then textInput should not crash', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + await page.keyboard.press('End') + + // Step 1: Backspace to delete "o" + await page.keyboard.press('Backspace') + await expect(ed).toHaveText('hell') + + // Wait for selection to settle after React re-render + await page.waitForTimeout(50) + + // Step 2: Type new text (one char at a time with delay to avoid race) + await page.keyboard.type('world', { delay: 30 }) + + // Should have backspaced then typed + await expect(ed).toHaveText('hellworld') + }) + + /** + * Test that text insertion works correctly via real browser events. + * Note: iOS Safari's legacy textInput event is handled by the native + * beforeinput listener on real iOS devices. + */ + test('iOS Safari textInput event should insert text', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + + // Type text using real browser events + await page.keyboard.type('hello') + + // Text should be inserted + await expect(ed).toHaveText('hello') + }) + + test('swipe-text after backspace should not crash', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + await page.keyboard.press('End') + await expect(ed).toHaveText('hello') + + // Delete all characters with backspace (with delay to avoid race conditions) + for (let i = 0; i < 5; i++) { + await page.keyboard.press('Backspace') + await page.waitForTimeout(30) + } + + // Wait for deletions to complete + await expect(ed).toHaveText(/^[\u200B]?$/) // empty or zero-width space + + // Wait for selection to settle + await page.waitForTimeout(50) + + // Now type new text + await page.keyboard.type('world', { delay: 30 }) + + // Text should be "world" after inserting + await expect(ed).toHaveText('world') + }) + + /** + * iOS swipe-text word deletion - ACTUAL BEHAVIOR (from real device testing): + * + * When user swipe-types "hello" and presses backspace ONCE: + * 1. iOS fires a SINGLE insertText event with data="hello" (whole word) + * 2. iOS fires a SINGLE deleteContentBackward event with targetRanges=4-5 (last char only!) + * 3. BUT if we don't preventDefault, iOS natively deletes the whole word + * + * The bug: When we preventDefault and handle it ourselves, we only see the + * targetRange for the last character and delete only that character. + * + * The fix (Option 2): Track when a multi-char insert happens, and if + * deleteContentBackward is pressed immediately after at the end of that + * inserted chunk, delete the whole chunk. + */ + test('iOS swipe-text: backspace after swipe-typed word should delete entire word', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + + // Simulate iOS swipe-text behavior + const result = await page.evaluate(async () => { + const editor = document.querySelector('[role="textbox"]') as HTMLElement + + // Step 1: Simulate swipe-typing "hello" - iOS sends a single insertText with the whole word + const insertEvent = new InputEvent('beforeinput', { + inputType: 'insertText', + data: 'hello', + bubbles: true, + cancelable: true + }) + + // Set up a collapsed range at position 0 for the insertion point + const sel = window.getSelection() + if (sel && sel.rangeCount > 0) { + const range = sel.getRangeAt(0) + ;(insertEvent as any).getTargetRanges = () => [ + { + startContainer: range.startContainer, + startOffset: range.startOffset, + endContainer: range.endContainer, + endOffset: range.endOffset, + collapsed: true + } + ] + } else { + ;(insertEvent as any).getTargetRanges = () => [] + } + + editor.dispatchEvent(insertEvent) + + // Wait for React to process the insert + await new Promise((r) => setTimeout(r, 50)) + + const afterInsert = editor.textContent?.replace(/\u200B/g, '') + + // Step 2: Simulate pressing backspace - iOS sends ONE deleteContentBackward + // with targetRange covering only the LAST character (4-5), NOT the whole word + const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT) + let textNode: Text | null = null + while (walker.nextNode()) { + const node = walker.currentNode as Text + if (node.textContent && node.textContent.length > 0) { + textNode = node + break + } + } + + if (!textNode) { + return { error: 'No text node found after insert', afterInsert } + } + + const deleteEvent = new InputEvent('beforeinput', { + inputType: 'deleteContentBackward', + bubbles: true, + cancelable: true + }) + + // iOS only provides targetRange for the LAST character, not the whole word! + // This is the key part of the bug - even though iOS would natively delete + // the whole word, the beforeinput event only reports the last char range. + const textLen = textNode.textContent?.length || 0 + ;(deleteEvent as any).getTargetRanges = () => [ + { + startContainer: textNode, + startOffset: textLen - 1, // e.g., 4 for "hello" + endContainer: textNode, + endOffset: textLen, // e.g., 5 for "hello" + collapsed: false + } + ] + + editor.dispatchEvent(deleteEvent) + + // Wait for React to process the delete + await new Promise((r) => setTimeout(r, 50)) + + return { + afterInsert, + finalText: editor.textContent, + finalLength: editor.textContent?.replace(/\u200B/g, '').length + } + }) + + console.log('Result:', JSON.stringify(result, null, 2)) + + // Verify the insert worked + expect(result.afterInsert).toBe('hello') + + // EXPECTED: Entire swipe-typed word should be deleted + // ACTUAL BUG: Only last char deleted, "hello" -> "hell" + expect(result.finalLength).toBe(0) + }) + + /** + * iOS swipe-text space preservation: + * + * When user has "hello|" and swipe-types "world", iOS inserts " world" (with leading space). + * When backspace is pressed, iOS native deletes only "world" and preserves the space. + * Result should be "hello " (with trailing space), not "hello" (no space). + */ + test('iOS swipe-text: backspace after swipe-typed word should preserve auto-inserted space', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + await page.keyboard.press('End') + await expect(ed).toHaveText('hello') + + // Simulate iOS swipe-text behavior with leading space + const result = await page.evaluate(async () => { + const editor = document.querySelector('[role="textbox"]') as HTMLElement + + // Step 1: Simulate swipe-typing " world" (with leading space) after "hello" + // iOS automatically adds a space when swipe-typing after existing text + const insertEvent = new InputEvent('beforeinput', { + inputType: 'insertText', + data: ' world', // Note the leading space! + bubbles: true, + cancelable: true + }) + + const sel = window.getSelection() + if (sel && sel.rangeCount > 0) { + const range = sel.getRangeAt(0) + ;(insertEvent as any).getTargetRanges = () => [ + { + startContainer: range.startContainer, + startOffset: range.startOffset, + endContainer: range.endContainer, + endOffset: range.endOffset, + collapsed: true + } + ] + } else { + ;(insertEvent as any).getTargetRanges = () => [] + } + + editor.dispatchEvent(insertEvent) + + // Wait for React to process the insert + await new Promise((r) => setTimeout(r, 50)) + + const afterInsert = editor.textContent?.replace(/\u200B/g, '') + + // Step 2: Simulate pressing backspace + const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT) + let textNode: Text | null = null + while (walker.nextNode()) { + const node = walker.currentNode as Text + if (node.textContent && node.textContent.length > 0) { + textNode = node + break + } + } + + if (!textNode) { + return { error: 'No text node found after insert', afterInsert } + } + + const deleteEvent = new InputEvent('beforeinput', { + inputType: 'deleteContentBackward', + bubbles: true, + cancelable: true + }) + + // iOS only provides targetRange for the last character + const textLen = textNode.textContent?.length || 0 + ;(deleteEvent as any).getTargetRanges = () => [ + { + startContainer: textNode, + startOffset: textLen - 1, + endContainer: textNode, + endOffset: textLen, + collapsed: false + } + ] + + editor.dispatchEvent(deleteEvent) + + // Wait for React to process the delete + await new Promise((r) => setTimeout(r, 50)) + + return { + afterInsert, + finalText: editor.textContent?.replace(/\u200B/g, ''), + // Check if trailing space is preserved + hasTrailingSpace: editor.textContent + ?.replace(/\u200B/g, '') + .endsWith(' ') + } + }) + + console.log('Result:', JSON.stringify(result, null, 2)) + + // Verify the insert worked (including the space) + expect(result.afterInsert).toBe('hello world') + + // EXPECTED: "world" deleted but space preserved -> "hello " + // ACTUAL BUG: Both space and word deleted -> "hello" + expect(result.finalText).toBe('hello ') + expect(result.hasTrailingSpace).toBe(true) + }) +}) + +/** + * iOS Safari Text Suggestion Bug + * + * THE BUG (from real iOS device testing): + * When user types "hel" and taps a keyboard suggestion "hello": + * 1. iOS fires insertReplacementText with data=null but dataTransfer="hello" + * 2. Our code checks `if (!data) return` and bails out + * 3. iOS then fires insertText with data=" " to insert a space + * 4. Result: "hel" becomes " " instead of "hello" + * + * EXPECTED: "hel" → "hello" (the suggested word) + * ACTUAL BUG: "hel" → " " (just a space) + * + * THE FIX: Check event.dataTransfer.getData('text/plain') as fallback when data is null + */ +test.describe('iOS Safari text suggestion', () => { + // Skip on mobile-safari: cannot emulate iOS native IME behavior + test.beforeEach(async ({}, testInfo) => { + test.skip( + testInfo.project.name === 'mobile-safari', + 'Skipped on mobile-safari: cannot emulate iOS native IME behavior' + ) + }) + + /** + * iOS Safari sends insertReplacementText with the replacement text in + * dataTransfer instead of data (unlike Android GBoard which uses data). + * + * Note: This test is skipped on webkit/mobile-safari because the test environment + * doesn't support passing DataTransfer to InputEvent constructor. The fix has been + * verified on real iOS Safari devices via console logging. + */ + test('tapping a keyboard suggestion should replace in-progress word', async ({ + mount, + page, + browserName + }) => { + // Skip webkit in test environment - DataTransfer constructor doesn't work the same way + // Real iOS Safari behavior verified via console logging on actual device + test.skip( + browserName === 'webkit', + 'webkit test env does not support DataTransfer in InputEvent constructor' + ) + + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + + // Step 1: Type "hel" (simulating user typing before suggestion) + await page.keyboard.type('hel') + await expect(ed).toHaveText('hel') + + // Step 2: Simulate iOS Safari text suggestion tap + // iOS sends insertReplacementText with data=null and replacement in dataTransfer + const result = await page.evaluate(async () => { + const editor = document.querySelector('[role="textbox"]') as HTMLElement + + // Find the text node containing "hel" + const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT) + let textNode: Text | null = null + while (walker.nextNode()) { + const node = walker.currentNode as Text + if (node.textContent && node.textContent.includes('hel')) { + textNode = node + break + } + } + + if (!textNode) { + return { + error: 'No text node found', + editorContent: editor.textContent + } + } + + // Create a mock DataTransfer with the suggestion text + const dataTransfer = new DataTransfer() + dataTransfer.setData('text/plain', 'hello') + + // Create the insertReplacementText event (iOS Safari style) + const replaceEvent = new InputEvent('beforeinput', { + inputType: 'insertReplacementText', + data: null, // iOS Safari puts null here! + dataTransfer: dataTransfer, + bubbles: true, + cancelable: true + }) + + // Set up getTargetRanges to return the range to replace ("hel" at 0-3) + const textContent = textNode.textContent || '' + const helStart = textContent.indexOf('hel') + ;(replaceEvent as any).getTargetRanges = () => [ + { + startContainer: textNode, + startOffset: helStart >= 0 ? helStart : 0, + endContainer: textNode, + endOffset: helStart >= 0 ? helStart + 3 : 3, + collapsed: false + } + ] + + editor.dispatchEvent(replaceEvent) + + // Wait for React to process + await new Promise((r) => setTimeout(r, 50)) + + return { + finalText: editor.textContent?.replace(/\u200B/g, ''), + wasReplaced: editor.textContent?.includes('hello') + } + }) + + console.log('iOS text suggestion result:', JSON.stringify(result, null, 2)) + + // EXPECTED: "hel" should be replaced with "hello" + // ACTUAL BUG: "hel" remains unchanged because we bail out when data is null + expect(result.error).toBeUndefined() + expect(result.finalText).toBe('hello') + expect(result.wasReplaced).toBe(true) + }) + + /** + * Verify that Android-style insertReplacementText (with data in event.data) + * still works after the iOS fix. + */ + test('Android GBoard style suggestion should still work', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + + // Type "hel" + await page.keyboard.type('hel') + await expect(ed).toHaveText('hel') + + // Simulate Android GBoard style - data is in event.data (not dataTransfer) + const result = await page.evaluate(async () => { + const editor = document.querySelector('[role="textbox"]') as HTMLElement + + const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT) + let textNode: Text | null = null + while (walker.nextNode()) { + const node = walker.currentNode as Text + if (node.textContent && node.textContent.includes('hel')) { + textNode = node + break + } + } + + if (!textNode) { + return { error: 'No text node found' } + } + + // Android style: data is in event.data + const replaceEvent = new InputEvent('beforeinput', { + inputType: 'insertReplacementText', + data: 'hello', // Android puts the replacement here + bubbles: true, + cancelable: true + }) + + const textContent = textNode.textContent || '' + const helStart = textContent.indexOf('hel') + ;(replaceEvent as any).getTargetRanges = () => [ + { + startContainer: textNode, + startOffset: helStart >= 0 ? helStart : 0, + endContainer: textNode, + endOffset: helStart >= 0 ? helStart + 3 : 3, + collapsed: false + } + ] + + editor.dispatchEvent(replaceEvent) + + await new Promise((r) => setTimeout(r, 50)) + + return { + finalText: editor.textContent?.replace(/\u200B/g, '') + } + }) + + expect(result.error).toBeUndefined() + expect(result.finalText).toBe('hello') + }) +}) + +/** + * iOS Swipe-Text Trailing Space Bug + * + * THE BUG (from real iOS device testing): + * When user swipe-types a word, iOS sends: + * 1. insertText with the swiped word (e.g., "hello") - multi-char, we track it + * 2. insertText with a single space " " - this CLEARS our tracking! + * 3. User presses backspace - tracking is null, so we only delete one char + * + * EXPECTED: Backspace after swipe+space should delete the swiped word + * ACTUAL BUG: Only deletes the trailing space because tracking was cleared + * + * THE FIX: When a single space follows a multi-char insert within a short + * time window, extend the tracking to include the space instead of clearing it. + */ +test.describe('iOS swipe-text trailing space', () => { + // Skip on mobile-safari: cannot emulate iOS native IME behavior + test.beforeEach(async ({}, testInfo) => { + test.skip( + testInfo.project.name === 'mobile-safari', + 'Skipped on mobile-safari: cannot emulate iOS native IME behavior' + ) + }) + + /** + * iOS sends a trailing space after swipe-typing, which clears our tracking. + * Backspace should still delete the whole swiped word. + */ + test('backspace after swipe + trailing space should delete swiped word', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + + const result = await page.evaluate(async () => { + const editor = document.querySelector('[role="textbox"]') as HTMLElement + + // Step 1: Simulate swipe-typing "hello" (multi-char insert) + const swipeEvent = new InputEvent('beforeinput', { + inputType: 'insertText', + data: 'hello', + bubbles: true, + cancelable: true + }) + + const sel = window.getSelection() + if (sel && sel.rangeCount > 0) { + const range = sel.getRangeAt(0) + ;(swipeEvent as any).getTargetRanges = () => [ + { + startContainer: range.startContainer, + startOffset: range.startOffset, + endContainer: range.endContainer, + endOffset: range.endOffset, + collapsed: true + } + ] + } else { + ;(swipeEvent as any).getTargetRanges = () => [] + } + + editor.dispatchEvent(swipeEvent) + await new Promise((r) => setTimeout(r, 30)) + + const afterSwipe = editor.textContent?.replace(/\u200B/g, '') + + // Step 2: iOS sends a trailing space as a SEPARATE single-char event + // This is the bug trigger - it clears our multi-char tracking! + const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT) + let textNode: Text | null = null + while (walker.nextNode()) { + const node = walker.currentNode as Text + if (node.textContent && node.textContent.length > 0) { + textNode = node + break + } + } + + if (!textNode) { + return { error: 'No text node found after swipe', afterSwipe } + } + + const spaceEvent = new InputEvent('beforeinput', { + inputType: 'insertText', + data: ' ', // Single space - this triggers the bug! + bubbles: true, + cancelable: true + }) + + const textLen = textNode.textContent?.length || 0 + ;(spaceEvent as any).getTargetRanges = () => [ + { + startContainer: textNode, + startOffset: textLen, + endContainer: textNode, + endOffset: textLen, + collapsed: true + } + ] + + editor.dispatchEvent(spaceEvent) + await new Promise((r) => setTimeout(r, 30)) + + const afterSpace = editor.textContent?.replace(/\u200B/g, '') + + // Step 3: Press backspace - should delete the whole swiped word + // Find the text node again after React re-render + const walker2 = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT) + let textNode2: Text | null = null + while (walker2.nextNode()) { + const node = walker2.currentNode as Text + if (node.textContent && node.textContent.length > 0) { + textNode2 = node + break + } + } + + if (!textNode2) { + return { + error: 'No text node found after space', + afterSwipe, + afterSpace + } + } + + const deleteEvent = new InputEvent('beforeinput', { + inputType: 'deleteContentBackward', + bubbles: true, + cancelable: true + }) + + const textLen2 = textNode2.textContent?.length || 0 + ;(deleteEvent as any).getTargetRanges = () => [ + { + startContainer: textNode2, + startOffset: textLen2 - 1, + endContainer: textNode2, + endOffset: textLen2, + collapsed: false + } + ] + + editor.dispatchEvent(deleteEvent) + await new Promise((r) => setTimeout(r, 50)) + + return { + afterSwipe, + afterSpace, + finalText: editor.textContent?.replace(/\u200B/g, ''), + // Check what was deleted + deletedCorrectly: + editor.textContent?.replace(/\u200B/g, '') === '' || + editor.textContent?.replace(/\u200B/g, '') === ' ' + } + }) + + console.log('Trailing space test result:', JSON.stringify(result, null, 2)) + + // Verify setup worked + expect(result.error).toBeUndefined() + expect(result.afterSwipe).toBe('hello') + expect(result.afterSpace).toBe('hello ') + + // EXPECTED: Entire swiped word deleted (leaving empty or just the space) + // ACTUAL BUG: Only one char deleted because tracking was cleared by space + // We expect the result to be empty string (whole word + space deleted) + // or just a space (word deleted, space preserved) + expect(result.finalText).toMatch(/^[ ]?$/) // Empty or single space + }) + + /** + * Multiple swipes in sequence - each swipe's trailing space should not + * break backspace behavior for the most recent swipe. + */ + test('backspace after multiple swipes should delete most recent word', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + + const result = await page.evaluate(async () => { + const editor = document.querySelector('[role="textbox"]') as HTMLElement + + const dispatchInsert = async (text: string) => { + const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT) + let textNode: Text | null = null + let textLen = 0 + while (walker.nextNode()) { + const node = walker.currentNode as Text + if (node.textContent) { + textNode = node + textLen = node.textContent.length + } + } + + const event = new InputEvent('beforeinput', { + inputType: 'insertText', + data: text, + bubbles: true, + cancelable: true + }) + + if (textNode) { + ;(event as any).getTargetRanges = () => [ + { + startContainer: textNode, + startOffset: textLen, + endContainer: textNode, + endOffset: textLen, + collapsed: true + } + ] + } else { + const sel = window.getSelection() + if (sel && sel.rangeCount > 0) { + const range = sel.getRangeAt(0) + ;(event as any).getTargetRanges = () => [ + { + startContainer: range.startContainer, + startOffset: range.startOffset, + endContainer: range.endContainer, + endOffset: range.endOffset, + collapsed: true + } + ] + } else { + ;(event as any).getTargetRanges = () => [] + } + } + + editor.dispatchEvent(event) + await new Promise((r) => setTimeout(r, 30)) + } + + // Swipe "hello" + trailing space + await dispatchInsert('hello') + await dispatchInsert(' ') + + // Swipe "world" + trailing space + await dispatchInsert('world') + await dispatchInsert(' ') + + const beforeDelete = editor.textContent?.replace(/\u200B/g, '') + + // Press backspace + const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT) + let textNode: Text | null = null + while (walker.nextNode()) { + const node = walker.currentNode as Text + if (node.textContent && node.textContent.length > 0) { + textNode = node + break + } + } + + if (!textNode) { + return { error: 'No text node', beforeDelete } + } + + const deleteEvent = new InputEvent('beforeinput', { + inputType: 'deleteContentBackward', + bubbles: true, + cancelable: true + }) + + const textLen = textNode.textContent?.length || 0 + ;(deleteEvent as any).getTargetRanges = () => [ + { + startContainer: textNode, + startOffset: textLen - 1, + endContainer: textNode, + endOffset: textLen, + collapsed: false + } + ] + + editor.dispatchEvent(deleteEvent) + await new Promise((r) => setTimeout(r, 50)) + + return { + beforeDelete, + finalText: editor.textContent?.replace(/\u200B/g, '') + } + }) + + console.log('Multiple swipes test result:', JSON.stringify(result, null, 2)) + + expect(result.error).toBeUndefined() + expect(result.beforeDelete).toBe('hello world ') + + // EXPECTED: "world " deleted, leaving "hello " or "hello" + // ACTUAL BUG: Only one char deleted, leaving "hello world" + expect(result.finalText).toMatch(/^hello ?$/) + }) +}) + +/** + * iOS Swipe-Text Double Space Prevention + * + * THE BUG (from real iOS device testing): + * When user swipes "hello", iOS auto-adds a trailing space → "hello " + * When user then swipes "world", iOS sends " world" (with leading space) + * Result: "hello world" with DOUBLE SPACE + * + * EXPECTED: "hello world" (single space between words) + * ACTUAL BUG: "hello world" (double space) + * + * THE FIX: When inserting a multi-char string that starts with a space, + * check if the character before is already a space. If so, strip the + * leading space from the insert to avoid double-spacing. + */ +test.describe('iOS swipe-text double space prevention', () => { + // Skip on mobile-safari: cannot emulate iOS native IME behavior + test.beforeEach(async ({}, testInfo) => { + test.skip( + testInfo.project.name === 'mobile-safari', + 'Skipped on mobile-safari: cannot emulate iOS native IME behavior' + ) + }) + + /** + * When swiping " world" after "hello " (which already has trailing space), + * the leading space should be stripped to avoid double-spacing. + */ + test('swipe after trailing space should not create double space', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + + const result = await page.evaluate(async () => { + const editor = document.querySelector('[role="textbox"]') as HTMLElement + + const dispatchInsert = async (text: string) => { + const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT) + let textNode: Text | null = null + let textLen = 0 + while (walker.nextNode()) { + const node = walker.currentNode as Text + if (node.textContent) { + textNode = node + textLen = node.textContent.length + } + } + + const event = new InputEvent('beforeinput', { + inputType: 'insertText', + data: text, + bubbles: true, + cancelable: true + }) + + if (textNode) { + ;(event as any).getTargetRanges = () => [ + { + startContainer: textNode, + startOffset: textLen, + endContainer: textNode, + endOffset: textLen, + collapsed: true + } + ] + } else { + const sel = window.getSelection() + if (sel && sel.rangeCount > 0) { + const range = sel.getRangeAt(0) + ;(event as any).getTargetRanges = () => [ + { + startContainer: range.startContainer, + startOffset: range.startOffset, + endContainer: range.endContainer, + endOffset: range.endOffset, + collapsed: true + } + ] + } else { + ;(event as any).getTargetRanges = () => [] + } + } + + editor.dispatchEvent(event) + await new Promise((r) => setTimeout(r, 30)) + } + + // Step 1: Swipe "hello" + await dispatchInsert('hello') + const afterHello = editor.textContent?.replace(/\u200B/g, '') + + // Step 2: iOS adds trailing space + await dispatchInsert(' ') + const afterSpace = editor.textContent?.replace(/\u200B/g, '') + + // Step 3: Swipe " world" (iOS sends with leading space) + await dispatchInsert(' world') + const afterWorld = editor.textContent?.replace(/\u200B/g, '') + + // Count spaces between hello and world + const match = afterWorld?.match(/hello( +)world/) + const spaceCount = match ? match[1].length : 0 + + return { + afterHello, + afterSpace, + afterWorld, + spaceCount, + hasDoubleSpace: afterWorld?.includes(' ') + } + }) + + console.log('Double space test result:', JSON.stringify(result, null, 2)) + + expect(result.afterHello).toBe('hello') + expect(result.afterSpace).toBe('hello ') + + // EXPECTED: "hello world" (single space) + // ACTUAL BUG: "hello world" (double space) + expect(result.afterWorld).toBe('hello world') + expect(result.spaceCount).toBe(1) + expect(result.hasDoubleSpace).toBe(false) + }) +}) + +/** + * iOS swipe after newline tests + * + * When swiping on a new line (after Enter), iOS often adds a leading space. + * This space should be stripped since it's at the start of a line. + */ +test.describe('iOS swipe-text after newline', () => { + // Skip on mobile-safari: cannot emulate iOS native IME behavior + test.beforeEach(async ({}, testInfo) => { + test.skip( + testInfo.project.name === 'mobile-safari', + 'Skipped on mobile-safari: cannot emulate iOS native IME behavior' + ) + }) + + /** + * When swiping "world" after "hello\n", the leading space should be stripped. + * iOS sends swipe data with leading space even at start of line. + */ + test('swipe after newline should not have leading space', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + + const result = await page.evaluate(async () => { + const editor = document.querySelector('[role="textbox"]') as HTMLElement + + // Helper to dispatch beforeinput and let it be handled + const dispatchBeforeInput = async ( + inputType: string, + data: string | null + ) => { + const event = new InputEvent('beforeinput', { + inputType, + data, + bubbles: true, + cancelable: true + }) + + const sel = window.getSelection() + if (sel && sel.rangeCount > 0) { + const range = sel.getRangeAt(0) + ;(event as any).getTargetRanges = () => [ + { + startContainer: range.startContainer, + startOffset: range.startOffset, + endContainer: range.endContainer, + endOffset: range.endOffset, + collapsed: range.collapsed + } + ] + } else { + ;(event as any).getTargetRanges = () => [] + } + + editor.dispatchEvent(event) + await new Promise((r) => setTimeout(r, 50)) + } + + // Step 1: Type "hello" (simulating char-by-char typing via swipe for simplicity) + await dispatchBeforeInput('insertText', 'hello') + + // Step 2: Press Enter (simulated via keydown) + const enterEvent = new KeyboardEvent('keydown', { + key: 'Enter', + bubbles: true, + cancelable: true + }) + editor.dispatchEvent(enterEvent) + await new Promise((r) => setTimeout(r, 50)) + + const afterEnter = editor.innerText?.replace(/\u200B/g, '').trim() + + // Step 3: Swipe " world" on the new line (iOS sends with leading space) + await dispatchBeforeInput('insertText', ' world') + + const finalValue = editor.innerText?.replace(/\u200B/g, '') + + return { + afterEnter, + finalValue, + startsWithSpaceOnLine2: finalValue?.split('\n')[1]?.startsWith(' ') + } + }) + + console.log('Swipe after newline result:', JSON.stringify(result, null, 2)) + + // The second line should NOT start with a space + expect(result.startsWithSpaceOnLine2).toBe(false) + // Final value should be "hello\nworld" not "hello\n world" + expect(result.finalValue).toMatch(/hello\n\s*world/) + expect(result.finalValue).not.toContain('\n ') + }) + + /** + * iOS sometimes sends a single space as a separate event BEFORE the swiped word. + * This space should be skipped entirely at the start of a line. + */ + test('single space before swipe word at start of line should be skipped', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + + const result = await page.evaluate(async () => { + const editor = document.querySelector('[role="textbox"]') as HTMLElement + + const dispatchBeforeInput = async ( + inputType: string, + data: string | null + ) => { + const event = new InputEvent('beforeinput', { + inputType, + data, + bubbles: true, + cancelable: true + }) + + const sel = window.getSelection() + if (sel && sel.rangeCount > 0) { + const range = sel.getRangeAt(0) + ;(event as any).getTargetRanges = () => [ + { + startContainer: range.startContainer, + startOffset: range.startOffset, + endContainer: range.endContainer, + endOffset: range.endOffset, + collapsed: range.collapsed + } + ] + } else { + ;(event as any).getTargetRanges = () => [] + } + + editor.dispatchEvent(event) + await new Promise((r) => setTimeout(r, 50)) + } + + // Step 1: Insert "hello" + await dispatchBeforeInput('insertText', 'hello') + + // Step 2: Press Enter + const enterEvent = new KeyboardEvent('keydown', { + key: 'Enter', + bubbles: true, + cancelable: true + }) + editor.dispatchEvent(enterEvent) + await new Promise((r) => setTimeout(r, 50)) + + // Step 3: iOS sends JUST a space first (separate event before the word) + await dispatchBeforeInput('insertText', ' ') + const afterSpace = editor.innerText?.replace(/\u200B/g, '') + + // Step 4: iOS sends the actual word + await dispatchBeforeInput('insertText', 'world') + const finalValue = editor.innerText?.replace(/\u200B/g, '') + + return { + afterSpace, + finalValue, + line2: finalValue?.split('\n')[1] + } + }) + + console.log( + 'Single space before swipe result:', + JSON.stringify(result, null, 2) + ) + + // The space should have been skipped, so line 2 should be "world" not " world" + expect(result.line2).toBe('world') + expect(result.finalValue).not.toContain('\n ') + }) +}) diff --git a/src/inlay/__ct__/inlay.mobile.spec.tsx b/src/inlay/__ct__/inlay.mobile.spec.tsx new file mode 100644 index 0000000..b6ec115 --- /dev/null +++ b/src/inlay/__ct__/inlay.mobile.spec.tsx @@ -0,0 +1,155 @@ +import { test, expect } from '@playwright/experimental-ct-react' +import { MobileInlay, SimpleMobileInlay } from './fixtures/mobile-inlay' + +// These tests run on mobile projects defined in playwright-ct.config.mts +// Run with: bun run test:ct -- --project=mobile-chrome +// or: bun run test:ct -- --project=mobile-safari + +test.describe('Mobile touch interaction', () => { + test('tap positions caret in editor', async ({ mount, page }) => { + await mount() + + const editor = page.getByRole('textbox') + await expect(editor).toHaveCount(1) + + // Click to focus (tap in mobile projects) + await editor.click() + await expect(editor).toBeFocused() + }) + + test('editor has correct mobile attributes', async ({ mount, page }) => { + await mount() + + const editor = page.getByRole('textbox') + await expect(editor).toHaveAttribute('inputmode', 'text') + await expect(editor).toHaveAttribute('autocapitalize', 'sentences') + // Note: autoCorrect is intentionally omitted (undefined) to let iOS use native behavior + // for keyboard suggestions. When not set, iOS defaults to system settings. + await expect(editor).toHaveAttribute('spellcheck', 'false') + }) + + test('editor has touch-action manipulation style', async ({ + mount, + page + }) => { + await mount() + + const editor = page.getByRole('textbox') + const touchAction = await editor.evaluate( + (el) => window.getComputedStyle(el).touchAction + ) + expect(touchAction).toBe('manipulation') + }) + + test('typing works after focus', async ({ mount, page }) => { + await mount() + + const editor = page.getByRole('textbox') + await editor.click() + + // Type one character at a time and verify it appears + await page.keyboard.type('a') + const rawValue = await page.getByTestId('raw-value').textContent() + expect(rawValue).toContain('a') + }) + + test('text editing works', async ({ mount, page }) => { + await mount() + + const editor = page.getByRole('textbox') + await editor.click() + await expect(editor).toBeFocused() + + // Type at caret position + await page.keyboard.type('!') + // Value should have changed + const rawValue = await page.getByTestId('raw-value').textContent() + expect(rawValue).toContain('!') + }) +}) + +test.describe('Portal interaction', () => { + test('portal items respond to click/tap', async ({ mount, page }) => { + await mount() + + const editor = page.getByRole('textbox') + await editor.click() + + // Portal should be visible when there's a mention token + const portal = page.getByTestId('portal') + await expect(portal).toBeVisible() + + // Click on a portal item + const item = page.getByTestId('item-1') + await expect(item).toBeVisible() + await item.click() + + // Should have selected the item + await expect(page.getByTestId('selected')).toHaveText('Apple') + }) + + test('portal item selection works', async ({ mount, page }) => { + await mount() + + const editor = page.getByRole('textbox') + await editor.click() + + const portal = page.getByTestId('portal') + await expect(portal).toBeVisible() + + // Click item 2 + const item2 = page.getByTestId('item-2') + await item2.click() + + // Should have selected item 2 + await expect(page.getByTestId('selected')).toHaveText('Banana') + }) + + test('portal has touch-action manipulation style', async ({ + mount, + page + }) => { + await mount() + + const editor = page.getByRole('textbox') + await editor.click() + + const portal = page.getByTestId('portal-list') + await expect(portal).toBeVisible() + + const touchAction = await portal.evaluate( + (el) => window.getComputedStyle(el).touchAction + ) + expect(touchAction).toBe('manipulation') + }) +}) + +test.describe('Token interaction', () => { + test('interacting near token works correctly', async ({ mount, page }) => { + await mount() + + const editor = page.getByRole('textbox') + await editor.click() + + // Should be able to type + await page.keyboard.type('!') + const rawValue = await page.getByTestId('raw-value').textContent() + expect(rawValue).toContain('!') + }) + + test('backspace near token works', async ({ mount, page }) => { + await mount() + + const editor = page.getByRole('textbox') + await editor.click() + + // Move to end + for (let i = 0; i < 10; i++) await page.keyboard.press('ArrowRight') + + // Backspace should delete token characters + await page.keyboard.press('Backspace') + const rawValue = await page.getByTestId('raw-value').textContent() + // Should have deleted something + expect(rawValue?.length).toBeLessThan('a@user'.length) + }) +}) diff --git a/src/inlay/__ct__/inlay.overlap.spec.tsx b/src/inlay/__ct__/inlay.overlap.spec.tsx new file mode 100644 index 0000000..4037fba --- /dev/null +++ b/src/inlay/__ct__/inlay.overlap.spec.tsx @@ -0,0 +1,134 @@ +import { test, expect } from '@playwright/experimental-ct-react' +import { OverlappingPluginsInlay } from './fixtures/overlapping-plugins-inlay' + +test.describe('Plugin overlap resolution (CT)', () => { + test.describe('Exact same substring - two plugins match @test at identical range', () => { + test('only one token renders and first plugin wins (diverged output confirms which)', async ({ + mount, + page + }) => { + // Input: "hello @test world" + // Plugin A matches @test → renders "[A:test]" + // Plugin B matches @test → renders "[B:test]" + // Both match the EXACT same range. Only one should render. + // With proper overlap resolution, first plugin (A) should win. + await mount( + + ) + + const ed = page.getByRole('textbox') + await expect(ed).toBeVisible() + + // Should have exactly one token, not two + const allTokens = ed.locator('[data-plugin]') + await expect(allTokens).toHaveCount(1) + + // The visual output should be from Plugin A (first in array) + // This confirms which plugin "won" via diverged rendering + await expect(ed).toContainText('[A:test]') + await expect(ed).not.toContainText('[B:test]') + + // Confirm it's Plugin A's token + await expect(allTokens.first()).toHaveAttribute('data-plugin', 'pluginA') + }) + }) + + test.describe('Longer match wins - @alice vs @alice_vip at same start', () => { + test('longer @alice_vip token wins over shorter @alice match', async ({ + mount, + page + }) => { + // Input: "hello @alice_vip world" + // Short plugin matches "@alice" at position 6-12 + // Long plugin matches "@alice_vip" at position 6-16 + // Both start at position 6, but Long is 10 chars vs Short's 6 chars + // Longest-match-wins: Long plugin should win + await mount( + + ) + + const ed = page.getByRole('textbox') + await expect(ed).toBeVisible() + + // Should have exactly one token (the longer match consumed the range) + const allTokens = ed.locator('[data-plugin]') + await expect(allTokens).toHaveCount(1) + + // The visual should be from Long plugin, not Short + await expect(ed).toContainText('[long:alice_vip]') + await expect(ed).not.toContainText('[short:alice]') + + // Confirm it's Long plugin's token + const token = allTokens.first() + await expect(token).toHaveAttribute('data-plugin', 'long') + await expect(token).toHaveAttribute('data-token-raw', '@alice_vip') + }) + + test('short match still works when long pattern does not apply', async ({ + mount, + page + }) => { + // Input: "hello @alice world" (no _vip suffix) + // Short plugin matches "@alice" at position 6-12 + // Long plugin does NOT match (no @alice_vip in text) + // Short plugin should render since there's no competition + await mount( + + ) + + const ed = page.getByRole('textbox') + await expect(ed).toBeVisible() + + // Should have exactly one token from Short plugin + const allTokens = ed.locator('[data-plugin]') + await expect(allTokens).toHaveCount(1) + + await expect(ed).toContainText('[short:alice]') + await expect(allTokens.first()).toHaveAttribute('data-plugin', 'short') + }) + }) + + test.describe('Non-overlapping tokens preserved', () => { + test('multiple non-overlapping tokens from different plugins are all rendered', async ({ + mount, + page + }) => { + await mount( + + ) + + const ed = page.getByRole('textbox') + await expect(ed).toBeVisible() + + // Should have 4 tokens total (2 mentions, 2 hashtags) + const allTokens = ed.locator('[data-plugin]') + await expect(allTokens).toHaveCount(4) + + // Two mentions + const mentionTokens = ed.locator('[data-plugin="mention"]') + await expect(mentionTokens).toHaveCount(2) + + // Two hashtags + const hashtagTokens = ed.locator('[data-plugin="hashtag"]') + await expect(hashtagTokens).toHaveCount(2) + + // Verify specific values exist + await expect(ed.locator('[data-token-raw="@alice"]')).toHaveCount(1) + await expect(ed.locator('[data-token-raw="@bob"]')).toHaveCount(1) + await expect(ed.locator('[data-token-raw="#react"]')).toHaveCount(1) + await expect(ed.locator('[data-token-raw="#vue"]')).toHaveCount(1) + }) + }) +}) diff --git a/src/inlay/__ct__/inlay.portal-navigation.spec.tsx b/src/inlay/__ct__/inlay.portal-navigation.spec.tsx new file mode 100644 index 0000000..a80dc98 --- /dev/null +++ b/src/inlay/__ct__/inlay.portal-navigation.spec.tsx @@ -0,0 +1,157 @@ +import { test, expect } from '@playwright/experimental-ct-react' +import type { Page } from '@playwright/test' +import type { MountResult } from '@playwright/experimental-ct-react' +import { PortalNavigationInlay } from './fixtures/portal-navigation-inlay' + +test.describe('Portal keyboard navigation (CT)', () => { + // Helper to set up the test with portal open + async function setupPortal( + mount: (component: React.ReactElement) => Promise, + page: Page + ) { + await mount() + + const editor = page.getByTestId('editor') + await expect(editor).toBeVisible() + + // Wait for token to be rendered - scope to editor to avoid hidden first-pass + const tokenRender = editor.getByTestId('token-render') + await expect(tokenRender).toBeVisible() + + // Click on the token to activate portal + await tokenRender.click() + + // Wait for portal to appear + const portal = page.getByTestId('portal') + await expect(portal).toBeVisible() + + return { editor, portal } + } + + test('ArrowDown navigates to next item', async ({ mount, page }) => { + await setupPortal(mount, page) + + // First item should be active by default + const item1 = page.getByTestId('item-1') + const item2 = page.getByTestId('item-2') + + await expect(item1).toHaveAttribute('data-active') + await expect(item2).not.toHaveAttribute('data-active') + + // Press ArrowDown + await page.keyboard.press('ArrowDown') + + await expect(item1).not.toHaveAttribute('data-active') + await expect(item2).toHaveAttribute('data-active') + }) + + test('ArrowUp navigates to previous item', async ({ mount, page }) => { + await setupPortal(mount, page) + + // Navigate down first + await page.keyboard.press('ArrowDown') + await page.keyboard.press('ArrowDown') + + const item3 = page.getByTestId('item-3') + await expect(item3).toHaveAttribute('data-active') + + // Press ArrowUp + await page.keyboard.press('ArrowUp') + + const item2 = page.getByTestId('item-2') + await expect(item2).toHaveAttribute('data-active') + }) + + test('ArrowDown wraps from last to first item', async ({ mount, page }) => { + await setupPortal(mount, page) + + // Navigate to last item + await page.keyboard.press('ArrowDown') + await page.keyboard.press('ArrowDown') + + const item3 = page.getByTestId('item-3') + await expect(item3).toHaveAttribute('data-active') + + // Press ArrowDown again - should wrap to first + await page.keyboard.press('ArrowDown') + + const item1 = page.getByTestId('item-1') + await expect(item1).toHaveAttribute('data-active') + }) + + test('ArrowUp wraps from first to last item', async ({ mount, page }) => { + await setupPortal(mount, page) + + // First item is active by default + const item1 = page.getByTestId('item-1') + await expect(item1).toHaveAttribute('data-active') + + // Press ArrowUp - should wrap to last + await page.keyboard.press('ArrowUp') + + const item3 = page.getByTestId('item-3') + await expect(item3).toHaveAttribute('data-active') + }) + + test('Enter selects the active item', async ({ mount, page }) => { + await setupPortal(mount, page) + + // Navigate to second item + await page.keyboard.press('ArrowDown') + + // Press Enter to select + await page.keyboard.press('Enter') + + // Check that the item was selected + const selected = page.getByTestId('selected') + await expect(selected).toHaveText('Banana') + + // Check that the raw value was updated + const rawValue = page.getByTestId('raw-value') + await expect(rawValue).toHaveText('hello @2 ') + }) + + test('Mouse hover changes active item', async ({ mount, page }) => { + await setupPortal(mount, page) + + const item1 = page.getByTestId('item-1') + const item3 = page.getByTestId('item-3') + + // First item is active by default + await expect(item1).toHaveAttribute('data-active') + + // Hover over third item + await item3.hover() + + // Third item should now be active + await expect(item3).toHaveAttribute('data-active') + await expect(item1).not.toHaveAttribute('data-active') + }) + + test('Click selects the item', async ({ mount, page }) => { + await setupPortal(mount, page) + + // Click on third item + const item3 = page.getByTestId('item-3') + await item3.click() + + // Check that the item was selected + const selected = page.getByTestId('selected') + await expect(selected).toHaveText('Cherry') + + // Check that the raw value was updated + const rawValue = page.getByTestId('raw-value') + await expect(rawValue).toHaveText('hello @3 ') + }) + + test('Focus stays in editor after navigation', async ({ mount, page }) => { + const { editor } = await setupPortal(mount, page) + + // Navigate with arrow keys + await page.keyboard.press('ArrowDown') + await page.keyboard.press('ArrowDown') + + // Focus should still be in editor + await expect(editor).toBeFocused() + }) +}) diff --git a/src/inlay/__ct__/inlay.structured-actions.spec.tsx b/src/inlay/__ct__/inlay.structured-actions.spec.tsx new file mode 100644 index 0000000..0d2d648 --- /dev/null +++ b/src/inlay/__ct__/inlay.structured-actions.spec.tsx @@ -0,0 +1,126 @@ +import { test, expect } from '@playwright/experimental-ct-react' +import { StructuredActionsInlay } from './fixtures/structured-actions-inlay' + +test.describe('StructuredInlay replace/update actions (CT)', () => { + test('update changes rendered label without changing raw value', async ({ + mount, + page + }) => { + await mount() + + const ed = page.getByTestId('editor') + await expect(ed).toBeVisible() + + // Wait for token to be weaved (scope to editor to avoid hidden first-pass element) + const tokenRender = ed.getByTestId('token-render') + await expect(tokenRender).toBeVisible() + await expect(tokenRender).toHaveText('@alice') + + // Click on the token to activate portal + await tokenRender.click() + + // Wait for portal to appear + const portal = page.getByTestId('portal') + await expect(portal).toBeVisible() + + // Click the update button + await page.getByTestId('btn-update').click() + + // Verify rendered label changed + await expect(tokenRender).toHaveText('UpdatedLabel') + + // Verify raw value is unchanged + const rawValue = page.getByTestId('raw-value') + await expect(rawValue).toHaveText('hello @alice world') + }) + + test('replace changes raw value', async ({ mount, page }) => { + await mount() + + const ed = page.getByTestId('editor') + await expect(ed).toBeVisible() + + // Wait for token to be weaved (scope to editor) + const tokenRender = ed.getByTestId('token-render') + await expect(tokenRender).toBeVisible() + + // Click on the token to activate portal + await tokenRender.click() + + // Wait for portal to appear + const portal = page.getByTestId('portal') + await expect(portal).toBeVisible() + + // Click the replace button (replaces @alice with @replaced) + await page.getByTestId('btn-replace').click() + + // Verify raw value changed + const rawValue = page.getByTestId('raw-value') + await expect(rawValue).toHaveText('hello @replaced world') + + // Verify the new token is rendered + await expect(tokenRender).toHaveText('@replaced') + }) + + test('replace positions caret at end of new text', async ({ + mount, + page + }) => { + await mount() + + const ed = page.getByTestId('editor') + await expect(ed).toBeVisible() + + // Wait for token (scope to editor) + const tokenRender = ed.getByTestId('token-render') + await expect(tokenRender).toBeVisible() + await expect(tokenRender).toHaveText('@a') + + // Click on token to activate portal + await tokenRender.click() + await expect(page.getByTestId('portal')).toBeVisible() + + // Replace @a with @replaced + // The portal has onMouseDown preventDefault, so focus stays in editor + await page.getByTestId('btn-replace').click() + + // Verify the value updated correctly + const rawValue = page.getByTestId('raw-value') + await expect(rawValue).toHaveText('hi @replaced bye') + + // Type to verify caret position (focus should still be in editor) + await page.keyboard.type('X') + + // Caret should be after @replaced + await expect(rawValue).toHaveText('hi @replacedX bye') + }) + + test('replace with longer text updates raw value correctly', async ({ + mount, + page + }) => { + await mount() + + const ed = page.getByTestId('editor') + await expect(ed).toBeVisible() + + // Wait for token (scope to editor) + const tokenRender = ed.getByTestId('token-render') + await expect(tokenRender).toBeVisible() + await expect(tokenRender).toHaveText('@a') + + // Click on token to activate portal + await tokenRender.click() + await expect(page.getByTestId('portal')).toBeVisible() + + // Replace @a with @replaced + await page.getByTestId('btn-replace').click() + + // Verify the value updated correctly + const rawValue = page.getByTestId('raw-value') + await expect(rawValue).toHaveText('hi @replaced bye') + + // Verify the token is re-rendered with new value + await expect(tokenRender).toHaveText('@replaced') + }) +}) diff --git a/src/inlay/__ct__/inlay.tokens.spec.tsx b/src/inlay/__ct__/inlay.tokens.spec.tsx new file mode 100644 index 0000000..3920f3e --- /dev/null +++ b/src/inlay/__ct__/inlay.tokens.spec.tsx @@ -0,0 +1,53 @@ +import { test, expect } from '@playwright/experimental-ct-react' +import { ControlledTokenInlay } from './fixtures/controlled-token-inlay' + +test.describe('Token-aware deletions (CT)', () => { + test('Backspace at end of token deletes last raw char', async ({ + mount, + page + }) => { + await mount() + + const ed = page.getByRole('textbox') + await ed.click() + // Ensure token has been weaved into the DOM and content is present + await expect(ed.locator('[data-token-text="@x"]').first()).toBeVisible() + await expect(ed).toHaveText('A@xB') + // overshoot to the right + for (let i = 0; i < 10; i++) await page.keyboard.press('ArrowRight') + await page.keyboard.press('ArrowLeft') + await page.keyboard.press('Backspace') + await expect(ed).toHaveText('A@B') + await expect(ed.locator('[data-token-text]')).toHaveCount(0) + }) + + test('Range delete across token removes the token completely', async ({ + mount, + page + }) => { + await mount() + + const ed = page.getByRole('textbox') + await ed.click() + // Ensure token has been weaved into the DOM and content is present + const token = ed.locator('[data-token-text="@x"]').first() + await expect(token).toBeVisible() + await expect(ed).toHaveText('A@xB') + const edBox = await ed.boundingBox() + const tokBox = await token.boundingBox() + if (!edBox || !tokBox) throw new Error('no boxes') + const y = edBox.y + edBox.height / 2 + const startX = Math.max(edBox.x + 2, tokBox.x - 4) + const endX = Math.min( + edBox.x + edBox.width - 2, + tokBox.x + tokBox.width + 2 + ) + await page.mouse.move(startX, y) + await page.mouse.down() + await page.mouse.move(endX, y, { steps: 5 }) + await page.mouse.up() + await page.keyboard.press('Backspace') + await expect(ed).toHaveText('AB') + await expect(ed.locator('[data-token-text]')).toHaveCount(0) + }) +}) diff --git a/src/inlay/__tests__/inlay-editor-behavior.test.tsx b/src/inlay/__tests__/inlay-editor-behavior.test.tsx new file mode 100644 index 0000000..870fde1 --- /dev/null +++ b/src/inlay/__tests__/inlay-editor-behavior.test.tsx @@ -0,0 +1,695 @@ +import React from 'react' +import { describe, it, expect } from 'vitest' +import { render, fireEvent, act, waitFor } from '@testing-library/react' +import * as Inlay from '../inlay' +import { getAbsoluteOffset, setDomSelection } from '../internal/dom-utils' + +function flush() { + return new Promise((r) => setTimeout(r, 0)) +} + +// Helper to dispatch native beforeinput event for backspace +// This is needed because our handler uses native event listeners +function fireBackspace(element: HTMLElement) { + const sel = window.getSelection() + // Range is captured for potential future use in getTargetRanges simulation + const _range = sel?.rangeCount ? sel.getRangeAt(0) : null + void _range // Acknowledge intentionally unused + + const event = new InputEvent('beforeinput', { + bubbles: true, + cancelable: true, + inputType: 'deleteContentBackward', + data: null + }) + + // jsdom doesn't support getTargetRanges, so we need to ensure selection is set + element.dispatchEvent(event) +} + +// Helper to dispatch native beforeinput for delete forward +function fireDelete(element: HTMLElement) { + const event = new InputEvent('beforeinput', { + bubbles: true, + cancelable: true, + inputType: 'deleteContentForward', + data: null + }) + element.dispatchEvent(event) +} + +// Empty state rendering +describe('Inlay empty state', () => { + it('renders zero-width space for consistent caret height', async () => { + const { getByTestId } = render( + + + + ) + const ed = getByTestId('editor') as HTMLElement + + await act(async () => { + await flush() + }) + + // Should contain a zero-width space (U+200B) to maintain caret baseline + expect(ed.textContent).toBe('\u200B') + }) +}) + +// Editing basics (Space/Enter) — Backspace covered in its own block below +describe('Inlay editing (Space/Enter)', () => { + it('Space and Enter modify value appropriately', async () => { + function Test() { + const [value, setValue] = React.useState('ab') + return ( + + + + ) + } + + const { getByTestId } = render() + const ed = getByTestId('editor') as HTMLElement + + await act(async () => { + ;(ed as HTMLElement).focus() + setDomSelection(ed as HTMLElement, 2) + await flush() + }) + + await act(async () => { + fireEvent.keyDown(ed, { key: ' ' }) + await flush() + }) + expect((ed as HTMLElement).textContent).toBe('ab ') + + await act(async () => { + fireEvent.keyDown(ed, { key: 'Enter' }) + await flush() + }) + expect((ed as HTMLElement).textContent).toBe('ab \n') + + // Backspace semantics are covered separately + }) +}) + +// Backspace semantics +describe('Inlay Backspace semantics', () => { + it('does nothing at start of content (collapsed at 0)', async () => { + function Test() { + const [value, setValue] = React.useState('ab') + return ( + + + + ) + } + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + await act(async () => { + ed.focus() + setDomSelection(ed, 0) + await flush() + }) + + await act(async () => { + fireBackspace(ed) + await flush() + }) + + expect(ed.textContent).toBe('ab') + const sel = window.getSelection()! + const caret = getAbsoluteOffset(ed, sel.focusNode!, sel.focusOffset) + expect(caret).toBe(0) + }) + + it('deletes previous char when collapsed (not at start)', async () => { + function Test() { + const [value, setValue] = React.useState('ab') + return ( + + + + ) + } + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + await act(async () => { + ed.focus() + setDomSelection(ed, 2) + await flush() + }) + + await act(async () => { + fireBackspace(ed) + await flush() + }) + + expect(ed.textContent).toBe('a') + const sel = window.getSelection()! + const caret = getAbsoluteOffset(ed, sel.focusNode!, sel.focusOffset) + expect(caret).toBe(1) + }) + + it('deletes selected range (within plain text)', async () => { + function Test() { + const [value, setValue] = React.useState('abcd') + return ( + + + + ) + } + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + await act(async () => { + ed.focus() + setDomSelection(ed, 1, 3) // select 'bc' + await flush() + }) + + await act(async () => { + fireBackspace(ed) + await flush() + }) + + expect(ed.textContent).toBe('ad') + const sel = window.getSelection()! + const caret = getAbsoluteOffset(ed, sel.focusNode!, sel.focusOffset) + expect(caret).toBe(1) + }) + + it('range delete across a token removes the token raw span', async () => { + function Test() { + const [value, setValue] = React.useState('A@xB') + return ( + + {/* Register token for '@x' so weaving recognizes it */} + + @x + + + ) + } + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + await waitFor(() => { + expect(ed.querySelector('[data-token-text="@x"]')).toBeTruthy() + }) + + await act(async () => { + ed.focus() + setDomSelection(ed, 1, 3) + await flush() + }) + + await act(async () => { + fireBackspace(ed) + await flush() + }) + + expect(ed.querySelector('[data-token-text]')).toBeFalsy() + }) + + it('collapsed backspace at end of token deletes last raw char of token', async () => { + function Test() { + const [value, setValue] = React.useState('A@xB') + return ( + + + @x + + + {({ value }) => } + + + ) + } + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + await waitFor(() => { + expect(ed.querySelector('[data-token-text]')).toBeTruthy() + }) + + await act(async () => { + ed.focus() + setDomSelection(ed, 3) // right after '@x' + await flush() + }) + + await act(async () => { + fireBackspace(ed) + await flush() + }) + + // Deletes 'x' -> raw becomes 'A@B'; token no longer matches + const ctxVal = getByTestId('ctx-val') + expect(ctxVal.getAttribute('data-value')).toBe('A@B') + expect(ed.querySelector('[data-token-text]')).toBeFalsy() + }) +}) + +// onKeyDown interception +describe('Inlay onKeyDown interception', () => { + it('returns true to prevent built-in handling (Space, Enter)', async () => { + const ref = React.createRef() + + function Test() { + const [value, setValue] = React.useState('ab') + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === ' ' || e.key === 'Enter') return true + return false + } + return ( + + + + ) + } + + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + await act(async () => { + ref.current!.setSelection(2) + await flush() + }) + + await act(async () => { + fireEvent.keyDown(ed, { key: ' ' }) + await flush() + }) + expect(ed.textContent).toBe('ab') + + await act(async () => { + fireEvent.keyDown(ed, { key: 'Enter' }) + await flush() + }) + expect(ed.textContent).toBe('ab') + }) +}) + +// Newline rendering +describe('Inlay trailing newline rendering', () => { + it('adds a when value ends with \n (when at least one token is present)', () => { + function Test({ value }: { value: string }) { + return ( + {}} data-testid="root"> + + X + + + ) + } + + const { getByTestId, rerender } = render() + const editor = getByTestId('root') as HTMLElement + expect(editor.querySelector('br')).toBeTruthy() + + rerender() + expect(editor.querySelector('br')).toBeFalsy() + }) + + it('first Enter on empty editor creates a visible newline (trailing )', async () => { + function Test() { + const [value, setValue] = React.useState('') + return ( + + + + ) + } + + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + await act(async () => { + ed.focus() + fireEvent.keyDown(ed, { key: 'Enter' }) + await flush() + }) + + // Expect a trailing to appear immediately after first Enter + expect(ed.querySelector('br')).toBeTruthy() + }) +}) + +// Grapheme cluster behavior +describe('Inlay Backspace with grapheme clusters', () => { + it('deletes full grapheme cluster (emoji + skin tone) when collapsed', async () => { + function Test() { + const [value, setValue] = React.useState('👍🏼') + return ( + + + + ) + } + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + await act(async () => { + ed.focus() + setDomSelection(ed, '👍🏼'.length) // caret after the cluster + await flush() + }) + + await act(async () => { + fireBackspace(ed) + await flush() + }) + + expect(ed.textContent).toBe('\u200B') + const sel = window.getSelection()! + const caret = getAbsoluteOffset(ed, sel.focusNode!, sel.focusOffset) + expect(caret).toBe(0) + }) + + it('deletes an entire flag grapheme (regional indicators) before caret', async () => { + const flag = '🇺🇸' + function Test() { + const [value, setValue] = React.useState(`a${flag}b`) + return ( + + + + ) + } + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + const value = `a${flag}b` + const posAfterFlag = value.indexOf(flag) + flag.length + + await act(async () => { + ed.focus() + setDomSelection(ed, posAfterFlag) + await flush() + }) + + await act(async () => { + fireBackspace(ed) + await flush() + }) + + expect(ed.textContent).toBe('ab') + }) + + it('deletes composed character with combining mark as a single grapheme', async () => { + const composed = 'e\u0301' // e + combining acute + function Test() { + const [value, setValue] = React.useState(composed) + return ( + + + + ) + } + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + await act(async () => { + ed.focus() + setDomSelection(ed, composed.length) + await flush() + }) + + await act(async () => { + fireBackspace(ed) + await flush() + }) + + expect(ed.textContent).toBe('\u200B') + }) +}) + +describe('Inlay selection deletion is grapheme-aware', () => { + it('Backspace with selection slicing through a grapheme deletes the whole grapheme', async () => { + const cluster = '👍🏼' + const text = `a${cluster}b` + function Test() { + const [value, setValue] = React.useState(text) + return ( + + + + ) + } + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + const start = 1 + 1 // into the grapheme + const end = 1 + cluster.length - 1 // still inside the grapheme + + await act(async () => { + ed.focus() + setDomSelection(ed, start, end) + await flush() + }) + + await act(async () => { + fireBackspace(ed) + await flush() + }) + + // Expect entire grapheme removed, leaving 'ab' + expect(ed.textContent).toBe('ab') + const sel = window.getSelection()! + const caret = getAbsoluteOffset(ed, sel.focusNode!, sel.focusOffset) + expect(caret).toBe(1) + }) + + it('Delete with selection slicing through a grapheme deletes the whole grapheme', async () => { + const cluster = '🇺🇸' + const text = `x${cluster}y` + function Test() { + const [value, setValue] = React.useState(text) + return ( + + + + ) + } + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + const start = 1 // start at grapheme start + const end = 1 + 1 // end in the middle of grapheme + + await act(async () => { + ed.focus() + setDomSelection(ed, start, end) + await flush() + }) + + await act(async () => { + fireDelete(ed) + await flush() + }) + + expect(ed.textContent).toBe('xy') + const sel = window.getSelection()! + const caret = getAbsoluteOffset(ed, sel.focusNode!, sel.focusOffset) + expect(caret).toBe(1) + }) +}) + +// ZWJ grapheme sequences and selection snapping +describe('Inlay grapheme advanced cases', () => { + it('Backspace/Delete remove whole ZWJ grapheme (family emoji)', async () => { + const family = '👨👩👧👦' + function Test() { + const [value, setValue] = React.useState(family) + return ( + + + + ) + } + const { getByTestId, rerender } = render() + const ed = getByTestId('ed') as HTMLElement + + await act(async () => { + ed.focus() + setDomSelection(ed, family.length) + await flush() + }) + await act(async () => { + fireBackspace(ed) + await flush() + }) + expect(ed.textContent).toBe('\u200B') + + // Reset and test Delete + rerender( + {}} data-testid="ed"> + + + ) + await act(async () => { + ed.focus() + setDomSelection(ed, 0) + await flush() + }) + await act(async () => { + fireDelete(ed) + await flush() + }) + expect(ed.textContent).toBe('\u200B') + }) + + it('setSelection snaps to grapheme boundaries', async () => { + const cluster = '👍🏼' + const text = `a${cluster}b` + const ref = React.createRef() + function Test() { + const [value, setValue] = React.useState(text) + return ( + + + {({ selection, getSelectionRange }) => { + const range = getSelectionRange() + let rawStart = -1 + let rawEnd = -1 + const root = ref.current?.root + if (root && range) { + rawStart = getAbsoluteOffset( + root, + range.startContainer, + range.startOffset + ) + rawEnd = getAbsoluteOffset( + root, + range.endContainer, + range.endOffset + ) + } + return ( + + ) + }} + + + + ) + } + const { getByTestId } = render() + + const mid = 1 + Math.floor(cluster.length / 2) + await act(async () => { + ref.current!.setSelection(mid) + await flush() + }) + + const ctx = getByTestId('ctx') + // Expect snapped to start of grapheme (offset 1) + expect(ctx.getAttribute('data-start')).toBe('1') + expect(ctx.getAttribute('data-end')).toBe('1') + expect(ctx.getAttribute('data-raw-start')).toBe('1') + expect(ctx.getAttribute('data-raw-end')).toBe('1') + }) +}) + +// Placeholder +describe('Inlay placeholder', () => { + it('shows placeholder only when value is empty', () => { + const placeholder = 'Type here...' + const { container, rerender } = render( + {}} placeholder={placeholder}> + + + ) + + expect(container.textContent).toContain(placeholder) + + rerender( + {}} placeholder={placeholder}> + + + ) + + expect(container.textContent).not.toContain(placeholder) + }) +}) + +// Multiline behavior +describe('Inlay multiline prop', () => { + it('multiline=false blocks Enter and Shift+Enter on empty editor', async () => { + function Test() { + const [value, setValue] = React.useState('') + return ( + + + + ) + } + + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + await act(async () => { + ed.focus() + fireEvent.keyDown(ed, { key: 'Enter' }) + await flush() + }) + expect(ed.querySelector('br')).toBeFalsy() + expect(ed.textContent).toBe('\u200B') + + await act(async () => { + fireEvent.keyDown(ed, { key: 'Enter', shiftKey: true }) + await flush() + }) + expect(ed.querySelector('br')).toBeFalsy() + expect(ed.textContent).toBe('\u200B') + }) + + it('multiline=false does not render trailing even if value ends with \n', async () => { + function Test() { + const [value, setValue] = React.useState('A\n') + return ( + + + + ) + } + + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + expect(ed.querySelector('br')).toBeFalsy() + }) +}) + +// (IME composition tests removed; to be covered in Playwright later) diff --git a/src/inlay/__tests__/inlay-selection-and-active-token.test.tsx b/src/inlay/__tests__/inlay-selection-and-active-token.test.tsx new file mode 100644 index 0000000..fd100a9 --- /dev/null +++ b/src/inlay/__tests__/inlay-selection-and-active-token.test.tsx @@ -0,0 +1,202 @@ +import React from 'react' +import { describe, it, expect } from 'vitest' +import { render, act, waitFor } from '@testing-library/react' +import * as Inlay from '../inlay' +import { getAbsoluteOffset } from '../internal/dom-utils' + +function flush() { + return new Promise((r) => setTimeout(r, 0)) +} + +describe('Inlay public selection API', () => { + it('setSelection updates public context and getSelectionRange round-trips to same raw offsets', async () => { + const ref = React.createRef() + + const Test = () => ( + {}} + data-testid="ed" + > + + {({ selection, getSelectionRange }) => { + const range = getSelectionRange() + let rawStart = -1 + let rawEnd = -1 + const root = ref.current?.root + if (root && range) { + rawStart = getAbsoluteOffset( + root, + range.startContainer, + range.startOffset + ) + rawEnd = getAbsoluteOffset( + root, + range.endContainer, + range.endOffset + ) + } + return ( + + ) + }} + + + ) + + const { getByTestId } = render() + + await act(async () => { + ref.current!.setSelection(1, 3) + await flush() + }) + + await waitFor(() => { + const ctx = getByTestId('ctx') + expect(ctx.getAttribute('data-start')).toBe('1') + expect(ctx.getAttribute('data-end')).toBe('3') + expect(ctx.getAttribute('data-range')).toBe('1') + expect(ctx.getAttribute('data-raw-start')).toBe('1') + expect(ctx.getAttribute('data-raw-end')).toBe('3') + }) + }) +}) + +describe('Inlay active token and state', () => { + it('middle/end for non-diverged token; start boundary is spacer', async () => { + const ref = React.createRef() + const { getByTestId } = render( + {}} data-testid="ed"> + + @x + + + {({ activeToken, activeTokenState }) => ( + + )} + + + ) + + const ed = getByTestId('ed') as HTMLElement + + await waitFor(() => { + expect(ed.querySelector('[data-token-text]')).toBeTruthy() + }) + + // Start boundary selects spacer [0,1] + await act(async () => { + ref.current!.setSelection(1) + await flush() + }) + await waitFor(() => { + const ctx = document.querySelector('[data-testid="ctx"]') as HTMLElement + expect(ctx.getAttribute('data-start')).toBe('0') + expect(ctx.getAttribute('data-end')).toBe('1') + expect(ctx.getAttribute('data-collapsed')).toBe('1') + expect(ctx.getAttribute('data-atstart')).toBe('0') + expect(ctx.getAttribute('data-atend')).toBe('1') + }) + + // Middle of token (offset 2) + await act(async () => { + ref.current!.setSelection(2) + await flush() + }) + await waitFor(() => { + const ctx = document.querySelector('[data-testid="ctx"]') as HTMLElement + expect(ctx.getAttribute('data-start')).toBe('1') + expect(ctx.getAttribute('data-end')).toBe('3') + expect(ctx.getAttribute('data-collapsed')).toBe('1') + expect(ctx.getAttribute('data-atstart')).toBe('0') + expect(ctx.getAttribute('data-atend')).toBe('0') + }) + + // End of token (offset 3) + await act(async () => { + ref.current!.setSelection(3) + await flush() + }) + await waitFor(() => { + const ctx = document.querySelector('[data-testid="ctx"]') as HTMLElement + expect(ctx.getAttribute('data-start')).toBe('1') + expect(ctx.getAttribute('data-end')).toBe('3') + expect(ctx.getAttribute('data-collapsed')).toBe('1') + expect(ctx.getAttribute('data-atstart')).toBe('0') + expect(ctx.getAttribute('data-atend')).toBe('1') + }) + }) + + it('diverged: start boundary is spacer; end-of-token reports atEnd', async () => { + const ref = React.createRef() + const { getByTestId } = render( + {}} + data-testid="ed" + > + + Alex + + + {({ activeToken, activeTokenState }) => ( + + )} + + + ) + + const ed = getByTestId('ed') as HTMLElement + + await waitFor(() => { + expect(ed.querySelector('[data-token-text]')).toBeTruthy() + }) + + // Start boundary is spacer [0,1] + await act(async () => { + ref.current!.setSelection(1) + await flush() + }) + await waitFor(() => { + const ctx = document.querySelector('[data-testid="ctx"]') as HTMLElement + expect(ctx.getAttribute('data-start')).toBe('0') + expect(ctx.getAttribute('data-end')).toBe('1') + expect(ctx.getAttribute('data-atstart')).toBe('0') + expect(ctx.getAttribute('data-atend')).toBe('1') + }) + + // End of token (offset 6) + await act(async () => { + ref.current!.setSelection(6) + await flush() + }) + await waitFor(() => { + const ctx = document.querySelector('[data-testid="ctx"]') as HTMLElement + expect(ctx.getAttribute('data-atstart')).toBe('0') + expect(ctx.getAttribute('data-atend')).toBe('1') + }) + }) +}) diff --git a/src/inlay/__tests__/inlay-tokens-and-registry.test.tsx b/src/inlay/__tests__/inlay-tokens-and-registry.test.tsx new file mode 100644 index 0000000..79b7164 --- /dev/null +++ b/src/inlay/__tests__/inlay-tokens-and-registry.test.tsx @@ -0,0 +1,264 @@ +import React from 'react' +import { describe, it, expect } from 'vitest' +import { render, waitFor, act } from '@testing-library/react' +import * as Inlay from '../inlay' + +function flush() { + return new Promise((r) => setTimeout(r, 0)) +} + +// Ancestor registration +describe('Inlay ancestor registration', () => { + it('wraps a token with its ancestor element in the visible weaved output', async () => { + const { getByTestId } = render( + {}} data-testid="ed"> + + + X + + + + ) + + const editor = getByTestId('ed') as HTMLElement + + await waitFor(() => { + const token = editor.querySelector('[data-token-text="X"]') + expect(token).toBeTruthy() + }) + + const wrapped = editor.querySelector( + '[data-anc="outer"] [data-token-text="X"]' + ) + expect(wrapped).toBeTruthy() + }) + + it('uses the top-most ancestor, not inner ones, as the captured node (outer wrapper present)', async () => { + const { getByTestId } = render( + {}} data-testid="ed"> + + + + X + + + + + ) + + const editor = getByTestId('ed') as HTMLElement + + await waitFor(() => { + const token = editor.querySelector('[data-token-text="X"]') + expect(token).toBeTruthy() + }) + + const outerWrapped = editor.querySelector( + '[data-anc="outer"] [data-token-text="X"]' + ) + expect(outerWrapped).toBeTruthy() + }) + + it('when no ancestor wrapper is present, token renders directly without ancestor', async () => { + const { getByTestId } = render( + {}} data-testid="ed"> + + X + + + ) + + const editor = getByTestId('ed') as HTMLElement + + await waitFor(() => { + const token = editor.querySelector('[data-token-text="X"]') + expect(token).toBeTruthy() + }) + + expect(editor.querySelector('[data-anc]')).toBeFalsy() + }) +}) + +// Adjacent tokens +describe('Inlay adjacent tokens (weaving)', () => { + it('renders back-to-back tokens without a spacer', () => { + const { getByTestId } = render( + {}} data-testid="ed"> + + @x + + + @x + + + ) + + const editor = getByTestId('ed') as HTMLElement + const tokens = editor.querySelectorAll('[data-token-text="@x"]') + expect(tokens.length).toBe(2) + }) +}) + +// Registry staleness +describe('Inlay token registry staleness', () => { + it('when value changes and token child is removed, no stale token renders; plain text is shown', async () => { + const Test = ({ + value, + withToken + }: { + value: string + withToken: boolean + }) => ( + {}} data-testid="ed"> + {withToken ? ( + + X + + ) : ( + + )} + + ) + + const { getByTestId, rerender } = render( + + ) + const ed = getByTestId('ed') as HTMLElement + + await waitFor(() => { + expect(ed.querySelector('[data-token-text="X"]')).toBeTruthy() + }) + + rerender() + + await waitFor(() => { + expect(ed.querySelector('[data-token-text]')).toBeFalsy() + expect(ed.textContent).toBe('Y') + }) + }) + + it('when value changes but a stale token child remains, it should not appear in the visible editor', async () => { + const { getByTestId, rerender } = render( + {}} data-testid="ed"> + + X + + + ) + const ed = getByTestId('ed') as HTMLElement + + await waitFor(() => { + expect(ed.querySelector('[data-token-text="X"]')).toBeTruthy() + }) + + rerender( + {}} data-testid="ed"> + + X + + + ) + + await waitFor(() => { + expect(ed.querySelector('[data-token-text]')).toBeFalsy() + }) + }) +}) + +// External controlled updates +describe('Inlay external controlled updates', () => { + it('re-weaves tokens when parent changes value (appears/disappears)', async () => { + function Test() { + const [value, setValue] = React.useState('A') + ;(window as any).__setVal = setValue + return ( + + + @x + + + ) + } + + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + await waitFor(() => { + expect(ed.querySelector('[data-token-text]')).toBeFalsy() + }) + + await act(async () => { + ;(window as any).__setVal('@x') + await flush() + }) + + await waitFor(() => { + expect(ed.querySelector('[data-token-text="@x"]')).toBeTruthy() + }) + + await act(async () => { + ;(window as any).__setVal('B') + await flush() + }) + + await waitFor(() => { + expect(ed.querySelector('[data-token-text]')).toBeFalsy() + }) + }) + + it('context remains usable after external value change (can set selection and get active token)', async () => { + const ref = React.createRef() + function Test() { + const [value, setValue] = React.useState('@x') + ;(window as any).__setVal2 = setValue + return ( + + + @x + + + {({ activeToken }) => ( + + )} + + + ) + } + + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + await waitFor(() => { + expect(ed.querySelector('[data-token-text]')).toBeTruthy() + }) + + await act(async () => { + ;(window as any).__setVal2('A@xB') + await flush() + }) + + await waitFor(() => { + expect(ed.querySelector('[data-token-text]')).toBeTruthy() + }) + + await act(async () => { + ref.current!.setSelection(2) + await flush() + }) + + await waitFor(() => { + const ctx = getByTestId('ctx') + expect(ctx.getAttribute('data-start')).toBe('1') + expect(ctx.getAttribute('data-end')).toBe('3') + }) + }) +}) diff --git a/src/inlay/hooks/use-clipboard.ts b/src/inlay/hooks/use-clipboard.ts new file mode 100644 index 0000000..c2b70d4 --- /dev/null +++ b/src/inlay/hooks/use-clipboard.ts @@ -0,0 +1,182 @@ +import { useCallback, useRef } from 'react' +import { flushSync } from 'react-dom' +import { + getAbsoluteOffset, + setDomSelection, + getClosestTokenEl, + getTokenRawRange +} from '../internal/dom-utils' + +export type ClipboardConfig = { + editorRef: React.RefObject + getValue: () => string + setValue: React.Dispatch> + pushUndoSnapshot?: () => void + isComposingRef: React.MutableRefObject +} + +function getSelectionFromDom( + root: HTMLElement +): { start: number; end: number } | null { + const domSelection = window.getSelection() + if (!domSelection || !domSelection.rangeCount) return null + + const range = domSelection.getRangeAt(0) + if (!root.contains(range.startContainer)) return null + + let start = getAbsoluteOffset(root, range.startContainer, range.startOffset) + let end = getAbsoluteOffset(root, range.endContainer, range.endOffset) + + // Handle snapped offsets when DOM selection exists but offsets collapsed + if (start === end && !range.collapsed) { + const startToken = getClosestTokenEl(range.startContainer) + const endToken = getClosestTokenEl(range.endContainer) + + if (startToken && startToken === endToken) { + const tokenRange = getTokenRawRange(root, startToken) + if (tokenRange) return tokenRange + } else if (startToken) { + const tokenRange = getTokenRawRange(root, startToken) + if (tokenRange) { + start = tokenRange.start + end = tokenRange.end + } + } else if (endToken) { + const tokenRange = getTokenRawRange(root, endToken) + if (tokenRange) { + start = tokenRange.start + end = tokenRange.end + } + } + } + + // Expand partial token selections to full token boundaries + const startToken = getClosestTokenEl(range.startContainer) + if (startToken) { + const tokenRange = getTokenRawRange(root, startToken) + if (tokenRange && start > tokenRange.start && start < tokenRange.end) { + start = tokenRange.start + } + } + + const endToken = getClosestTokenEl(range.endContainer) + if (endToken) { + const tokenRange = getTokenRawRange(root, endToken) + if (tokenRange && end > tokenRange.start && end < tokenRange.end) { + end = tokenRange.end + } + } + + return { start, end } +} + +export function useClipboard(cfg: ClipboardConfig) { + // Track pending selection for rapid operations where DOM hasn't caught up yet + const pendingSelectionRef = useRef<{ start: number; end: number } | null>( + null + ) + const pendingClearTimeoutRef = useRef(null) + + const onCopy = useCallback( + (event: React.ClipboardEvent) => { + event.preventDefault() + + const root = cfg.editorRef.current + if (!root) return + + const sel = getSelectionFromDom(root) + if (!sel || sel.start === sel.end) return + + const rawText = cfg.getValue().slice(sel.start, sel.end) + event.clipboardData.setData('text/plain', rawText) + }, + [cfg] + ) + + const onCut = useCallback( + (event: React.ClipboardEvent) => { + event.preventDefault() + + const root = cfg.editorRef.current + if (!root) return + + const sel = getSelectionFromDom(root) + if (!sel || sel.start === sel.end) return + + const rawText = cfg.getValue().slice(sel.start, sel.end) + event.clipboardData.setData('text/plain', rawText) + + cfg.pushUndoSnapshot?.() + + cfg.setValue((currentValue) => { + const before = currentValue.slice(0, sel.start) + const after = currentValue.slice(sel.end) + + requestAnimationFrame(() => { + const root = cfg.editorRef.current + if (root?.isConnected) setDomSelection(root, sel.start) + }) + + return before + after + }) + }, + [cfg] + ) + + const onPaste = useCallback( + (event: React.ClipboardEvent) => { + if (cfg.isComposingRef.current) return + event.preventDefault() + + const pastedText = event.clipboardData.getData('text/plain') + if (!pastedText) return + + const root = cfg.editorRef.current + if (!root) return + + // Use pending selection if available (from rapid paste), otherwise read from DOM + const sel = pendingSelectionRef.current ?? getSelectionFromDom(root) + if (!sel) return + + cfg.pushUndoSnapshot?.() + + const len = cfg.getValue().length + const safeStart = Math.max(0, Math.min(sel.start, len)) + const newSelection = safeStart + pastedText.length + + // Update pending selection synchronously BEFORE state update + // so subsequent rapid pastes use the correct position + pendingSelectionRef.current = { start: newSelection, end: newSelection } + + // Use flushSync to ensure DOM is updated synchronously + flushSync(() => { + cfg.setValue((currentValue) => { + const len = currentValue.length + const safeStart = Math.max(0, Math.min(sel.start, len)) + const safeEnd = Math.max(0, Math.min(sel.end, len)) + const before = currentValue.slice(0, safeStart) + const after = currentValue.slice(safeEnd) + return before + pastedText + after + }) + }) + + // Set selection immediately after flushSync (DOM is now updated) + if (root.isConnected) { + setDomSelection(root, newSelection) + } + + // Clear pending selection after a short delay, giving time for + // rapid paste events to complete before falling back to DOM + if (pendingClearTimeoutRef.current !== null) { + clearTimeout(pendingClearTimeoutRef.current) + } + pendingClearTimeoutRef.current = window.setTimeout(() => { + pendingClearTimeoutRef.current = null + pendingSelectionRef.current = null + }, 100) + }, + [cfg] + ) + + return { onCopy, onCut, onPaste } +} diff --git a/src/inlay/hooks/use-composition.ts b/src/inlay/hooks/use-composition.ts new file mode 100644 index 0000000..309f6f1 --- /dev/null +++ b/src/inlay/hooks/use-composition.ts @@ -0,0 +1,164 @@ +import { useCallback, useRef, useState } from 'react' +import { getAbsoluteOffset, setDomSelection } from '../internal/dom-utils' + +// Platform detection for iOS-specific handling +const isIOS = + typeof navigator !== 'undefined' && + (/iPad|iPhone|iPod/.test(navigator.userAgent) || + (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)) + +export function useComposition( + editorRef: React.RefObject, + serializeRawFromDom: () => string, + handleSelectionChange: () => void, + setValue: (updater: (prev: string) => string) => void, + getCurrentValue: () => string +) { + const [isComposing, setIsComposing] = useState(false) + const [contentKey, setContentKey] = useState(0) + const isComposingRef = useRef(false) + const compositionStartSelectionRef = useRef<{ + start: number + end: number + } | null>(null) + const compositionInitialValueRef = useRef(null) + const suppressNextBeforeInputRef = useRef(false) + const suppressNextKeydownCommitRef = useRef(null) + const compositionCommitKeyRef = useRef<'enter' | 'space' | null>(null) + const compositionJustEndedAtRef = useRef(0) + // Track last composition data for iOS workaround + const lastCompositionDataRef = useRef('') + + const onCompositionStart = useCallback( + (event: React.CompositionEvent) => { + console.log('[compositionstart]', { + data: event.data + }) + if (!editorRef.current) return + isComposingRef.current = true + setIsComposing(true) + const sel = window.getSelection() + if (sel && sel.rangeCount > 0) { + const r = sel.getRangeAt(0) + const start = getAbsoluteOffset( + editorRef.current, + r.startContainer, + r.startOffset + ) + const end = getAbsoluteOffset( + editorRef.current, + r.endContainer, + r.endOffset + ) + compositionStartSelectionRef.current = { start, end } + } + compositionInitialValueRef.current = getCurrentValue() + }, + [editorRef, getCurrentValue] + ) + + const onCompositionUpdate = useCallback( + (event: React.CompositionEvent) => { + // iOS Safari sometimes doesn't include data in compositionend, + // so we track the last composition data during updates + const data = (event as unknown as { data?: string }).data + if (data) { + lastCompositionDataRef.current = data + } + }, + [] + ) + + const onCompositionEnd = useCallback( + (event: React.CompositionEvent) => { + const eventData = (event as unknown as { data?: string }).data + console.log('[compositionend]', { + data: eventData, + lastCompositionData: lastCompositionDataRef.current + }) + + const root = editorRef.current + if (!root) { + isComposingRef.current = false + setIsComposing(false) + return + } + suppressNextBeforeInputRef.current = true + console.log('[compositionend] setting suppressNextBeforeInput = true') + + // Build committed value + let committed = (event as unknown as { data?: string }).data || '' + + // iOS Safari workaround: compositionend may not include data, + // fall back to last tracked composition data from updates + if (!committed && isIOS && lastCompositionDataRef.current) { + committed = lastCompositionDataRef.current + } + + const baseValue = compositionInitialValueRef.current ?? getCurrentValue() + const range = compositionStartSelectionRef.current ?? { start: 0, end: 0 } + const len = baseValue.length + const safeStart = Math.max(0, Math.min(range.start, len)) + const safeEnd = Math.max(0, Math.min(range.end, len)) + const before = baseValue.slice(0, safeStart) + const after = baseValue.slice(safeEnd) + + if (!committed) { + const domText = serializeRawFromDom() + const replacedLen = safeEnd - safeStart + const insertedLen = Math.max( + 0, + domText.length - (baseValue.length - replacedLen) + ) + if (insertedLen > 0 && safeStart + insertedLen <= domText.length) { + committed = domText.slice(safeStart, safeStart + insertedLen) + } + } + + setValue(() => before + committed + after) + + // Force a remount to purge any transient IME DOM artifacts left behind + setContentKey((k) => k + 1) + + // One-shot suppress the immediate commit keydown regardless of engine + // Prefer the key recorded during composition; default to 'enter' + suppressNextKeydownCommitRef.current = + compositionCommitKeyRef.current ?? 'enter' + compositionJustEndedAtRef.current = Date.now() + + requestAnimationFrame(() => { + const r = editorRef.current + if (!r) return + setDomSelection(r, safeStart + committed.length) + handleSelectionChange() + }) + + isComposingRef.current = false + setIsComposing(false) + compositionInitialValueRef.current = null + compositionStartSelectionRef.current = null + compositionCommitKeyRef.current = null + lastCompositionDataRef.current = '' + }, + [ + editorRef, + getCurrentValue, + handleSelectionChange, + serializeRawFromDom, + setValue + ] + ) + + return { + isComposing, + isComposingRef, + suppressNextBeforeInputRef, + suppressNextKeydownCommitRef, + compositionCommitKeyRef, + compositionJustEndedAtRef, + contentKey, + onCompositionStart, + onCompositionUpdate, + onCompositionEnd + } +} diff --git a/src/inlay/hooks/use-history.ts b/src/inlay/hooks/use-history.ts new file mode 100644 index 0000000..74961bb --- /dev/null +++ b/src/inlay/hooks/use-history.ts @@ -0,0 +1,98 @@ +import { useCallback, useRef } from 'react' + +export type Snapshot = { + value: string + selection: { start: number; end: number } +} + +export function useHistory( + getCurrentSnapshot: () => Snapshot, + applySnapshot: (snap: Snapshot) => void, + maxHistory: number = 200 +) { + const undoStackRef = useRef([]) + const redoStackRef = useRef([]) + + const pushUndoSnapshot = useCallback(() => { + const snap = getCurrentSnapshot() + const stack = undoStackRef.current + if (stack.length >= maxHistory) stack.shift() + stack.push(snap) + // New edits invalidate redo history + redoStackRef.current = [] + }, [getCurrentSnapshot, maxHistory]) + + const editSessionRef = useRef<{ + type: 'insert' | 'delete' | null + timer: number | null + }>({ + type: null, + timer: null + }) + + const endEditSession = useCallback(() => { + const s = editSessionRef.current + if (s.timer != null) { + clearTimeout(s.timer) + } + editSessionRef.current = { type: null, timer: null } + }, []) + + const beginEditSession = useCallback( + (type: 'insert' | 'delete') => { + const s = editSessionRef.current + if (s.type !== type) { + // Different kind resets session + endEditSession() + } + if (editSessionRef.current.type === null) { + // Start of a new coalesced chunk: push snapshot + pushUndoSnapshot() + } + // Refresh session + const timer = window.setTimeout(() => { + endEditSession() + }, 800) + editSessionRef.current = { type, timer } + }, + [endEditSession, pushUndoSnapshot] + ) + + const undo = useCallback(() => { + const stack = undoStackRef.current + if (stack.length > 0) { + const current = getCurrentSnapshot() + const last = stack.pop()! + const redoStack = redoStackRef.current + if (redoStack.length >= maxHistory) redoStack.shift() + redoStack.push(current) + applySnapshot(last) + return true + } + return false + }, [applySnapshot, getCurrentSnapshot, maxHistory]) + + const redo = useCallback(() => { + const redoStack = redoStackRef.current + if (redoStack.length > 0) { + const current = getCurrentSnapshot() + const next = redoStack.pop()! + const undoStack = undoStackRef.current + if (undoStack.length >= maxHistory) undoStack.shift() + undoStack.push(current) + applySnapshot(next) + return true + } + return false + }, [applySnapshot, getCurrentSnapshot, maxHistory]) + + return { + undoStackRef, + redoStackRef, + pushUndoSnapshot, + beginEditSession, + endEditSession, + undo, + redo + } +} diff --git a/src/inlay/hooks/use-key-handlers.ts b/src/inlay/hooks/use-key-handlers.ts new file mode 100644 index 0000000..8b489dc --- /dev/null +++ b/src/inlay/hooks/use-key-handlers.ts @@ -0,0 +1,1013 @@ +import React, { useCallback, useEffect, useRef } from 'react' +import { + getAbsoluteOffset, + getClosestTokenEl, + setDomSelection, + serializeRawFromDom +} from '../internal/dom-utils' +import { + nextGraphemeEnd, + prevGraphemeStart, + snapGraphemeEnd, + snapGraphemeStart +} from '../internal/string-utils' + +const isJsdom = + typeof navigator !== 'undefined' && /jsdom/i.test(navigator.userAgent || '') + +// Platform detection for iOS-specific handling +const isIOS = + typeof navigator !== 'undefined' && + (/iPad|iPhone|iPod/.test(navigator.userAgent) || + (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)) + +function scheduleSelection(cb: () => void) { + if (isJsdom) { + setTimeout(cb, 0) + } else if (typeof requestAnimationFrame === 'function') { + requestAnimationFrame(cb) + } else { + setTimeout(cb, 0) + } +} + +// Timeout for pending selection validity (ms) +const PENDING_SELECTION_TIMEOUT = 100 + +// Timeout for iOS swipe-text word deletion detection (ms) +// When backspace is pressed at the end of a recent multi-char insert, delete the whole chunk. +// iOS has no timeout - we use a generous value to avoid false negatives while still +// clearing stale tracking eventually. The main protection is position matching. +const SWIPE_TEXT_DELETE_TIMEOUT = 30000 // 30 seconds + +export type KeyHandlersConfig = { + editorRef: React.RefObject + contentKey: number // Changes when editor element is recreated (e.g., after IME composition) + multiline: boolean + onKeyDownProp?: (event: React.KeyboardEvent) => boolean | void + beginEditSession: (type: 'insert' | 'delete') => void + endEditSession: () => void + pushUndoSnapshot: () => void + undo: () => boolean + redo: () => boolean + isComposingRef: React.MutableRefObject + compositionCommitKeyRef: React.MutableRefObject<'enter' | 'space' | null> + suppressNextBeforeInputRef: React.MutableRefObject + suppressNextKeydownCommitRef: React.MutableRefObject + compositionJustEndedAtRef: React.MutableRefObject + setValue: React.Dispatch> + valueRef: React.MutableRefObject // Current value for sync checks + getActiveToken: () => { start: number; end: number } | null +} + +function selectionIntersectsToken(editor: HTMLElement): boolean { + const sel = window.getSelection() + if (!sel || sel.rangeCount === 0) return false + const rng = sel.getRangeAt(0) + const tokens = editor.querySelectorAll('[data-token-text]') + for (let i = 0; i < tokens.length; i++) { + const el = tokens[i] + const rangeIntersects = rng.intersectsNode + if (typeof rangeIntersects === 'function') { + if (rangeIntersects.call(rng, el)) return true + } else { + const tr = document.createRange() + tr.selectNode(el) + const overlap = + rng.compareBoundaryPoints(Range.END_TO_START, tr) === 1 && + rng.compareBoundaryPoints(Range.START_TO_END, tr) === -1 + if (overlap) return true + } + } + return false +} + +export function useKeyHandlers(cfg: KeyHandlersConfig) { + // Track pending selection to avoid stale window.getSelection() during rapid input + const pendingSelectionRef = useRef<{ pos: number; time: number } | null>(null) + + // Track last multi-char insert for iOS swipe-text word deletion detection + // When user swipe-types a word and presses backspace, iOS only sends one + // deleteContentBackward event for the last char, but expects the whole word deleted + const lastMultiCharInsertRef = useRef<{ + start: number + end: number + data: string // Track the actual inserted text for space preservation + time: number + } | null>(null) + + // Track when we actually prevented a beforeinput event (for MutationObserver to know) + const lastPreventedTimeRef = useRef(0) + + // Handle beforeinput via native event listener (React's synthetic event is unreliable) + const handleBeforeInput = useCallback( + (event: InputEvent) => { + const { editorRef } = cfg + if (!editorRef.current) return + + // Helper to mark that we're handling this event (for input handler to know) + const preventAndMark = () => { + event.preventDefault() + lastPreventedTimeRef.current = Date.now() + } + + const data: string | null | undefined = event.data + const inputType: string | undefined = event.inputType + + if (cfg.suppressNextBeforeInputRef.current) { + cfg.suppressNextBeforeInputRef.current = false + preventAndMark() + return + } + + // Immediately after compositionend, WebKit may fire an extra beforeinput inserting a newline. + // Block it within a short window regardless of current composition state. + if ( + cfg.compositionJustEndedAtRef.current && + Date.now() - cfg.compositionJustEndedAtRef.current < 50 && + (inputType === 'insertParagraph' || inputType === 'insertLineBreak') + ) { + preventAndMark() + return + } + + if (cfg.isComposingRef.current) { + if ( + inputType === 'insertParagraph' || + inputType === 'insertLineBreak' + ) { + preventAndMark() + } + return + } + + // Handle text insertions (insertText and insertReplacementText) + if (inputType === 'insertText' || inputType === 'insertReplacementText') { + // iOS handling: Let iOS modify DOM directly (prevents multi-word suggestions) + // EXCEPT when: has newlines (React crash) or in token context (stale DOM attributes) + const hasNewlines = cfg.valueRef.current.includes('\n') + const domSelection = window.getSelection() + const range = domSelection?.rangeCount + ? domSelection.getRangeAt(0) + : null + const isInTokenContext = range + ? !!getClosestTokenEl(range.startContainer) + : false + + const insertData = + inputType === 'insertReplacementText' + ? (data ?? event.dataTransfer?.getData('text/plain')) + : data + + if (isIOS && !hasNewlines && !isInTokenContext) { + return + } + + if (isIOS && hasNewlines && !insertData) { + // iOS with newlines but no data available - can't handle safely + // This shouldn't normally happen, but if it does, prevent and skip + preventAndMark() + return + } + + preventAndMark() + + // insertData already obtained above + if (!insertData) return + + // For insertReplacementText, use target ranges + let start: number + let end: number + + if (inputType === 'insertReplacementText') { + const targetRanges = event.getTargetRanges?.() + if (!targetRanges || targetRanges.length === 0) return + const targetRange = targetRanges[0] + start = getAbsoluteOffset( + editorRef.current, + targetRange.startContainer, + targetRange.startOffset + ) + end = getAbsoluteOffset( + editorRef.current, + targetRange.endContainer, + targetRange.endOffset + ) + } else { + // insertText: use current selection + const pending = pendingSelectionRef.current + if ( + pending && + Date.now() - pending.time < PENDING_SELECTION_TIMEOUT + ) { + start = pending.pos + end = pending.pos + } else { + const domSelection = window.getSelection() + if (!domSelection || !domSelection.rangeCount) return + const range = domSelection.getRangeAt(0) + start = getAbsoluteOffset( + editorRef.current, + range.startContainer, + range.startOffset + ) + end = getAbsoluteOffset( + editorRef.current, + range.endContainer, + range.endOffset + ) + } + } + + cfg.beginEditSession('insert') + cfg.setValue((currentValue) => { + const len = currentValue.length + const safeStart = Math.max(0, Math.min(start, len)) + const safeEnd = Math.max(0, Math.min(end, len)) + const before = currentValue.slice(0, safeStart) + const after = currentValue.slice(safeEnd) + + // Strip leading space when: + // 1. Inserting after a space (prevent double-space) + // 2. Inserting at start of line (after newline) - iOS swipe often adds leading space + // 3. Inserting at start of content (empty before) + // + // iOS sometimes sends the space as a SEPARATE event before the word, + // so we need to handle both single-space and space-prefixed insertions. + let finalInsertData = insertData + const shouldStripSpace = + insertData.startsWith(' ') && + (before.endsWith(' ') || + before.endsWith('\n') || + before.length === 0) + + if (shouldStripSpace) { + if (insertData === ' ') { + // Single space at start of line - skip entirely + return currentValue + } + // Multi-char with leading space - strip the space + finalInsertData = insertData.slice(1) + } + + const newValue = before + finalInsertData + after + const newSelection = safeStart + finalInsertData.length + + // Track as multi-char insert for swipe-text backspace detection + // ONLY track insertText (swipe-typing), NOT insertReplacementText (autocomplete) + // When user types "hel" and taps "hello" suggestion, backspace should delete char-by-char + const isSwipeText = inputType === 'insertText' + + if (isSwipeText && finalInsertData.length > 1) { + lastMultiCharInsertRef.current = { + start: safeStart, + end: newSelection, + data: finalInsertData, + time: Date.now() + } + } else if ( + isSwipeText && + finalInsertData === ' ' && + lastMultiCharInsertRef.current + ) { + // Extend tracking if adding trailing space to multi-char insert + const lastInsert = lastMultiCharInsertRef.current + if (safeStart === lastInsert.end) { + lastMultiCharInsertRef.current = { + start: lastInsert.start, + end: newSelection, + data: lastInsert.data + ' ', + time: lastInsert.time + } + } else { + lastMultiCharInsertRef.current = null + } + } else { + // Autocomplete (insertReplacementText) or single char - clear swipe tracking + lastMultiCharInsertRef.current = null + } + + pendingSelectionRef.current = { pos: newSelection, time: Date.now() } + + scheduleSelection(() => { + const root = editorRef.current + if (!root || !root.isConnected) return + setDomSelection(root, newSelection) + }) + return newValue + }) + return + } + + // Handle various delete input types (including mobile-specific ones) + if ( + inputType === 'deleteContentBackward' || + inputType === 'deleteContentForward' || + inputType === 'deleteWordBackward' || + inputType === 'deleteWordForward' || + inputType === 'deleteSoftLineBackward' || + inputType === 'deleteSoftLineForward' + ) { + preventAndMark() + + // iOS swipe-text fix: Use targetRanges when available AND the nodes are still connected. + // After rapid beforeinput events (e.g., iOS word deletion), React re-renders replace + // text nodes, making subsequent targetRanges point to orphaned nodes. + // In that case, use pending selection which tracks the expected cursor position. + const targetRanges = event.getTargetRanges?.() + + let start: number + let end: number + + // Check if targetRanges point to nodes still in the DOM + const targetRange = targetRanges?.[0] + const targetNodesConnected = + targetRange && + editorRef.current?.contains(targetRange.startContainer) && + editorRef.current?.contains(targetRange.endContainer) + + if (targetRange && targetNodesConnected) { + start = getAbsoluteOffset( + editorRef.current, + targetRange.startContainer, + targetRange.startOffset + ) + end = getAbsoluteOffset( + editorRef.current, + targetRange.endContainer, + targetRange.endOffset + ) + } else { + // Fallback: Use pending selection (for rapid deletes) or window.getSelection() + const pending = pendingSelectionRef.current + if ( + pending && + Date.now() - pending.time < PENDING_SELECTION_TIMEOUT + ) { + // Use pending selection for rapid iOS word deletion + start = pending.pos + end = pending.pos + } else { + const domSelection = window.getSelection() + if (!domSelection || !domSelection.rangeCount) return + const range = domSelection.getRangeAt(0) + start = getAbsoluteOffset( + editorRef.current, + range.startContainer, + range.startOffset + ) + end = getAbsoluteOffset( + editorRef.current, + range.endContainer, + range.endOffset + ) + } + } + + // iOS swipe-text word deletion detection: + // When user swipe-types a word and presses backspace, iOS sends ONE + // deleteContentBackward event with targetRange for just the last char. + // But the user expects the whole word to be deleted. + // Detect this by checking if we're deleting at the end of a recent multi-char insert. + const lastInsert = lastMultiCharInsertRef.current + const timeSinceInsert = lastInsert ? Date.now() - lastInsert.time : null + const matchesEnd = lastInsert ? end === lastInsert.end : false + const withinTimeout = + timeSinceInsert !== null && + timeSinceInsert < SWIPE_TEXT_DELETE_TIMEOUT + + if ( + inputType === 'deleteContentBackward' && + lastInsert && + withinTimeout && + matchesEnd + ) { + // This looks like iOS swipe-text deletion - delete the whole chunk + // But if the insert started with a space (iOS auto-inserts space before swipe words), + // preserve that space - only delete the word part + // EXCEPT: if the character before the insert is also a space (double-space scenario), + // delete the leading space too to avoid leaving a double space + const startsWithSpace = lastInsert.data.startsWith(' ') + + cfg.setValue((currentValue) => { + const charBefore = + lastInsert.start > 0 + ? currentValue.charAt(lastInsert.start - 1) + : '' + const prevIsSpace = charBefore === ' ' + + // If starts with space AND prev char is NOT a space, preserve the space + // Otherwise, delete from the start (including the leading space) + const preserveLeadingSpace = startsWithSpace && !prevIsSpace + const deleteStart = preserveLeadingSpace + ? lastInsert.start + 1 + : lastInsert.start + + // Actually perform the deletion here since we need currentValue + const before = currentValue.slice(0, deleteStart) + const after = currentValue.slice(end) + const newValue = before + after + + pendingSelectionRef.current = { + pos: deleteStart, + time: Date.now() + } + + scheduleSelection(() => { + const root = editorRef.current + if (!root || !root.isConnected) return + setDomSelection(root, deleteStart) + }) + + return newValue + }) + + lastMultiCharInsertRef.current = null // Clear tracking + return // Early return since we handled the deletion + } + + cfg.beginEditSession('delete') + cfg.setValue((currentValue) => { + const len = currentValue.length + const safeStart = Math.max(0, Math.min(start, len)) + const safeEnd = Math.max(0, Math.min(end, len)) + let newSelection = safeStart + let before = '' + let after = '' + + // For deleteContentBackward: + // - If there's a range selection (safeStart !== safeEnd), delete the entire selection + // - If it's a collapsed cursor, delete one grapheme backward + if (inputType === 'deleteContentBackward') { + // Handle range selection (delete entire selected range) + if (safeStart !== safeEnd) { + if (selectionIntersectsToken(editorRef.current!)) { + before = currentValue.slice(0, safeStart) + after = currentValue.slice(safeEnd) + newSelection = safeStart + } else { + const adjStart = snapGraphemeStart(currentValue, safeStart) + const adjEnd = snapGraphemeEnd(currentValue, safeEnd) + before = currentValue.slice(0, adjStart) + after = currentValue.slice(adjEnd) + newSelection = adjStart + } + } else { + // Collapsed cursor - delete one char/grapheme backward + const cursorPos = safeEnd + if (cursorPos === 0) return currentValue + const active = cfg.getActiveToken() + const insideToken = !!( + active && + cursorPos > active.start && + cursorPos <= active.end + ) + // Clear swipe-text tracking when near a token + if (active) lastMultiCharInsertRef.current = null + if (insideToken) { + const delStart = cursorPos - 1 + before = currentValue.slice(0, delStart) + after = currentValue.slice(cursorPos) + newSelection = delStart + } else { + const clusterStart = prevGraphemeStart(currentValue, cursorPos) + before = currentValue.slice(0, clusterStart) + after = currentValue.slice(cursorPos) + newSelection = clusterStart + } + } + } else if (inputType === 'deleteContentForward') { + const cursorPos = safeStart + if (cursorPos === len) return currentValue + const active = cfg.getActiveToken() + const insideToken = !!( + active && + cursorPos >= active.start && + cursorPos < active.end + ) + if (insideToken) { + const delEnd = cursorPos + 1 + before = currentValue.slice(0, cursorPos) + after = currentValue.slice(delEnd) + newSelection = cursorPos + } else { + const clusterEnd = nextGraphemeEnd(currentValue, cursorPos) + before = currentValue.slice(0, cursorPos) + after = currentValue.slice(clusterEnd) + newSelection = cursorPos + } + } else { + // Other delete types (word, line) - use the provided range + if (selectionIntersectsToken(editorRef.current!)) { + before = currentValue.slice(0, safeStart) + after = currentValue.slice(safeEnd) + newSelection = safeStart + } else { + const adjStart = snapGraphemeStart(currentValue, safeStart) + const adjEnd = snapGraphemeEnd(currentValue, safeEnd) + before = currentValue.slice(0, adjStart) + after = currentValue.slice(adjEnd) + newSelection = adjStart + } + } + // Store pending selection for rapid input handling + pendingSelectionRef.current = { pos: newSelection, time: Date.now() } + + scheduleSelection(() => { + const root = editorRef.current + if (!root || !root.isConnected) return + setDomSelection(root, newSelection) + }) + return before + after + }) + return + } + }, + [cfg] + ) + + // Attach native beforeinput listener (React's synthetic event is unreliable for beforeinput) + // Re-attach when contentKey changes (editor element is recreated after IME composition) + useEffect(() => { + const editor = cfg.editorRef.current + if (!editor) return + + const beforeInputListener = (e: Event) => handleBeforeInput(e as InputEvent) + editor.addEventListener('beforeinput', beforeInputListener) + + // iOS DOM → React state sync: + // When we don't preventDefault on iOS text insertions, iOS modifies the DOM directly. + // We use the input event to sync the DOM content back to React state. + // This uses serializeRawFromDom to correctly handle token elements. + const inputListener = (e: Event) => { + if (!isIOS) return // Only needed for iOS + + const ie = e as InputEvent + const inputType = ie.inputType + + // Handle text insertions (we didn't preventDefault, iOS modified DOM) + if ( + inputType === 'insertText' || + inputType === 'insertReplacementText' || + inputType === 'insertFromPaste' || + inputType === 'insertFromDrop' + ) { + // Skip if we just prevented a beforeinput event (handled it ourselves) + const now = Date.now() + if (now - lastPreventedTimeRef.current < 50) { + return + } + + // Get the raw DOM content (before stripping invisible chars) + const rawDomContent = editor.innerText || '' + + // Serialize DOM to get the correct text value (respecting token data-attributes) + // This strips zero-width spaces and other invisible characters + const newValue = serializeRawFromDom(editor) + + // Get current cursor position from DOM BEFORE React re-renders + const domSelection = window.getSelection() + let cursorPos = newValue.length + if (domSelection && domSelection.rangeCount > 0) { + const range = domSelection.getRangeAt(0) + const rawCursorPos = getAbsoluteOffset( + editor, + range.startContainer, + range.startOffset + ) + + // Adjust cursor position for any invisible characters that were stripped + // Count how many invisible chars exist before the cursor in raw content + const beforeCursor = rawDomContent.slice(0, rawCursorPos) + const invisibleCharsBeforeCursor = ( + beforeCursor.match(/[\u200B\uFEFF]/g) || [] + ).length + cursorPos = Math.max( + 0, + Math.min(rawCursorPos - invisibleCharsBeforeCursor, newValue.length) + ) + } + + // Track multi-char inserts for iOS swipe-text word deletion + // We need to distinguish between: + // - Swipe-typing: User swipes across keyboard to type a whole word at once + // → Backspace should delete the whole word + // - Autocomplete/suggestion: User types partial word, taps suggestion + // → Backspace should delete char-by-char + // + // iOS may send insertText for both! So we can't rely on inputType alone. + // Better heuristic: swipe-typing inserts AFTER a space or at start. + // Autocomplete typically replaces text mid-word. + const isSwipeText = inputType === 'insertText' + + // Check if cursor is in a token - skip swipe-text tracking if so + const range = domSelection?.rangeCount + ? domSelection.getRangeAt(0) + : null + const isInTokenContext = range + ? !!getClosestTokenEl(range.startContainer) + : false + + // We need to track if we added a space so we can adjust cursor position + let addedSpace = false + + cfg.setValue((oldValue) => { + let finalValue = newValue + let adjustedCursorPos = cursorPos + const insertedLength = newValue.length - oldValue.length + const insertStart = cursorPos - insertedLength + + // iOS often adds a leading space to swipe-typed words. + // Strip it when inserting after a newline or at the start of content. + if (insertedLength > 1 && insertStart >= 0) { + const charBeforeInsert = + insertStart > 0 ? oldValue.charAt(insertStart - 1) : '' + const insertedData = newValue.slice(insertStart, cursorPos) + + // Strip leading space if: + // 1. The inserted text starts with a space + // 2. AND we're at start of content OR after a newline OR after an existing space + if ( + insertedData.startsWith(' ') && + (insertStart === 0 || + charBeforeInsert === '\n' || + charBeforeInsert === ' ') + ) { + // Remove the leading space from the inserted text + finalValue = + oldValue.slice(0, insertStart) + + insertedData.slice(1) + + oldValue.slice(insertStart) + adjustedCursorPos = cursorPos - 1 + } + } + + // Recalculate for the adjusted value + const adjustedInsertedLength = finalValue.length - oldValue.length + const adjustedInsertStart = adjustedCursorPos - adjustedInsertedLength + + // Swipe-text vs autocomplete detection: + // - Swipe-typing: after space/start → track for whole-word deletion + // - Autocomplete: mid-word extension → char-by-char deletion + // - Token context: always char-by-char deletion + if (isInTokenContext) { + lastMultiCharInsertRef.current = null + } else if ( + isSwipeText && + adjustedInsertedLength > 1 && + adjustedInsertStart >= 0 + ) { + const lastCharOfOld = + oldValue.length > 0 ? oldValue.charAt(oldValue.length - 1) : '' + const endsWithWordChar = + lastCharOfOld && lastCharOfOld !== ' ' && lastCharOfOld !== '\n' + + // If oldValue ends mid-word, this is likely autocomplete + // (user typed "hel" and tapped "hello" to extend it) + if (endsWithWordChar) { + // Autocomplete - don't track for whole-word deletion + lastMultiCharInsertRef.current = null + } else { + // Swipe-typing (oldValue ends with space, newline, or is empty) + const charBeforeAdjustedInsert = + adjustedInsertStart > 0 + ? oldValue.charAt(adjustedInsertStart - 1) + : '' + const adjustedInsertedData = finalValue.slice( + adjustedInsertStart, + adjustedCursorPos + ) + + if ( + adjustedInsertStart > 0 && + charBeforeAdjustedInsert && + charBeforeAdjustedInsert !== ' ' && + charBeforeAdjustedInsert !== '\n' && + !adjustedInsertedData.startsWith(' ') + ) { + // Need to add a space before the inserted word + finalValue = + oldValue.slice(0, adjustedInsertStart) + + ' ' + + adjustedInsertedData + + oldValue.slice(adjustedInsertStart) + addedSpace = true + + // Track with the added space + lastMultiCharInsertRef.current = { + start: adjustedInsertStart, + end: adjustedCursorPos + 1, + data: ' ' + adjustedInsertedData, + time: Date.now() + } + } else { + // Normal swipe - track for whole-word deletion + lastMultiCharInsertRef.current = { + start: adjustedInsertStart, + end: adjustedCursorPos, + data: adjustedInsertedData, + time: Date.now() + } + } + } + } else if (isSwipeText && adjustedInsertedLength === 1) { + // Single char from swipe - check if it's a trailing space after a swipe + const insertedChar = finalValue.charAt(adjustedCursorPos - 1) + const lastInsert = lastMultiCharInsertRef.current + if ( + insertedChar === ' ' && + lastInsert && + adjustedInsertStart === lastInsert.end + ) { + // Extend the tracking to include the trailing space + lastMultiCharInsertRef.current = { + start: lastInsert.start, + end: adjustedCursorPos, + data: lastInsert.data + ' ', + time: lastInsert.time + } + } else { + // Regular single char, clear tracking + lastMultiCharInsertRef.current = null + } + } else if (!isSwipeText) { + // Autocomplete/suggestion (insertReplacementText) - clear swipe tracking + // User should be able to backspace char-by-char + lastMultiCharInsertRef.current = null + } + + // Update pending selection for rapid input handling + const finalCursorPos = addedSpace + ? adjustedCursorPos + 1 + : adjustedCursorPos + pendingSelectionRef.current = { + pos: finalCursorPos, + time: Date.now() + } + + return finalValue + }) + + // Restore caret position after React re-renders + // Use pendingSelectionRef since the actual position is calculated inside setValue + scheduleSelection(() => { + const root = cfg.editorRef.current + if (!root || !root.isConnected) return + const pending = pendingSelectionRef.current + const pos = pending ? pending.pos : cursorPos + setDomSelection(root, pos) + }) + } + + // After deletions, sync DOM → React to ensure they stay in sync + // This catches any cases where our deletion logic didn't perfectly match iOS's DOM state + if ( + inputType === 'deleteContentBackward' || + inputType === 'deleteContentForward' || + inputType === 'deleteWordBackward' || + inputType === 'deleteWordForward' || + inputType === 'deleteSoftLineBackward' || + inputType === 'deleteSoftLineForward' + ) { + // Small delay to let our beforeinput handler complete first + setTimeout(() => { + const root = cfg.editorRef.current + if (!root || !root.isConnected) return + + const domValue = serializeRawFromDom(root) + cfg.setValue((currentValue) => { + // Only sync if they're different (our handler might have already set it correctly) + if (currentValue !== domValue) { + return domValue + } + return currentValue + }) + }, 0) + } + } + editor.addEventListener('input', inputListener) + + return () => { + editor.removeEventListener('beforeinput', beforeInputListener) + editor.removeEventListener('input', inputListener) + } + }, [cfg.editorRef, cfg.contentKey, handleBeforeInput, cfg.setValue]) + + // Keep onBeforeInput as a no-op for backward compatibility (actual handling is via native listener) + const onBeforeInput = useCallback(() => { + // No-op: beforeinput is handled via native event listener + }, []) + + const onKeyDown = useCallback( + (event: React.KeyboardEvent) => { + // One-shot suppression for the immediate keydown fired after compositionend (WebKit bug) + if (cfg.suppressNextKeydownCommitRef.current) { + const sup = cfg.suppressNextKeydownCommitRef.current + const isEnter = event.key === 'Enter' || event.key === 'Return' + const isSpace = event.key === ' ' + if ((sup === 'enter' && isEnter) || (sup === 'space' && isSpace)) { + event.preventDefault() + event.stopPropagation() + cfg.suppressNextKeydownCommitRef.current = null + return + } + // Clear suppression if next key is different + cfg.suppressNextKeydownCommitRef.current = null + } + + if (cfg.onKeyDownProp?.(event)) return + + if (!cfg.multiline && event.key === 'Enter') { + event.preventDefault() + return + } + + if ( + event.key.startsWith('Arrow') || + event.metaKey || + event.ctrlKey || + event.altKey + ) { + if (!(event.key === 'Shift')) cfg.endEditSession() + } + + if ((event.metaKey || event.ctrlKey) && !event.altKey) { + const isUndo = event.key.toLowerCase() === 'z' && !event.shiftKey + const isRedo = + (event.key.toLowerCase() === 'z' && event.shiftKey) || + event.key.toLowerCase() === 'y' + if (isUndo) { + if (cfg.undo()) { + event.preventDefault() + return + } + } else if (isRedo) { + if (cfg.redo()) { + event.preventDefault() + return + } + } + } + + const { editorRef } = cfg + if (!editorRef.current) return + const domSelection = window.getSelection() + if (!domSelection || !domSelection.rangeCount) return + + const range = domSelection.getRangeAt(0) + const start = getAbsoluteOffset( + editorRef.current, + range.startContainer, + range.startOffset + ) + + if (cfg.isComposingRef.current) { + if (event.key === 'Enter' || event.key === 'Return') { + event.preventDefault() + event.stopPropagation() + cfg.compositionCommitKeyRef.current = 'enter' + return + } + if (event.key === ' ') { + event.preventDefault() + event.stopPropagation() + cfg.compositionCommitKeyRef.current = 'space' + return + } + return + } + + if (event.key === 'Enter') { + event.preventDefault() + const end = getAbsoluteOffset( + editorRef.current, + range.endContainer, + range.endOffset + ) + cfg.pushUndoSnapshot() + cfg.endEditSession() + cfg.setValue((currentValue) => { + const len = currentValue.length + const safeStart = Math.max(0, Math.min(start, len)) + const safeEnd = Math.max(0, Math.min(end, len)) + const before = currentValue.slice(0, safeStart) + const after = currentValue.slice(safeEnd) + const newValue = before + '\n' + after + const newSelection = safeStart + 1 + scheduleSelection(() => { + const root = editorRef.current + if (!root || !root.isConnected) return + setDomSelection(root, newSelection) + + // iOS fix: Reset autocomplete state after newline. + // iOS sometimes doesn't see as a word boundary and suggests + // merged words like "helloworld" instead of treating them separately. + // Toggle autocomplete attribute to reset iOS's autocomplete context + // without dismissing the keyboard (unlike blur/focus). + if (isIOS) { + const currentAutocomplete = root.getAttribute('autocomplete') + root.setAttribute('autocomplete', 'off') + requestAnimationFrame(() => { + if (root.isConnected) { + if (currentAutocomplete) { + root.setAttribute('autocomplete', currentAutocomplete) + } else { + root.removeAttribute('autocomplete') + } + } + }) + } + }) + return newValue + }) + } + + // On iOS, don't intercept space - let it flow to beforeinput/input + // so that iOS multi-word keyboard suggestions can work. + // On desktop, handle space directly for consistent behavior. + if (event.key === ' ' && !isIOS) { + event.preventDefault() + cfg.beginEditSession('insert') + cfg.setValue((currentValue) => { + const len = currentValue.length + const safeStart = Math.max(0, Math.min(start, len)) + const safeEnd = Math.max(0, Math.min(start, len)) + const before = currentValue.slice(0, safeStart) + const after = currentValue.slice(safeEnd) + const newValue = before + ' ' + after + const newSelection = safeStart + 1 + scheduleSelection(() => { + const root = editorRef.current + if (!root || !root.isConnected) return + setDomSelection(root, newSelection) + }) + return newValue + }) + } + + // Backspace is handled by beforeinput (deleteContentBackward) to support + // iOS swipe-text word deletion which fires multiple beforeinput events. + // Not calling preventDefault here allows beforeinput to fire. + + if (event.key === 'Delete') { + event.preventDefault() + cfg.beginEditSession('delete') + cfg.setValue((currentValue) => { + if (!currentValue) return '' + const len = currentValue.length + const safeStart = Math.max(0, Math.min(start, len)) + let newSelection = safeStart + let before: string + let after: string + if (range.collapsed) { + if (safeStart === len) return currentValue + const active = cfg.getActiveToken() + const insideToken = !!( + active && + safeStart >= active.start && + safeStart < active.end + ) + if (insideToken) { + const delEnd = safeStart + 1 + before = currentValue.slice(0, safeStart) + after = currentValue.slice(delEnd) + newSelection = safeStart + } else { + const clusterEnd = nextGraphemeEnd(currentValue, safeStart) + before = currentValue.slice(0, safeStart) + after = currentValue.slice(clusterEnd) + newSelection = safeStart + } + } else { + const end = getAbsoluteOffset( + editorRef.current!, + range.endContainer, + range.endOffset + ) + const safeEnd = Math.max(0, Math.min(end, len)) + if (selectionIntersectsToken(editorRef.current!)) { + before = currentValue.slice(0, safeStart) + after = currentValue.slice(safeEnd) + newSelection = safeStart + } else { + const adjStart = snapGraphemeStart(currentValue, safeStart) + const adjEnd = snapGraphemeEnd(currentValue, safeEnd) + before = currentValue.slice(0, adjStart) + after = currentValue.slice(adjEnd) + newSelection = adjStart + } + } + scheduleSelection(() => { + const root = editorRef.current + if (!root || !root.isConnected) return + setDomSelection(root, newSelection) + }) + return before + after + }) + } + }, + [cfg] + ) + + return { onBeforeInput, onKeyDown } +} diff --git a/src/inlay/hooks/use-placeholder-sync.ts b/src/inlay/hooks/use-placeholder-sync.ts new file mode 100644 index 0000000..480c5e2 --- /dev/null +++ b/src/inlay/hooks/use-placeholder-sync.ts @@ -0,0 +1,38 @@ +import { useLayoutEffect } from 'react' + +export function usePlaceholderSync( + editorRef: React.RefObject, + placeholderRef: React.RefObject, + deps: unknown[] +) { + useLayoutEffect(() => { + if (editorRef.current && placeholderRef.current) { + const editorStyles = window.getComputedStyle(editorRef.current) + const stylesToCopy: (keyof CSSStyleDeclaration)[] = [ + 'paddingTop', + 'paddingRight', + 'paddingBottom', + 'paddingLeft', + 'borderTopWidth', + 'borderRightWidth', + 'borderBottomWidth', + 'borderLeftWidth', + 'fontFamily', + 'fontSize', + 'lineHeight', + 'letterSpacing', + 'textAlign' + ] + + stylesToCopy.forEach((styleName) => { + const v = editorStyles[styleName] + if (v !== null) { + // @ts-expect-error - Style name is a valid CSSStyleDeclaration property + placeholderRef.current!.style[styleName] = v as string + } + }) + placeholderRef.current!.style.borderStyle = editorStyles.borderStyle + placeholderRef.current!.style.borderColor = 'transparent' + } + }, deps) +} diff --git a/src/inlay/hooks/use-selection-snap.ts b/src/inlay/hooks/use-selection-snap.ts new file mode 100644 index 0000000..5c5661d --- /dev/null +++ b/src/inlay/hooks/use-selection-snap.ts @@ -0,0 +1,175 @@ +import { useCallback } from 'react' +import { getAbsoluteOffset, setDomSelection } from '../internal/dom-utils' + +export type SelectionSnapConfig = { + editorRef: React.RefObject + setSelection: (sel: { start: number; end: number }) => void + lastAnchorRectRef: React.MutableRefObject + suppressNextSelectionAdjustRef: React.MutableRefObject + lastArrowDirectionRef: React.MutableRefObject< + 'left' | 'right' | 'up' | 'down' | null + > + lastShiftRef: React.MutableRefObject + isComposingRef: React.MutableRefObject +} + +export function useSelectionSnap(cfg: SelectionSnapConfig) { + const onSelect = useCallback(() => { + const root = cfg.editorRef.current + if (!root) return + + if (cfg.suppressNextSelectionAdjustRef.current) { + cfg.suppressNextSelectionAdjustRef.current = false + } + + const domSelection = window.getSelection() + if (!domSelection || !domSelection.rangeCount) return + + const range = domSelection.getRangeAt(0) + const clientRect = + range.getClientRects()[0] || range.getBoundingClientRect() + if (!(clientRect.x === 0 && clientRect.y === 0)) { + cfg.lastAnchorRectRef.current = new DOMRect( + clientRect.x, + clientRect.y, + clientRect.width, + clientRect.height + ) + } + + if (!root.contains(range.startContainer)) return + + const start = getAbsoluteOffset( + root, + range.startContainer, + range.startOffset + ) + const end = getAbsoluteOffset(root, range.endContainer, range.endOffset) + cfg.setSelection({ start, end }) + + if (cfg.isComposingRef.current) { + cfg.lastArrowDirectionRef.current = null + cfg.lastShiftRef.current = false + return + } + + const direction = cfg.lastArrowDirectionRef.current + const isShift = cfg.lastShiftRef.current + if (direction) { + requestAnimationFrame(() => { + const root = cfg.editorRef.current + if (!root) return + const sel = window.getSelection() + if (!sel || sel.rangeCount === 0) return + const rng = sel.getRangeAt(0) + + const getClosestTokenEl = (n: Node | null): HTMLElement | null => { + let curr: Node | null = n + while (curr) { + if (curr.nodeType === Node.ELEMENT_NODE) { + const asEl = curr as HTMLElement + if (asEl.hasAttribute('data-token-text')) return asEl + } + curr = curr.parentNode || null + } + return null + } + + const renderedLen = (el: Element): number => { + const walker = document.createTreeWalker( + el, + NodeFilter.SHOW_TEXT, + null + ) + let total = 0 + let n: Node | null + while ((n = walker.nextNode())) total += (n.textContent || '').length + return total + } + const rawLen = (el: Element): number => + (el.getAttribute('data-token-text') || '').length + + const findFirstTextNode = (el: Element): ChildNode | null => { + const walker = document.createTreeWalker( + el, + NodeFilter.SHOW_TEXT, + null + ) + return walker.nextNode() as ChildNode | null + } + const findLastTextNode = (el: Element): ChildNode | null => { + const walker = document.createTreeWalker( + el, + NodeFilter.SHOW_TEXT, + null + ) + let last: Node | null = null + let n: Node | null + while ((n = walker.nextNode())) last = n + return last as ChildNode | null + } + + const snapEdgeForToken = ( + tokenEl: Element, + prefer: 'start' | 'end' + ): number | null => { + if (!root) return null + if (prefer === 'start') { + const first = findFirstTextNode(tokenEl) + if (first) return getAbsoluteOffset(root, first, 0) + return null + } else { + const last = findLastTextNode(tokenEl) + if (last) { + const len = (last.textContent || '').length + return getAbsoluteOffset(root, last, len) + } + return null + } + } + + const arrowToEdge = (dir: typeof direction): 'start' | 'end' => + dir === 'left' || dir === 'up' ? 'start' : 'end' + + const tokenEl = getClosestTokenEl(rng.startContainer) + if (!tokenEl) { + cfg.lastArrowDirectionRef.current = null + cfg.lastShiftRef.current = false + return + } + + const isDiverged = renderedLen(tokenEl) !== rawLen(tokenEl) + if (!isDiverged) { + cfg.lastArrowDirectionRef.current = null + cfg.lastShiftRef.current = false + return + } + + if (!isShift) { + if (!rng.collapsed) return + const edge = arrowToEdge(direction) + const target = snapEdgeForToken(tokenEl, edge) + if (target == null) return + cfg.suppressNextSelectionAdjustRef.current = true + setDomSelection(root, target) + } else { + const anchorNode = sel.anchorNode + const anchorOffset = sel.anchorOffset + const edge = arrowToEdge(direction) + const focusRaw = snapEdgeForToken(tokenEl, edge) + if (focusRaw == null || !anchorNode) return + const anchorRaw = getAbsoluteOffset(root, anchorNode, anchorOffset) + const startRaw = Math.min(anchorRaw, focusRaw) + const endRaw = Math.max(anchorRaw, focusRaw) + cfg.suppressNextSelectionAdjustRef.current = true + setDomSelection(root, startRaw, endRaw) + } + + cfg.lastArrowDirectionRef.current = null + cfg.lastShiftRef.current = false + }) + } + }, [cfg]) + + return { onSelect } +} diff --git a/src/inlay/hooks/use-selection.ts b/src/inlay/hooks/use-selection.ts new file mode 100644 index 0000000..ff0690b --- /dev/null +++ b/src/inlay/hooks/use-selection.ts @@ -0,0 +1,175 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { getAbsoluteOffset, setDomSelection } from '../internal/dom-utils' +import { snapGraphemeEnd, snapGraphemeStart } from '../internal/string-utils' + +export type SelectionState = { start: number; end: number } + +// Detect iOS Safari - includes modern iPads that report as "MacIntel" with touch +const isIOS = + typeof navigator !== 'undefined' && + (/iPad|iPhone|iPod/.test(navigator.userAgent) || + (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)) + +export function useSelection( + editorRef: React.RefObject, + value: string +) { + const [selection, setSelection] = useState({ + start: 0, + end: 0 + }) + const lastAnchorRectRef = useRef(new DOMRect(0, 0, 0, 0)) + const virtualAnchorRef = useRef({ + getBoundingClientRect: () => lastAnchorRectRef.current + }) + const suppressNextSelectionAdjustRef = useRef(false) + + // Update just the anchor rect from current selection + const updateAnchorRect = useCallback(() => { + const domSelection = window.getSelection() + if (!domSelection || !domSelection.rangeCount) return + + const range = domSelection.getRangeAt(0) + const clientRect = + range.getClientRects()[0] || range.getBoundingClientRect() + if (!(clientRect.x === 0 && clientRect.y === 0)) { + lastAnchorRectRef.current = new DOMRect( + clientRect.x, + clientRect.y, + clientRect.width, + clientRect.height + ) + } + }, []) + + const handleSelectionChange = useCallback(() => { + if (!editorRef.current) return + + // Avoid feedback loop after we programmatically set selection + if (suppressNextSelectionAdjustRef.current) { + suppressNextSelectionAdjustRef.current = false + } + + const domSelection = window.getSelection() + if (!domSelection || !domSelection.rangeCount) return + + const range = domSelection.getRangeAt(0) + + // Update anchor rect + updateAnchorRect() + + if (!editorRef.current.contains(range.startContainer)) { + return + } + + const start = getAbsoluteOffset( + editorRef.current, + range.startContainer, + range.startOffset + ) + const end = getAbsoluteOffset( + editorRef.current, + range.endContainer, + range.endOffset + ) + setSelection({ start, end }) + }, [editorRef, updateAnchorRect]) + + const setSelectionImperative = useCallback( + (start: number, end?: number) => { + const root = editorRef.current + if (!root) return + const s = value + const rawStart = Math.max(0, Math.min(start, s.length)) + const rawEnd = + end != null ? Math.max(0, Math.min(end, s.length)) : rawStart + const a = Math.min(rawStart, rawEnd) + const b = Math.max(rawStart, rawEnd) + const snappedStart = snapGraphemeStart(s, a) + const snappedEnd = end != null ? snapGraphemeEnd(s, b) : snappedStart + suppressNextSelectionAdjustRef.current = true + setDomSelection(root, snappedStart, snappedEnd) + handleSelectionChange() + }, + [editorRef, handleSelectionChange, value] + ) + + // iOS Safari fires selectionchange on document, not the element. + // This listener ensures we capture selection changes on mobile Safari. + useEffect(() => { + const handleDocumentSelectionChange = () => { + const root = editorRef.current + if (!root) return + + // Only process if our editor is focused or contains the selection + const domSelection = window.getSelection() + if (!domSelection || !domSelection.rangeCount) return + + const range = domSelection.getRangeAt(0) + if (!root.contains(range.startContainer)) return + + // Delegate to our existing handler + handleSelectionChange() + } + + document.addEventListener('selectionchange', handleDocumentSelectionChange) + return () => { + document.removeEventListener( + 'selectionchange', + handleDocumentSelectionChange + ) + } + }, [editorRef, handleSelectionChange]) + + // iOS: Update anchor rect after input/viewport changes (caret rect can be stale) + useEffect(() => { + if (!isIOS) return + + let rafId: number | null = null + const deferredUpdate = () => { + if (rafId !== null) cancelAnimationFrame(rafId) + rafId = requestAnimationFrame(() => { + rafId = null + updateAnchorRect() + }) + } + + const handleInput = (e: Event) => { + const root = editorRef.current + if (root?.contains(e.target as Node)) deferredUpdate() + } + + const handleViewportChange = () => { + const root = editorRef.current + if ( + root && + (document.activeElement === root || + root.contains(document.activeElement)) + ) { + deferredUpdate() + } + } + + document.addEventListener('input', handleInput, true) + const vv = window.visualViewport + vv?.addEventListener('resize', handleViewportChange) + vv?.addEventListener('scroll', handleViewportChange) + + return () => { + if (rafId !== null) cancelAnimationFrame(rafId) + document.removeEventListener('input', handleInput, true) + vv?.removeEventListener('resize', handleViewportChange) + vv?.removeEventListener('scroll', handleViewportChange) + } + }, [editorRef, updateAnchorRect]) + + return { + selection, + setSelection, + setSelectionImperative, + handleSelectionChange, + lastAnchorRectRef, + virtualAnchorRef, + suppressNextSelectionAdjustRef + } +} diff --git a/src/inlay/hooks/use-token-weaver.tsx b/src/inlay/hooks/use-token-weaver.tsx new file mode 100644 index 0000000..cb9ad52 --- /dev/null +++ b/src/inlay/hooks/use-token-weaver.tsx @@ -0,0 +1,187 @@ +import React, { + useCallback, + useLayoutEffect, + useMemo, + useRef, + useState +} from 'react' + +export type TokenState = { + isCollapsed: boolean + isAtStartOfToken: boolean + isAtEndOfToken: boolean +} + +export type TokenInfo = { + text: string + node: React.ReactElement + start: number + end: number +} + +export function useTokenWeaver( + value: string, + selection: { start: number; end: number }, + multiline: boolean, + children: React.ReactNode +) { + const [isRegistered, setIsRegistered] = useState(false) + const tokenRegistry = useRef<{ text: string; node: React.ReactElement }[]>([]) + const activeTokenRef = useRef(null) + + if (!isRegistered) tokenRegistry.current = [] + + const registerToken = useCallback( + (token: { text: string; node: React.ReactElement }) => { + tokenRegistry.current.push(token) + }, + [] + ) + + useLayoutEffect(() => { + setIsRegistered(false) + }, [children]) + + useLayoutEffect(() => { + if (!isRegistered) setIsRegistered(true) + }, [isRegistered]) + + const { weavedChildren, activeToken, activeTokenState } = useMemo(() => { + if (!isRegistered) { + return { + weavedChildren: null as React.ReactNode, + activeToken: activeTokenRef.current, + activeTokenState: null as TokenState | null + } + } + + const isStale = tokenRegistry.current.some( + (token) => value.indexOf(token.text) === -1 + ) + if (isStale) { + return { + weavedChildren: null as React.ReactNode, + activeToken: activeTokenRef.current, + activeTokenState: null as TokenState | null + } + } + + // Empty state: render zero-width space to maintain consistent caret height + if (value.length === 0) { + return { + weavedChildren: {'\u200B'}, + activeToken: null, + activeTokenState: null as TokenState | null + } + } + + // Robust sorting to handle duplicate tokens + const sortedTokens: { text: string; node: React.ReactElement }[] = [] + const tokenPool = [...tokenRegistry.current] + let searchIndex = 0 + while (searchIndex < value.length && tokenPool.length > 0) { + let foundToken = false + for (let i = 0; i < tokenPool.length; i++) { + const token = tokenPool[i] + if (value.startsWith(token.text, searchIndex)) { + sortedTokens.push(token) + tokenPool.splice(i, 1) + searchIndex += token.text.length + foundToken = true + break + } + } + if (!foundToken) searchIndex++ + } + + const result: React.ReactNode[] = [] + const map: TokenInfo[] = [] + let currentIndex = 0 + if (sortedTokens.length === 0) { + const nodes: React.ReactNode[] = [{value}] + if (multiline && value.endsWith('\n')) + nodes.push() + const tokenMap = [ + { text: value, node: , start: 0, end: value.length } + ] + const active = + tokenMap.find( + (t) => selection.start >= t.start && selection.end <= t.end + ) || null + return { + weavedChildren: nodes, + activeToken: active, + activeTokenState: null as TokenState | null + } + } + + for (const token of sortedTokens) { + const tokenStartIndex = value.indexOf(token.text, currentIndex) + if (tokenStartIndex === -1) continue + if (tokenStartIndex > currentIndex) { + const spacerText = value.slice(currentIndex, tokenStartIndex) + result.push({spacerText}) + map.push({ + text: spacerText, + node: , + start: currentIndex, + end: tokenStartIndex + }) + } + const tokenWithKey = React.cloneElement(token.node, { + key: `token-${currentIndex}` + }) + result.push(tokenWithKey) + map.push({ + text: token.text, + node: token.node, + start: tokenStartIndex, + end: tokenStartIndex + token.text.length + }) + currentIndex = tokenStartIndex + token.text.length + } + + if (currentIndex < value.length) { + const trailingSpacer = value.slice(currentIndex) + result.push({trailingSpacer}) + map.push({ + text: trailingSpacer, + node: , + start: currentIndex, + end: value.length + }) + } + + if (multiline && value.endsWith('\n')) { + result.push() + } + + const active = + map.find((t) => selection.start >= t.start && selection.end <= t.end) || + null + + let activeTokenState: TokenState | null = null + if (active) { + const isCollapsed = selection.start === selection.end + activeTokenState = { + isCollapsed, + isAtStartOfToken: isCollapsed && selection.start === active.start, + isAtEndOfToken: isCollapsed && selection.end === active.end + } + } + + activeTokenRef.current = active + + return { weavedChildren: result, activeToken: active, activeTokenState } + }, [value, isRegistered, selection, multiline]) + + return { + isRegistered, + setIsRegistered, + registerToken, + weavedChildren, + activeToken, + activeTokenRef, + activeTokenState + } +} diff --git a/src/inlay/hooks/use-touch-selection.ts b/src/inlay/hooks/use-touch-selection.ts new file mode 100644 index 0000000..eb294d8 --- /dev/null +++ b/src/inlay/hooks/use-touch-selection.ts @@ -0,0 +1,144 @@ +import { useCallback, useRef } from 'react' +import { getClosestTokenEl } from '../internal/dom-utils' + +export type TouchSelectionConfig = { + editorRef: React.RefObject + handleSelectionChange: () => void + isComposingRef: React.MutableRefObject +} + +/** + * Handles touch-based selection interactions on mobile devices. + * - Debounces rapid touch events + * - Snaps selection to token boundaries when touching inside tokens + * - Handles long-press detection for native selection mode + */ +export function useTouchSelection(cfg: TouchSelectionConfig) { + const touchStartPosRef = useRef<{ x: number; y: number } | null>(null) + const longPressTimeoutRef = useRef(null) + const isTouchActiveRef = useRef(false) + + const clearLongPressTimeout = useCallback(() => { + if (longPressTimeoutRef.current !== null) { + clearTimeout(longPressTimeoutRef.current) + longPressTimeoutRef.current = null + } + }, []) + + const onTouchStart = useCallback( + (event: React.TouchEvent) => { + if (cfg.isComposingRef.current) return + + const touch = event.touches[0] + if (!touch) return + + touchStartPosRef.current = { x: touch.clientX, y: touch.clientY } + isTouchActiveRef.current = true + + // Long-press detection (500ms) - triggers native selection mode + // We don't prevent this; we just use it to know user wants selection + clearLongPressTimeout() + longPressTimeoutRef.current = window.setTimeout(() => { + // After long press, let native selection handle it + // We'll sync state in touchend or selectionchange + longPressTimeoutRef.current = null + }, 500) + }, + [cfg.isComposingRef, clearLongPressTimeout] + ) + + const onTouchMove = useCallback( + (event: React.TouchEvent) => { + if (!isTouchActiveRef.current) return + + const touch = event.touches[0] + if (!touch || !touchStartPosRef.current) return + + // If user moves finger significantly, cancel long-press + const dx = Math.abs(touch.clientX - touchStartPosRef.current.x) + const dy = Math.abs(touch.clientY - touchStartPosRef.current.y) + if (dx > 10 || dy > 10) { + clearLongPressTimeout() + } + }, + [clearLongPressTimeout] + ) + + const onTouchEnd = useCallback( + (event: React.TouchEvent) => { + clearLongPressTimeout() + isTouchActiveRef.current = false + touchStartPosRef.current = null + + if (cfg.isComposingRef.current) return + + const root = cfg.editorRef.current + if (!root) return + + // Check if touch ended inside a token + const changedTouch = event.changedTouches[0] + if (!changedTouch) return + + const elementAtPoint = document.elementFromPoint( + changedTouch.clientX, + changedTouch.clientY + ) + + if (elementAtPoint && root.contains(elementAtPoint)) { + const tokenEl = getClosestTokenEl(elementAtPoint) + + if (tokenEl) { + // If inside a token, snap selection to token boundary + // Let the native selection happen first, then sync + requestAnimationFrame(() => { + cfg.handleSelectionChange() + }) + } else { + // Normal text area - just sync selection + requestAnimationFrame(() => { + cfg.handleSelectionChange() + }) + } + } + }, + [cfg, clearLongPressTimeout] + ) + + return { + onTouchStart, + onTouchMove, + onTouchEnd + } +} + +/** + * Utility to detect if the current device supports touch + */ +export function isTouchDevice(): boolean { + if (typeof window === 'undefined') return false + return ( + 'ontouchstart' in window || + navigator.maxTouchPoints > 0 || + // @ts-expect-error - msMaxTouchPoints is IE-specific + navigator.msMaxTouchPoints > 0 + ) +} + +/** + * Utility to detect iOS devices + */ +export function isIOS(): boolean { + if (typeof navigator === 'undefined') return false + return ( + /iPad|iPhone|iPod/.test(navigator.userAgent) || + (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1) + ) +} + +/** + * Utility to detect Android devices + */ +export function isAndroid(): boolean { + if (typeof navigator === 'undefined') return false + return /Android/.test(navigator.userAgent) +} diff --git a/src/inlay/hooks/use-virtual-keyboard.ts b/src/inlay/hooks/use-virtual-keyboard.ts new file mode 100644 index 0000000..8c5bb29 --- /dev/null +++ b/src/inlay/hooks/use-virtual-keyboard.ts @@ -0,0 +1,67 @@ +import { useEffect, useRef, useCallback } from 'react' + +export type VirtualKeyboardConfig = { + editorRef: React.RefObject + onVirtualKeyboardChange?: (open: boolean) => void +} + +/** + * Detects virtual keyboard visibility changes using the visualViewport API. + * Scrolls the editor into view when the keyboard opens. + */ +export function useVirtualKeyboard(cfg: VirtualKeyboardConfig) { + const isKeyboardOpenRef = useRef(false) + const initialViewportHeightRef = useRef(null) + + const handleViewportResize = useCallback(() => { + const vv = window.visualViewport + if (!vv) return + + // Store initial viewport height on first call + if (initialViewportHeightRef.current === null) { + initialViewportHeightRef.current = vv.height + } + + // Detect keyboard by comparing current viewport height to initial + // A significant reduction (>25%) typically indicates keyboard is open + const threshold = initialViewportHeightRef.current * 0.75 + const keyboardOpen = vv.height < threshold + + // Only fire callback on state change + if (keyboardOpen !== isKeyboardOpenRef.current) { + isKeyboardOpenRef.current = keyboardOpen + cfg.onVirtualKeyboardChange?.(keyboardOpen) + + // When keyboard opens, scroll editor into view + if (keyboardOpen && cfg.editorRef.current) { + // Use a small delay to let the viewport settle + requestAnimationFrame(() => { + cfg.editorRef.current?.scrollIntoView({ + behavior: 'smooth', + block: 'center' + }) + }) + } + } + }, [cfg]) + + useEffect(() => { + const vv = window.visualViewport + if (!vv) return + + // Initialize with current height + initialViewportHeightRef.current = vv.height + + vv.addEventListener('resize', handleViewportResize) + vv.addEventListener('scroll', handleViewportResize) + + return () => { + vv.removeEventListener('resize', handleViewportResize) + vv.removeEventListener('scroll', handleViewportResize) + } + }, [handleViewportResize]) + + return { + isKeyboardOpen: isKeyboardOpenRef.current + } +} diff --git a/src/inlay/index.ts b/src/inlay/index.ts new file mode 100644 index 0000000..9586135 --- /dev/null +++ b/src/inlay/index.ts @@ -0,0 +1,15 @@ +import { Root, Token, Portal } from './inlay' +import { StructuredInlay } from './structured/structured-inlay' + +// Re-export types that consumers may need +export type { InlayProps, InlayRef, TokenState } from './inlay' +export type { Plugin } from './structured/plugins/plugin' +export type { Matcher, Match } from './internal/string-utils' + +// Compound component export — use as Inlay.Root, Inlay.Token, Inlay.Portal, etc. +export const Inlay = { + Root, + Token, + Portal, + StructuredInlay +} diff --git a/src/inlay/inlay.tsx b/src/inlay/inlay.tsx new file mode 100644 index 0000000..75af325 --- /dev/null +++ b/src/inlay/inlay.tsx @@ -0,0 +1,653 @@ +import type { Scope } from '@radix-ui/react-context' +import { createContextScope } from '@radix-ui/react-context' +import * as Popover from '@radix-ui/react-popover' +import { useControllableState } from '@radix-ui/react-use-controllable-state' +import React, { + createContext, + useCallback, + useContext, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState +} from 'react' +import { + getAbsoluteOffset, + setDomSelection, + serializeRawFromDom as serializeFromDom +} from './internal/dom-utils' +import { useHistory } from './hooks/use-history' +import { useSelection } from './hooks/use-selection' +import { useTokenWeaver } from './hooks/use-token-weaver' +import { useComposition } from './hooks/use-composition' +import { useKeyHandlers } from './hooks/use-key-handlers' +import { usePlaceholderSync } from './hooks/use-placeholder-sync' +import { useSelectionSnap } from './hooks/use-selection-snap' +import { + PortalList, + PortalItem, + type PortalKeyboardHandler +} from './portal-list' +import { useClipboard } from './hooks/use-clipboard' +import { useVirtualKeyboard } from './hooks/use-virtual-keyboard' +import { useTouchSelection } from './hooks/use-touch-selection' + +export const COMPONENT_NAME = 'Inlay' +export const TEXT_COMPONENT_NAME = 'Inlay.Text' + +export type ScopedProps = P & { __scope?: Scope } +const [createInlayContext] = createContextScope(COMPONENT_NAME) + +const AncestorContext = createContext(null) +const PopoverControlContext = createContext<{ + setOpen: (open: boolean) => void +} | null>(null) + +// Context for portal keyboard navigation +type PortalKeyboardContextValue = { + setHandler: (handler: PortalKeyboardHandler | null) => void +} +const PortalKeyboardContext = createContext( + null +) +export { PortalKeyboardContext } + +// Context for anchor rect - allows Portal to position itself based on caret position +type AnchorRectContextValue = { + getRect: () => DOMRect +} +const AnchorRectContext = createContext(null) + +function annotateWithAncestor( + node: React.ReactNode, + currentAncestor: React.ReactElement | null +): React.ReactNode { + if (!React.isValidElement(node)) { + return node + } + + const element = node as React.ReactElement<{ children?: React.ReactNode }> + const nextAncestor = currentAncestor ?? element + + const children = element.props.children + const wrappedChildren = React.Children.map( + children, + (child: React.ReactNode) => annotateWithAncestor(child, nextAncestor) + ) + + return ( + + {React.cloneElement(element, undefined, wrappedChildren)} + + ) +} + +// --- Internal Context for token registration --- +type InternalInlayContextValue = { + registerToken: (token: { text: string; node: React.ReactElement }) => void +} +const [InternalInlayProvider, useInternalInlayContext] = + createInlayContext(COMPONENT_NAME) + +// --- Public Context for consumer state --- +type TokenInfo = { + text: string + node: React.ReactElement + start: number + end: number +} + +export type TokenState = { + isCollapsed: boolean + isAtStartOfToken: boolean + isAtEndOfToken: boolean +} + +type PublicInlayContextValue = { + value: string + selection: { start: number; end: number } + activeToken: TokenInfo | null + activeTokenState: TokenState | null + getSelectionRange: () => Range | null +} + +const [PublicInlayProvider, usePublicInlayContext] = + createInlayContext(COMPONENT_NAME) + +export type InlayProps = ScopedProps< + { + children: React.ReactNode + value?: string + defaultValue?: string + onChange?: (value: string) => void + placeholder?: React.ReactNode + multiline?: boolean + // Mobile input attributes + inputMode?: + | 'text' + | 'search' + | 'email' + | 'tel' + | 'url' + | 'numeric' + | 'decimal' + | 'none' + autoCapitalize?: + | 'off' + | 'none' + | 'on' + | 'sentences' + | 'words' + | 'characters' + autoCorrect?: 'on' | 'off' + enterKeyHint?: + | 'enter' + | 'done' + | 'go' + | 'next' + | 'previous' + | 'search' + | 'send' + onVirtualKeyboardChange?: (open: boolean) => void + } & Omit< + React.HTMLAttributes, + 'onChange' | 'defaultValue' | 'onKeyDown' + > & { + onKeyDown?: (event: React.KeyboardEvent) => boolean // Return true to stop propagation + } & { + getPopoverAnchorRect?: (root: HTMLDivElement | null) => DOMRect + } +> + +export type InlayRef = { + root: HTMLDivElement | null + setSelection: (start: number, end?: number) => void +} + +const Inlay = React.forwardRef((props, forwardedRef) => { + const { + __scope, + children: allChildren, + value: valueProp, + defaultValue, + onChange, + onKeyDown: onKeyDownProp, + placeholder, + multiline = true, + getPopoverAnchorRect, + // Mobile input attributes + inputMode = 'text', + autoCapitalize = 'sentences', + autoCorrect, // Omit by default - let iOS use native behavior (single-word suggestions) + enterKeyHint, + onVirtualKeyboardChange, + ...inlayProps + } = props + + const popoverPortal = React.useMemo( + () => + React.Children.toArray(allChildren).find( + (child) => React.isValidElement(child) && child.type === Portal + ), + [allChildren] + ) + + const children = React.useMemo( + () => + React.Children.toArray(allChildren).filter( + (child) => !React.isValidElement(child) || child.type !== Portal + ), + [allChildren] + ) + + const [isPopoverOpen, setIsPopoverOpen] = useState(false) + + // Portal keyboard handler - allows Portal.List to intercept keyboard events + const portalKeyboardHandlerRef = useRef(null) + const portalKeyboardContext = useMemo( + () => ({ + setHandler: (handler: PortalKeyboardHandler | null) => { + portalKeyboardHandlerRef.current = handler + } + }), + [] + ) + const popoverControl = useMemo(() => ({ setOpen: setIsPopoverOpen }), []) + const [value, setValue] = useControllableState({ + prop: valueProp, + defaultProp: defaultValue || '', + onChange + }) + // Keep a ref to the current value for synchronous access in event handlers + const valueRef = useRef(value) + valueRef.current = value + const editorRef = useRef(null) + const placeholderRef = useRef(null) + const { + selection, + setSelection, + setSelectionImperative, + handleSelectionChange, + lastAnchorRectRef, + suppressNextSelectionAdjustRef + } = useSelection(editorRef, value) + const virtualAnchorRef = useRef({ + getBoundingClientRect: () => lastAnchorRectRef.current + }) + const customAnchorRef = useRef({ + getBoundingClientRect: () => new DOMRect(0, 0, 0, 0) + }) + useLayoutEffect(() => { + if (!getPopoverAnchorRect) return + customAnchorRef.current.getBoundingClientRect = () => { + try { + const root = editorRef.current + const rect = getPopoverAnchorRect(root) + return rect ?? new DOMRect(0, 0, 0, 0) + } catch { + return new DOMRect(0, 0, 0, 0) + } + } + }, [getPopoverAnchorRect]) + const chosenAnchorRef = getPopoverAnchorRect + ? customAnchorRef + : virtualAnchorRef + const { + isRegistered, + registerToken, + weavedChildren, + activeToken, + activeTokenRef, + activeTokenState + } = useTokenWeaver(value, selection, multiline, children) + + const lastArrowDirectionRef = useRef<'left' | 'right' | 'up' | 'down' | null>( + null + ) + const lastShiftRef = useRef(false) + + const getCurrentSnapshot = useCallback(() => { + const root = editorRef.current + if (root) { + const sel = window.getSelection() + if (sel && sel.rangeCount > 0) { + const r = sel.getRangeAt(0) + const start = getAbsoluteOffset(root, r.startContainer, r.startOffset) + const end = getAbsoluteOffset(root, r.endContainer, r.endOffset) + return { value, selection: { start, end } } + } + } + return { value, selection } + }, [value, selection]) + const applySnapshot = useCallback( + (snap: { value: string; selection: { start: number; end: number } }) => { + setValue(() => snap.value) + requestAnimationFrame(() => { + const root = editorRef.current + if (root) { + suppressNextSelectionAdjustRef.current = true + setDomSelection(root, snap.selection.start, snap.selection.end) + } + }) + }, + [setValue] + ) + const { pushUndoSnapshot, beginEditSession, endEditSession, undo, redo } = + useHistory(getCurrentSnapshot, applySnapshot, 200) + + const serializeRawFromDom = useCallback((): string => { + const root = editorRef.current + if (!root) return value + return serializeFromDom(root) + }, [value]) + + const { + isComposing, + isComposingRef, + suppressNextBeforeInputRef, + suppressNextKeydownCommitRef, + compositionCommitKeyRef, + compositionJustEndedAtRef, + contentKey, + onCompositionStart, + onCompositionUpdate, + onCompositionEnd + } = useComposition( + editorRef, + serializeRawFromDom, + handleSelectionChange, + setValue, + () => value + ) + + usePlaceholderSync(editorRef, placeholderRef, [value, placeholder]) + + // weaving moved + const getSelectionRange = useCallback(() => { + const domSelection = window.getSelection() + if (domSelection && domSelection.rangeCount > 0) { + return domSelection.getRangeAt(0) + } + return null + }, []) + const { onBeforeInput, onKeyDown } = useKeyHandlers({ + editorRef, + contentKey, + multiline, + onKeyDownProp, + beginEditSession, + endEditSession, + pushUndoSnapshot, + undo, + redo, + isComposingRef, + compositionCommitKeyRef, + suppressNextBeforeInputRef, + suppressNextKeydownCommitRef, + compositionJustEndedAtRef, + setValue, + valueRef, + getActiveToken: () => + activeTokenRef.current + ? { + start: activeTokenRef.current.start, + end: activeTokenRef.current.end + } + : null + }) + const { onSelect } = useSelectionSnap({ + editorRef, + setSelection, + lastAnchorRectRef, + suppressNextSelectionAdjustRef, + lastArrowDirectionRef, + lastShiftRef, + isComposingRef + }) + const { onCopy, onCut, onPaste } = useClipboard({ + editorRef, + getValue: () => value, + setValue, + pushUndoSnapshot, + isComposingRef + }) + useVirtualKeyboard({ + editorRef, + onVirtualKeyboardChange + }) + const { onTouchStart, onTouchMove, onTouchEnd } = useTouchSelection({ + editorRef, + handleSelectionChange, + isComposingRef + }) + // Wrap onKeyDown to route through portal keyboard handler first + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + // If portal has a keyboard handler registered and it handles the event, stop + if ( + isPopoverOpen && + portalKeyboardHandlerRef.current && + portalKeyboardHandlerRef.current(event) + ) { + return + } + // Otherwise, use the default key handler + onKeyDown(event) + }, + [isPopoverOpen, onKeyDown] + ) + useImperativeHandle(forwardedRef, () => ({ + root: editorRef.current, + setSelection: (start: number, end?: number) => { + if (editorRef.current) { + setSelectionImperative(start, end) + } + } + })) + return ( + + + + + + {/* First pass: renders children to populate registry */} + + {React.Children.map(children, (child) => + annotateWithAncestor(child, null) + )} + + + {/* Second pass: renders weaved content */} + + + {isRegistered ? weavedChildren : children} + + {value.length === 0 && placeholder && !isComposing && ( + + {placeholder} + + )} + + ({ getRect: () => lastAnchorRectRef.current }), + [] + )} + > + + {popoverPortal} + + + + + + + ) +}) + +type PortalRenderProps = (context: PublicInlayContextValue) => React.ReactNode + +type PortalProps = ScopedProps< + Omit & { + children: PortalRenderProps + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: `data-${string}`]: any + } +> + +const Portal = (props: PortalProps) => { + const { + __scope, + children, + side = 'bottom', + sideOffset = 0, + alignOffset = 0, + ...contentProps + } = props + const context = usePublicInlayContext(COMPONENT_NAME, __scope) + const popoverControl = useContext(PopoverControlContext) + const keyboardContext = useContext(PortalKeyboardContext) + const anchorRectContext = useContext(AnchorRectContext) + const contentRef = useRef(null) + + const content = children(context) + const hasContent = !!content + + // Track last content to avoid flashing during timing gaps between render and effects + const lastContentRef = useRef(null) + const closeTimeoutRef = useRef(null) + + if (hasContent) { + lastContentRef.current = content + if (closeTimeoutRef.current !== null) { + clearTimeout(closeTimeoutRef.current) + closeTimeoutRef.current = null + } + } + + useLayoutEffect(() => { + if (hasContent) { + popoverControl?.setOpen(true) + } else { + // Defer close to allow effects to catch up + if (closeTimeoutRef.current !== null) + clearTimeout(closeTimeoutRef.current) + closeTimeoutRef.current = window.setTimeout(() => { + closeTimeoutRef.current = null + popoverControl?.setOpen(false) + lastContentRef.current = null + }, 50) + } + return () => { + if (closeTimeoutRef.current !== null) { + clearTimeout(closeTimeoutRef.current) + closeTimeoutRef.current = null + } + } + }, [hasContent, popoverControl]) + + // Position manually via DOM to follow caret on iOS and avoid re-render loops + useLayoutEffect(() => { + const el = contentRef.current + if (!el || !anchorRectContext) return + + const rect = anchorRectContext.getRect() + if (rect.x === 0 && rect.y === 0 && rect.width === 0 && rect.height === 0) + return + + let top: number, left: number + if (side === 'bottom') { + top = rect.bottom + sideOffset + left = rect.left + alignOffset + } else if (side === 'top') { + top = rect.top - el.offsetHeight - sideOffset + left = rect.left + alignOffset + } else if (side === 'left') { + top = rect.top + alignOffset + left = rect.left - el.offsetWidth - sideOffset + } else { + top = rect.top + alignOffset + left = rect.right + sideOffset + } + + el.style.top = `${top}px` + el.style.left = `${left}px` + el.style.visibility = 'visible' + }) + + const displayContent = content || lastContentRef.current + + if (!displayContent) return null + + return ( + + e.preventDefault()} + {...contentProps} + style={{ + ...contentProps.style, + position: 'fixed', + top: 0, + left: 0, + zIndex: 50, + visibility: 'hidden' // Made visible by useLayoutEffect after positioning + }} + > + + {displayContent} + + + + ) +} +Portal.displayName = 'Inlay.Portal' + +// Attach List and Item to Portal for compound component API +const PortalWithList = Object.assign(Portal, { + List: PortalList, + Item: PortalItem +}) + +type TokenProps = ScopedProps<{ + value: string + children: React.ReactNode +}> & + React.HTMLAttributes + +const Token = React.forwardRef((props, ref) => { + const { __scope, value, children, ...textProps } = props + const internalContext = useInternalInlayContext(TEXT_COMPONENT_NAME, __scope) + const ancestor = useContext(AncestorContext) + + const nodeToRegister = ( + + {children} + + ) + + // Register the token with its text value and the React node itself. + // If an ancestor is found in context, register that instead of the immediate span. + internalContext.registerToken({ + text: value, + node: ancestor || nodeToRegister + }) + + return nodeToRegister +}) + +Token.displayName = TEXT_COMPONENT_NAME + +export { Inlay as Root, Token, PortalWithList as Portal } diff --git a/src/inlay/internal/dom-utils.test.ts b/src/inlay/internal/dom-utils.test.ts new file mode 100644 index 0000000..38dcedc --- /dev/null +++ b/src/inlay/internal/dom-utils.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'vitest' +import { getAbsoluteOffset, getTextNodeAtOffset } from './dom-utils' + +// Helper to build a minimal editor DOM tree +function createEditor(html: string): HTMLElement { + const root = document.createElement('div') + root.innerHTML = html + return root +} + +describe('dom-utils', () => { + it('getAbsoluteOffset on plain text accumulates correctly', () => { + const root = createEditor('hello world') + const firstText = root.querySelector('span')!.firstChild as Text + const secondText = root.querySelectorAll('span')[1].firstChild as Text + + expect(getAbsoluteOffset(root, firstText, 0)).toBe(0) // h| + expect(getAbsoluteOffset(root, firstText, 5)).toBe(5) // hello| + expect(getAbsoluteOffset(root, secondText, 1)).toBe(6) // hello␠| + }) + + it('getTextNodeAtOffset on plain text maps back to nodes/offsets', () => { + const root = createEditor('hello world') + const [n0, o0] = getTextNodeAtOffset(root, 0) + expect((n0 as Text).data.slice(0, o0)).toBe('') + + const [n5, o5] = getTextNodeAtOffset(root, 5) + expect((n5 as Text).data.slice(0, o5)).toBe('hello') + + const [n7, o7] = getTextNodeAtOffset(root, 7) + expect((n7 as Text).data.slice(0, o7)).toBe(' w') + }) + + it('diverged token: absolute offset snaps to token edges when inside token', () => { + const root = createEditor( + 'hi ' + + 'Alex' + + '!' + ) + const token = root.querySelector('[data-token-text]') as HTMLElement + const tokenInner = token.querySelector('span')!.firstChild as Text // "Alex" + + // Inside first char of rendered token should snap to start of raw token (offset at start of token = 3) + expect(getAbsoluteOffset(root, tokenInner, 1)).toBe(3) + + // Inside last char of rendered token should snap to end of raw token (raw len = 5, base start = 3, so end = 8) + expect(getAbsoluteOffset(root, tokenInner, 4)).toBe(8) + }) + + it('round-trip sweep over all offsets in mixed content (snap inside diverged token)', () => { + const root = createEditor( + 'X' + + 'Alex' + + 'Y' + ) + // raw: "X@alexY" -> token starts at 1, rawLen=5, tokenEnd=6, total len=7 + const tokenStart = 1 + const tokenRawLen = 5 + const tokenEnd = tokenStart + tokenRawLen + for (let i = 0; i <= 7; i++) { + const [n, o] = getTextNodeAtOffset(root, i) + const roundTrip = getAbsoluteOffset(root, n as Node, o) + const expected = + i < tokenStart || i > tokenEnd + ? i + : i - tokenStart <= tokenRawLen / 2 + ? tokenStart + : tokenEnd + expect(roundTrip).toBe(expected) + } + }) + + it('fallbacks: offsets past end clamp to last text node end', () => { + const root = createEditor('abc') + const [n, o] = getTextNodeAtOffset(root, 999) + expect((n as Text).data.length).toBe(3) + expect(o).toBe(3) + }) +}) diff --git a/src/inlay/internal/dom-utils.ts b/src/inlay/internal/dom-utils.ts new file mode 100644 index 0000000..8146bde --- /dev/null +++ b/src/inlay/internal/dom-utils.ts @@ -0,0 +1,381 @@ +const isTokenElement = (el: Element): boolean => + el.hasAttribute('data-token-text') + +const getTokenRawLength = (el: Element): number => + (el.getAttribute('data-token-text') || '').length + +const getRenderedTextLength = (el: Element): number => { + const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null) + let total = 0 + let n: Node | null + while ((n = walker.nextNode())) total += (n.textContent || '').length + return total +} + +const findFirstTextNode = (el: Element): ChildNode | null => { + const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null) + return walker.nextNode() as ChildNode | null +} + +const findLastTextNode = (el: Element): ChildNode | null => { + const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null) + let last: Node | null = null + let n: Node | null + while ((n = walker.nextNode())) last = n + return last as ChildNode | null +} + +export function getClosestTokenEl(node: Node | null): HTMLElement | null { + let curr: Node | null = node + while (curr) { + if (curr.nodeType === Node.ELEMENT_NODE) { + const el = curr as HTMLElement + if (el.hasAttribute('data-token-text')) return el + } + curr = curr.parentNode + } + return null +} + +export function getTokenRawRange( + root: HTMLElement, + tokenEl: HTMLElement +): { start: number; end: number } | null { + const rawText = tokenEl.getAttribute('data-token-text') + if (!rawText) return null + + const walker = document.createTreeWalker(tokenEl, NodeFilter.SHOW_TEXT, null) + const firstText = walker.nextNode() + if (!firstText) return null + + const start = getAbsoluteOffset(root, firstText, 0) + return { start, end: start + rawText.length } +} + +export const getTextNodeAtOffset = ( + root: HTMLElement, + offset: number +): [ChildNode | null, number] => { + const traverse = ( + container: Node, + remaining: { value: number } + ): [ChildNode | null, number] | null => { + const children = container.childNodes + for (let i = 0; i < children.length; i++) { + const child = children[i] + + if (child.nodeType === Node.ELEMENT_NODE) { + const el = child as Element + + // represents a newline character - count as 1 + // If we're at the position right after a , return the next text node + if (el.tagName === 'BR') { + if (remaining.value === 0) { + // Position is right at the - find next text node + // Look for the next sibling text node or continue traversal + for (let j = i + 1; j < children.length; j++) { + const next = children[j] + if (next.nodeType === Node.TEXT_NODE) { + return [next as ChildNode, 0] + } + if (next.nodeType === Node.ELEMENT_NODE) { + const nextEl = next as Element + const first = findFirstTextNode(nextEl) + if (first) return [first, 0] + } + } + // No next text node found - return null + return null + } + remaining.value -= 1 + continue + } + + if (isTokenElement(el)) { + const rawLen = getTokenRawLength(el) + const renderedLen = getRenderedTextLength(el) + const isDiverged = renderedLen !== rawLen + + if (isDiverged) { + if (remaining.value <= rawLen) { + const first = findFirstTextNode(el) + const last = findLastTextNode(el) + if (!first && !last) return null + + const snapToStart = remaining.value <= rawLen / 2 + if (snapToStart) { + if (first) return [first, 0] + } else { + if (last) return [last, (last.textContent || '').length] + } + if (first) return [first, 0] + if (last) return [last, (last.textContent || '').length] + return null + } + remaining.value -= rawLen + continue + } + + const found = traverse(el, remaining) + if (found) return found + continue + } + + const found = traverse(el, remaining) + if (found) return found + continue + } + + if (child.nodeType === Node.TEXT_NODE) { + const text = child.textContent || '' + if (remaining.value <= text.length) { + return [child as ChildNode, remaining.value] + } + remaining.value -= text.length + } + } + + return null + } + + const result = traverse(root, { value: Math.max(0, offset) }) + if (result) return result + + // Fallback: place at end of last text node + const allTokenTextNodes = Array.from( + root.querySelectorAll('[data-token-text]') + ) + .map((el) => findLastTextNode(el)) + .filter(Boolean) as ChildNode[] + + if (allTokenTextNodes.length > 0) { + const lastNode = allTokenTextNodes[allTokenTextNodes.length - 1] + return [lastNode, (lastNode.textContent || '').length] + } + + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null) + let last: Node | null = null + let n: Node | null + while ((n = walker.nextNode())) last = n + if (last) return [last as ChildNode, (last.textContent || '').length] + + return [null, 0] +} + +export const getAbsoluteOffset = ( + root: HTMLElement, + node: Node, + offset: number +): number => { + const getOffsetWithinElement = ( + el: Element, + target: Node, + targetOffset: number + ): number => { + const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null) + let total = 0 + let n: Node | null + while ((n = walker.nextNode())) { + if (n === target) { + total += Math.min(targetOffset, (n.textContent || '').length) + break + } + total += (n.textContent || '').length + } + return total + } + + // Handle element node containers (e.g. Firefox Ctrl+a sets selection on element, not text node) + if (node.nodeType === Node.ELEMENT_NODE) { + const el = node as Element + const children = el.childNodes + + let total = 0 + const measure = (n: Node): number => { + if (n.nodeType === Node.TEXT_NODE) { + return (n.textContent || '').length + } + if (n.nodeType === Node.ELEMENT_NODE) { + const e = n as Element + // represents a newline character + if (e.tagName === 'BR') return 1 + if (isTokenElement(e)) return getTokenRawLength(e) + let sum = 0 + for (let i = 0; i < e.childNodes.length; i++) { + sum += measure(e.childNodes[i]) + } + return sum + } + return 0 + } + + for (let i = 0; i < offset && i < children.length; i++) { + total += measure(children[i]) + } + return total + } + + const traverse = (container: Node, acc: { value: number }): number | null => { + const children = container.childNodes + for (let i = 0; i < children.length; i++) { + const child = children[i] + + if (child.nodeType === Node.ELEMENT_NODE) { + const el = child as Element + + if (isTokenElement(el)) { + const rawLen = getTokenRawLength(el) + const renderedLen = getRenderedTextLength(el) + const isDiverged = renderedLen !== rawLen + + if (isDiverged) { + if (el.contains(node)) { + const withinRendered = getOffsetWithinElement(el, node, offset) + const snapToStart = withinRendered <= renderedLen / 2 + return acc.value + (snapToStart ? 0 : rawLen) + } + acc.value += rawLen + continue + } + + if (el.contains(node)) { + const inner = traverse(el, acc) + if (inner != null) return inner + } else { + acc.value += renderedLen + } + continue + } + + // represents a newline character - count as 1 and continue + if (el.tagName === 'BR') { + acc.value += 1 + continue + } + + if (el.contains(node)) { + const inner = traverse(el, acc) + if (inner != null) return inner + } else { + const measureSubtree = (e: Element): number => { + // represents a newline character + if (e.tagName === 'BR') return 1 + let total = 0 + const cn = e.childNodes + for (let j = 0; j < cn.length; j++) { + const c = cn[j] + if (c.nodeType === Node.TEXT_NODE) { + total += (c.textContent || '').length + } else if (c.nodeType === Node.ELEMENT_NODE) { + const ce = c as Element + if (isTokenElement(ce)) { + const rl = getTokenRawLength(ce) + const rr = getRenderedTextLength(ce) + total += rr === rl ? rr : rl + } else { + total += measureSubtree(ce) + } + } + } + return total + } + acc.value += measureSubtree(el) + } + continue + } + + if (child.nodeType === Node.TEXT_NODE) { + if (child === node) { + return acc.value + Math.min(offset, (child.textContent || '').length) + } else { + acc.value += (child.textContent || '').length + } + } + } + return null + } + + const result = traverse(root, { value: 0 }) + if (result != null) return result + + // Fallback: total length + let total = 0 + const stack: Node[] = [root] + while (stack.length) { + const n = stack.pop()! + if (n.nodeType === Node.ELEMENT_NODE) { + const el = n as Element + if (isTokenElement(el)) { + const rl = getTokenRawLength(el) + const rr = getRenderedTextLength(el) + total += rr === rl ? rr : rl + continue + } + const cn = el.childNodes + for (let i = cn.length - 1; i >= 0; i--) stack.push(cn[i]) + } else if (n.nodeType === Node.TEXT_NODE) { + total += (n.textContent || '').length + } + } + return total +} + +export const setDomSelection = ( + root: HTMLElement, + start: number, + end?: number +) => { + const [startNode, startOffset] = getTextNodeAtOffset(root, start) + const [endNode, endOffset] = end + ? getTextNodeAtOffset(root, end) + : [startNode, startOffset] + + if (startNode && endNode) { + // Guard against NotFoundError on iOS: nodes may have been removed + // by React re-renders between scheduling and execution + if (!startNode.isConnected || !endNode.isConnected) { + return + } + + const range = document.createRange() + range.setStart(startNode, startOffset) + range.setEnd(endNode, endOffset) + const selection = window.getSelection() + if (selection) { + selection.removeAllRanges() + selection.addRange(range) + } + } +} + +export const serializeRawFromDom = (root: HTMLElement): string => { + const clone = root.cloneNode(true) as HTMLElement + const tokenEls = clone.querySelectorAll('[data-token-text]') + tokenEls.forEach((el) => { + const raw = el.getAttribute('data-token-text') || '' + const renderedLen = getRenderedTextLength(el) + if (renderedLen !== raw.length) { + ;(el as HTMLElement).textContent = raw + } + }) + let result = (clone as HTMLElement).innerText + // iOS contentEditable often has trailing newlines, zero-width spaces, or other + // invisible characters when "empty". Strip these from the end. + // Also handle the case where the entire content is just whitespace/invisible chars. + result = result.replace(/[\u200B\uFEFF]+/g, '') // Remove zero-width spaces throughout + + // Handle empty content (just newlines or whitespace) + if (result === '\n' || result.trim() === '') { + return '' + } + + // ContentEditable often adds ONE extra trailing newline. Remove it if present. + // But preserve intentional newlines in the content (e.g., "hello\nworld\n" → "hello\nworld") + // This is tricky: we can't know if the final newline is intentional or added by browser. + // Heuristic: if it ends with double newline, remove one. Single trailing newline stays. + if (result.endsWith('\n\n')) { + result = result.slice(0, -1) + } + + return result +} diff --git a/src/inlay/internal/string-utils.test.ts b/src/inlay/internal/string-utils.test.ts new file mode 100644 index 0000000..b323ae4 --- /dev/null +++ b/src/inlay/internal/string-utils.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect } from 'vitest' +import { + createRegexMatcher, + filterMatchesByMatcher, + groupMatchesByMatcher, + scan +} from './string-utils' + +describe('string-utils', () => { + it('createRegexMatcher + scan: finds and sorts matches with names', () => { + const mention = createRegexMatcher<{ mention: string }, 'mention'>( + 'mention', + { + regex: /@\w+/g, + transform: (m) => ({ mention: m[0] }) + } + ) + + const text = 'hi @alex and @bob' + const matches = scan(text, [mention]) + + expect(matches.map((m) => m.raw)).toEqual(['@alex', '@bob']) + expect(matches.map((m) => m.start)).toEqual([3, 13]) + expect(matches.every((m) => m.matcher === 'mention')).toBe(true) + }) + + it('createRegexMatcher resets lastIndex between scans', () => { + const word = createRegexMatcher<{ w: string }, 'word'>('word', { + regex: /\w+/g, + transform: (m) => ({ w: m[0] }) + }) + + const a = scan('alpha beta', [word]) + const b = scan('gamma', [word]) + + expect(a.map((m) => m.raw)).toEqual(['alpha', 'beta']) + expect(b.map((m) => m.raw)).toEqual(['gamma']) + }) + + it('filterMatchesByMatcher returns only requested matcher type', () => { + const mention = createRegexMatcher<{ mention: string }, 'mention'>( + 'mention', + { regex: /@\w+/g, transform: (m) => ({ mention: m[0] }) } + ) + const hash = createRegexMatcher<{ tag: string }, 'hashtag'>('hashtag', { + regex: /#\w+/g, + transform: (m) => ({ tag: m[0] }) + }) + + const matches = scan('say @alex and #music', [mention, hash]) + + const onlyMentions = filterMatchesByMatcher(matches, 'mention') + expect(onlyMentions.map((m) => m.raw)).toEqual(['@alex']) + }) + + it('groupMatchesByMatcher groups by matcher name', () => { + const m1 = createRegexMatcher<{ v: string }, 'a'>('a', { + regex: /a/g, + transform: (m) => ({ v: m[0] }) + }) + const m2 = createRegexMatcher<{ v: string }, 'b'>('b', { + regex: /b/g, + transform: (m) => ({ v: m[0] }) + }) + + const matches = scan('ababa', [m1, m2]) + const grouped = groupMatchesByMatcher(matches) + + expect(grouped.a?.map((x) => x.raw)).toEqual(['a', 'a', 'a']) + expect(grouped.b?.map((x) => x.raw)).toEqual(['b', 'b']) + }) + + it('createRegexMatcher throws when regex lacks global flag', () => { + expect(() => + // @ts-expect-error intentional invalid regex config for test + createRegexMatcher('bad', { regex: /@\w+/, transform: (m) => m[0] }) + ).toThrow() + }) + + it('createRegexMatcher throws when regex uses sticky flag', () => { + expect(() => + // @ts-expect-error intentional invalid regex config for test + createRegexMatcher('bad', { regex: /@\w+/gy, transform: (m) => m[0] }) + ).toThrow() + }) + + it('scan filters overlapping matches - longer match wins', () => { + const atWord = createRegexMatcher<{ v: string }, 'at'>('at', { + regex: /@\w+/g, + transform: (m) => ({ v: m[0] }) + }) + const word = createRegexMatcher<{ v: string }, 'word'>('word', { + regex: /\w+/g, + transform: (m) => ({ v: m[0] }) + }) + + // @alex (5 chars starting at 0) vs alex (4 chars starting at 1) + // They overlap, and @alex is longer, so only @alex should be returned + const matches = scan('@alex', [atWord, word]) + expect(matches.map((m) => `${m.matcher}:${m.raw}`)).toEqual(['at:@alex']) + }) + + it('scan: longest match wins when two matchers start at same position', () => { + const short = createRegexMatcher<{ v: string }, 'short'>('short', { + regex: /@alice/g, + transform: (m) => ({ v: m[0] }) + }) + const long = createRegexMatcher<{ v: string }, 'long'>('long', { + regex: /@alice_vip/g, + transform: (m) => ({ v: m[0] }) + }) + + // Both match starting at position 0, but long is 10 chars vs short's 6 + const matches = scan('@alice_vip', [short, long]) + expect(matches.map((m) => `${m.matcher}:${m.raw}`)).toEqual([ + 'long:@alice_vip' + ]) + }) + + it('scan: first matcher wins when matches are exact same range', () => { + const pluginA = createRegexMatcher<{ v: string }, 'a'>('a', { + regex: /@test/g, + transform: (m) => ({ v: m[0] }) + }) + const pluginB = createRegexMatcher<{ v: string }, 'b'>('b', { + regex: /@test/g, + transform: (m) => ({ v: m[0] }) + }) + + // Both match @test at exact same range - first matcher (a) should win + const matches = scan('@test', [pluginA, pluginB]) + expect(matches.map((m) => `${m.matcher}:${m.raw}`)).toEqual(['a:@test']) + }) + + it('scan preserves non-overlapping matches from multiple matchers', () => { + const mention = createRegexMatcher<{ v: string }, 'mention'>('mention', { + regex: /@\w+/g, + transform: (m) => ({ v: m[0] }) + }) + const hashtag = createRegexMatcher<{ v: string }, 'hashtag'>('hashtag', { + regex: /#\w+/g, + transform: (m) => ({ v: m[0] }) + }) + + // @bob and #music don't overlap, both should be kept + const matches = scan('@bob loves #music', [mention, hashtag]) + expect(matches.map((m) => `${m.matcher}:${m.raw}`)).toEqual([ + 'mention:@bob', + 'hashtag:#music' + ]) + }) +}) diff --git a/src/inlay/internal/string-utils.ts b/src/inlay/internal/string-utils.ts new file mode 100644 index 0000000..b5de702 --- /dev/null +++ b/src/inlay/internal/string-utils.ts @@ -0,0 +1,248 @@ +/** + * Represents a single matched token within a larger string. + * @template T The type of the structured data associated with the match. + * @template N The literal string type of the matcher's name. + */ +export type Match = { + /** The raw matched text span from the original string. */ + raw: string + /** The start index of the match in the full string. */ + start: number + /** The end index of the match in the full string. */ + end: number + /** A structured object with parsed values defined by the matcher. */ + data: T + /** The unique `name` of the matcher that produced this match. */ + matcher: N +} + +/** + * A utility type that constructs a `Match` type from a `Matcher` type. + * It infers the data type `T` and the literal name `N` from the matcher. + * This is key to creating a discriminated union for the `scan` function's return type. + */ +export type MatchFromMatcher = + M extends Matcher ? Match : never + +type MatchData = Omit, 'matcher'> + +/** + * Defines a configuration for finding a specific kind of token. + * It's a generic interface that can be implemented using various strategies (e.g., regex, prefix, custom logic). + * @template T The type of the structured data this matcher will produce. + * @template N The literal string type of the matcher's name. + */ +export type Matcher = { + /** A unique name for the matcher, used to identify which matcher found a token. */ + name: N + /** + * The core matching logic. It scans the input text and returns all found tokens. + * @param text The full string to scan. + * @returns An array of match data, without the `matcher` property. + */ + match: (text: string) => MatchData[] +} + +/** + * Scans a string for tokens using a set of configured matchers. + * + * This function is the core of the token matching utility. It orchestrates the matching process + * by running all provided matchers over the input text and consolidating the results. + * Each matcher can produce a different type of structured data, and the return type will + * be a discriminated union of all possible `Match` types, inferred from the provided matchers. + * + * @param text The full string to scan. + * @param matchers An array of `Matcher` objects to run on the string. The use of `` ensures + * that the literal types of matcher names are preserved for type inference. + * @returns An array of all `Match` objects found, sorted by their start index. + * The type of each element is a discriminated union of all possible `Match` types. + * Consumers can switch on `match.matcher` to safely access `match.data`. + */ +export function scan[]>( + text: string, + matchers: M +): MatchFromMatcher[] { + const allMatches = matchers.flatMap((matcher) => + matcher.match(text).map((m) => ({ ...m, matcher: matcher.name })) + ) as MatchFromMatcher[] + + // Sort by start position ascending, then by length descending (longest first at same position) + // This ensures the longest match at each position is considered first + allMatches.sort((a, b) => { + if (a.start !== b.start) return a.start - b.start + return b.end - b.start - (a.end - a.start) + }) + + // Greedy algorithm: accept non-overlapping matches, preferring longer ones + // When two matches overlap, the one starting earlier (or longer at same position) wins + const accepted: typeof allMatches = [] + let lastEnd = -1 + for (const match of allMatches) { + if (match.start >= lastEnd) { + accepted.push(match) + lastEnd = match.end + } + } + + return accepted +} + +/** + * Options for creating a regex-based matcher. + * @template T The type of the structured data the matcher will produce. + */ +type RegexMatcherOptions = { + /** The regular expression to match against. Must include the 'g' flag for global matching. */ + regex: RegExp + /** A function to transform the raw regex match array into the desired structured data. */ + transform: (match: RegExpExecArray) => T +} + +/** + * A factory function to create a `Matcher` that uses a regular expression. + * This simplifies the creation of matchers for patterns that can be described with regex. + * + * @template T The type of the structured data the created matcher will produce. + * @param name The unique name for this matcher. + * @param options The configuration for the regex matcher. + * @returns A new `Matcher` object. + */ +export function createRegexMatcher( + name: N, + options: RegexMatcherOptions +): Matcher { + if (!options.regex.global) { + throw new Error('createRegexMatcher requires a global regex (e.g. /.../g)') + } + if (options.regex.sticky) { + throw new Error( + `createRegexMatcher does not support the sticky 'y' flag, as it interferes with scanning.` + ) + } + + return { + name, + match: (text: string): MatchData[] => { + const matches: MatchData[] = [] + let regexMatch: RegExpExecArray | null + + // Important: Reset lastIndex on the regex before each new scan. + options.regex.lastIndex = 0 + + while ((regexMatch = options.regex.exec(text)) !== null) { + // This check is necessary to avoid infinite loops with zero-width matches. + if (regexMatch.index === options.regex.lastIndex) { + options.regex.lastIndex++ + } + + matches.push({ + raw: regexMatch[0], + start: regexMatch.index, + end: regexMatch.index + regexMatch[0].length, + data: options.transform(regexMatch) + }) + } + return matches + } + } +} + +/** + * Filters an array of matches to only include those from a specific matcher. + * This is a type-safe alternative to `matches.filter(m => m.matcher === '...')`. + * + * @param matches The array of `Match` objects to filter. + * @param matcherName The name of the matcher to filter by. + * @returns A new array containing only the matches from the specified matcher, + * with the `data` property correctly typed. + */ +export function filterMatchesByMatcher< + M extends Match, + N extends M['matcher'] +>(matches: readonly M[], matcherName: N): Extract[] { + return matches.filter( + (match): match is Extract => + match.matcher === matcherName + ) +} + +/** + * Groups an array of matches by their matcher name. + * + * @param matches An array of `Match` objects, typically from `scan`. + * @returns A record where keys are matcher names and values are arrays of matches + * from that matcher, with each array correctly typed. + */ +export function groupMatchesByMatcher>( + matches: readonly M[] +): Partial<{ [N in M['matcher']]: Extract[] }> { + // We use a less specific type for `grouped` internally to work around a TypeScript + // limitation where it cannot correlate the matcher name (key) with the match + // object's type (value) within the loop. The final cast is safe because the + // logic guarantees the structure of the returned object. + const grouped: Record = {} + + for (const match of matches) { + const key = match.matcher + if (!grouped[key]) { + grouped[key] = [] + } + grouped[key].push(match) + } + + return grouped as unknown as Partial<{ + [N in M['matcher']]: Extract[] + }> +} + +// --- Grapheme segmentation helpers --- +// These utilities provide consistent grapheme cluster boundaries across engines. +// They are intentionally here since they operate purely on strings. +import Graphemer from 'graphemer' + +const graphemeSplitter = new Graphemer() + +export function prevGraphemeStart(text: string, index: number): number { + if (index <= 0) return 0 + let pos = 0 + for (const cluster of graphemeSplitter.iterateGraphemes(text)) { + const next = pos + cluster.length + if (next >= index) return pos + pos = next + } + return pos +} + +export function nextGraphemeEnd(text: string, index: number): number { + if (index >= text.length) return text.length + let pos = 0 + for (const cluster of graphemeSplitter.iterateGraphemes(text)) { + const next = pos + cluster.length + if (index < next) return next + pos = next + } + return text.length +} + +export function snapGraphemeStart(text: string, index: number): number { + if (index <= 0) return 0 + let pos = 0 + for (const cluster of graphemeSplitter.iterateGraphemes(text)) { + const next = pos + cluster.length + if (index < next) return pos + pos = next + } + return pos +} + +export function snapGraphemeEnd(text: string, index: number): number { + if (index >= text.length) return text.length + let pos = 0 + for (const cluster of graphemeSplitter.iterateGraphemes(text)) { + const next = pos + cluster.length + if (index <= pos) return pos + if (index <= next) return next + pos = next + } + return pos +} diff --git a/src/inlay/portal-list.tsx b/src/inlay/portal-list.tsx new file mode 100644 index 0000000..b30d3db --- /dev/null +++ b/src/inlay/portal-list.tsx @@ -0,0 +1,348 @@ +import React, { + createContext, + useCallback, + useContext, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState +} from 'react' + +// --- Portal List Navigation Context --- + +type PortalListContextValue = { + activeIndex: number + setActiveIndex: (index: number) => void + registerItem: (value: T, disabled?: boolean) => number + unregisterItem: (index: number) => void + selectItem: (index: number) => void + loop: boolean +} + +const PortalListContext = createContext(null) + +function usePortalListContext() { + const context = useContext(PortalListContext) + if (!context) { + throw new Error('Portal.Item must be used within Portal.List') + } + return context +} + +// --- Keyboard Handler Context --- +// This context allows the parent Inlay.Root to intercept keyboard events + +export type PortalKeyboardHandler = ( + event: React.KeyboardEvent +) => boolean + +// Import the context from inlay.tsx to avoid circular dependency issues +// The context is created in inlay.tsx and exported +import { PortalKeyboardContext } from './inlay' + +export function usePortalKeyboardContext() { + return useContext(PortalKeyboardContext) +} + +// --- Portal.List Component --- + +export type PortalListProps = { + children: React.ReactNode + onSelect: (item: T, index: number) => void + onDismiss?: () => void + loop?: boolean +} & Omit, 'onSelect'> + +function PortalListInner( + props: PortalListProps, + ref: React.ForwardedRef +) { + const { children, onSelect, onDismiss, loop = true, ...divProps } = props + + const [activeIndex, setActiveIndex] = useState(0) + const itemsRef = useRef>([]) + const [, forceUpdate] = useState(0) + const keyboardContext = usePortalKeyboardContext() + + const registerItem = useCallback((value: T, disabled?: boolean) => { + const index = itemsRef.current.length + itemsRef.current.push({ value, disabled }) + forceUpdate((n) => n + 1) + return index + }, []) + + const unregisterItem = useCallback((index: number) => { + itemsRef.current.splice(index, 1) + forceUpdate((n) => n + 1) + }, []) + + const selectItem = useCallback( + (index: number) => { + const item = itemsRef.current[index] + if (item && !item.disabled) { + onSelect(item.value, index) + } + }, + [onSelect] + ) + + const navigateUp = useCallback(() => { + const items = itemsRef.current + if (items.length === 0) return + + setActiveIndex((current) => { + let nextIndex = current - 1 + if (nextIndex < 0) { + nextIndex = loop ? items.length - 1 : 0 + } + + // Skip disabled items + let attempts = 0 + while (items[nextIndex]?.disabled && attempts < items.length) { + nextIndex = nextIndex - 1 + if (nextIndex < 0) { + nextIndex = loop ? items.length - 1 : 0 + } + attempts++ + } + + return nextIndex + }) + }, [loop]) + + const navigateDown = useCallback(() => { + const items = itemsRef.current + if (items.length === 0) return + + setActiveIndex((current) => { + let nextIndex = current + 1 + if (nextIndex >= items.length) { + nextIndex = loop ? 0 : items.length - 1 + } + + // Skip disabled items + let attempts = 0 + while (items[nextIndex]?.disabled && attempts < items.length) { + nextIndex = nextIndex + 1 + if (nextIndex >= items.length) { + nextIndex = loop ? 0 : items.length - 1 + } + attempts++ + } + + return nextIndex + }) + }, [loop]) + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent): boolean => { + const items = itemsRef.current + + switch (event.key) { + case 'ArrowUp': + event.preventDefault() + navigateUp() + return true + case 'ArrowDown': + event.preventDefault() + navigateDown() + return true + case 'Enter': { + const item = items[activeIndex] + if (item && !item.disabled) { + event.preventDefault() + onSelect(item.value, activeIndex) + return true + } + return false + } + case 'Escape': + event.preventDefault() + onDismiss?.() + return true + default: + return false + } + }, + [activeIndex, navigateUp, navigateDown, onSelect, onDismiss] + ) + + // Register keyboard handler with parent context + useLayoutEffect(() => { + keyboardContext?.setHandler(handleKeyDown) + return () => { + keyboardContext?.setHandler(null) + } + }, [keyboardContext, handleKeyDown]) + + // Reset active index when items change + useEffect(() => { + const items = itemsRef.current + if (activeIndex >= items.length && items.length > 0) { + setActiveIndex(items.length - 1) + } + }, [activeIndex]) + + const contextValue = useMemo( + (): PortalListContextValue => ({ + activeIndex, + setActiveIndex, + registerItem, + unregisterItem, + selectItem, + loop + }), + [activeIndex, registerItem, unregisterItem, selectItem, loop] + ) + + return ( + + { + // Prevent focus loss from editor + e.preventDefault() + divProps.onMouseDown?.(e) + }} + onTouchStart={(e) => { + // Prevent focus loss from editor on touch devices + e.preventDefault() + divProps.onTouchStart?.(e) + }} + > + {children} + + + ) +} + +export const PortalList = React.forwardRef(PortalListInner) as ( + props: PortalListProps & { ref?: React.ForwardedRef } +) => React.ReactElement + +// --- Portal.Item Component --- + +export type PortalItemProps = { + value: T + disabled?: boolean + children: React.ReactNode +} & Omit, 'value'> + +function PortalItemInner( + props: PortalItemProps, + ref: React.ForwardedRef +) { + const { value, disabled = false, children, ...divProps } = props + const context = usePortalListContext() + const [index, setIndex] = useState(-1) + const touchStartPosRef = useRef<{ x: number; y: number } | null>(null) + + // Register this item on mount + useLayoutEffect(() => { + const idx = context.registerItem(value, disabled) + setIndex(idx) + return () => { + context.unregisterItem(idx) + } + }, []) + + const isActive = context.activeIndex === index + + const handleClick = useCallback( + (e: React.MouseEvent) => { + if (!disabled && index >= 0) { + context.selectItem(index) + } + divProps.onClick?.(e) + }, + [disabled, context, divProps, index] + ) + + const handleMouseEnter = useCallback( + (e: React.MouseEvent) => { + if (!disabled && index >= 0) { + context.setActiveIndex(index) + } + divProps.onMouseEnter?.(e) + }, + [disabled, context, divProps, index] + ) + + // Touch handlers for mobile - activate on touch, select on release + const handleTouchStart = useCallback( + (e: React.TouchEvent) => { + const touch = e.touches[0] + if (touch) { + touchStartPosRef.current = { x: touch.clientX, y: touch.clientY } + } + // Activate item on touch start (like hover on desktop) + if (!disabled && index >= 0) { + context.setActiveIndex(index) + } + divProps.onTouchStart?.(e) + }, + [disabled, context, divProps, index] + ) + + const handleTouchEnd = useCallback( + (e: React.TouchEvent) => { + const touch = e.changedTouches[0] + const startPos = touchStartPosRef.current + + // Only select if touch didn't move significantly (not a scroll gesture) + if (touch && startPos) { + const dx = Math.abs(touch.clientX - startPos.x) + const dy = Math.abs(touch.clientY - startPos.y) + + // If movement was minimal, treat as a tap and select + if (dx < 10 && dy < 10) { + if (!disabled && index >= 0) { + // Prevent the subsequent click event + e.preventDefault() + context.selectItem(index) + } + } + } + + touchStartPosRef.current = null + divProps.onTouchEnd?.(e) + }, + [disabled, context, divProps, index] + ) + + return ( + + {children} + + ) +} + +export const PortalItem = React.forwardRef(PortalItemInner) as ( + props: PortalItemProps
- {command} + + + $ {command} + {copied ? ( - - Copied + + Copied ) : ( diff --git a/landing/components/time-slice-example.tsx b/landing/components/time-slice-example.tsx deleted file mode 100644 index eb6f3f9..0000000 --- a/landing/components/time-slice-example.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import React from 'react' -import { TimeSlice } from '@lib' -import type { DateRange } from '@lib/timeslice' -import { ChevronDown } from 'lucide-react' -import { - differenceInYears, - differenceInMonths, - differenceInWeeks, - differenceInDays, - differenceInHours, - differenceInMinutes, - differenceInSeconds -} from 'date-fns' - -export default function TimeSliceExample() { - const [dateRange, setDateRange] = React.useState({ - startDate: new Date(Date.now() - 1000 * 60 * 15), // 15 minutes ago - endDate: new Date() - }) - const [isOpen, setIsOpen] = React.useState(false) - - const onDateRangeChange = (range: DateRange) => { - setDateRange(range) - } - - const getDurationLabel = (start: Date, end: Date) => { - const diffSeconds = differenceInSeconds(end, start) - const diffMinutes = differenceInMinutes(end, start) - const diffHours = differenceInHours(end, start) - const diffDays = differenceInDays(end, start) - const diffWeeks = differenceInWeeks(end, start) - const diffMonths = differenceInMonths(end, start) - const diffYears = differenceInYears(end, start) - - if (diffYears > 0) return `${diffYears}y` - if (diffMonths > 0) return `${diffMonths}mo` - if (diffWeeks > 0) return `${diffWeeks}w` - if (diffDays > 0) return `${diffDays}d` - if (diffHours > 0) return `${diffHours}h` - if (diffMinutes > 0) return `${diffMinutes}m` - - return `${diffSeconds}s` - } - - const activeDurationLabel = React.useMemo(() => { - if (!dateRange.startDate || !dateRange.endDate) return '-' - return getDurationLabel(dateRange.startDate, dateRange.endDate) - }, [dateRange]) - - return ( - - - - - - - {activeDurationLabel} - - - - - - - - - - - - - Quick select - - - - 15 minutes - 15m - - - - - 1 hour - 1h - - - - - 1 day - 1d - - - - - 1 month - 1mo - - - - - ) -} diff --git a/landing/globals.css b/landing/globals.css index 6d51a12..fc41a06 100644 --- a/landing/globals.css +++ b/landing/globals.css @@ -1,86 +1,171 @@ @import 'tailwindcss'; +@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=JetBrains+Mono:wght@400;500&display=swap'); +@theme { + --color-bg: #0A0A0A; + --color-surface: #111111; + --color-surface-light: #1A1A1A; + --color-border: #222222; + --color-border-light: #333333; + + --color-text: #FFFFFF; + --color-text-muted: #888888; + --color-text-dim: #555555; + + --color-magenta: #FF2D92; + --color-cyan: #00F0FF; + --color-lime: #B8FF00; + + --font-sans: 'DM Sans', system-ui, sans-serif; + --font-mono: 'JetBrains Mono', monospace; +} + +* { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + font-family: var(--font-sans); + background-color: var(--color-bg); + color: var(--color-text); +} + +.font-mono { + font-family: var(--font-mono); +} + +/* Selection */ +::selection { + background-color: var(--color-magenta); + color: white; +} + +/* Smooth scrolling */ +html { + scroll-behavior: smooth; +} + +/* Custom scrollbar */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: var(--color-bg); +} + +::-webkit-scrollbar-thumb { + background: var(--color-border-light); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--color-text-dim); +} + +/* Collapsible animations for Radix */ @keyframes collapsible-down { - from { - height: 0; - } - to { - height: var(--radix-collapsible-content-height); - } + from { height: 0; } + to { height: var(--radix-collapsible-content-height); } } @keyframes collapsible-up { - from { - height: var(--radix-collapsible-content-height); - } - to { - height: 0; - } + from { height: var(--radix-collapsible-content-height); } + to { height: 0; } } .animate-collapsible-down { - animation: collapsible-down 300ms ease-out; + animation: collapsible-down 300ms cubic-bezier(0.22, 1, 0.36, 1); } .animate-collapsible-up { - animation: collapsible-up 300ms ease-out; + animation: collapsible-up 300ms cubic-bezier(0.22, 1, 0.36, 1); } -/* Radix UI animations */ +/* Dialog/Portal animations */ @keyframes fade-in { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - -@keyframes fade-out { - from { - opacity: 1; - } - to { - opacity: 0; - } + from { opacity: 0; } + to { opacity: 1; } } @keyframes zoom-in { - from { - transform: scale(0.95); - } - to { - transform: scale(1); - } -} - -@keyframes zoom-out { - from { - transform: scale(1); - } - to { - transform: scale(0.95); - } + from { transform: scale(0.95); opacity: 0; } + to { transform: scale(1); opacity: 1; } } .animate-in { - animation-duration: 150ms; - animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1); - will-change: transform, opacity; + animation-duration: 200ms; + animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1); } .fade-in-0 { animation-name: fade-in; } -.fade-out-0 { - animation-name: fade-out; -} - .zoom-in-95 { animation-name: zoom-in; } -.zoom-out-95 { - animation-name: zoom-out; +/* Pulse for cursor */ +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} + +.animate-pulse { + animation: pulse 1s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + +/* Bounce animation */ +@keyframes bounce { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(25%); } +} + +.animate-bounce { + animation: bounce 1s ease-in-out infinite; +} + +/* Scroll snap utilities */ +.snap-y { + scroll-snap-type: y mandatory; +} + +.snap-mandatory { + scroll-snap-type: y mandatory; +} + +.snap-start { + scroll-snap-align: start; +} + +.snap-always { + scroll-snap-stop: always; +} + +/* Prose styles for documentation */ +.prose { + line-height: 1.75; +} + +.prose p { + margin-bottom: 1rem; +} + +.prose code { + font-family: var(--font-mono); + font-size: 0.875em; + padding: 0.125rem 0.375rem; + border-radius: 0.25rem; + background-color: var(--color-surface-light); +} + +.prose-invert { + color: var(--color-text-muted); +} + +.prose-sm { + font-size: 0.875rem; } diff --git a/landing/package.json b/landing/package.json index 31da8f9..c0f4c47 100644 --- a/landing/package.json +++ b/landing/package.json @@ -14,6 +14,7 @@ "@radix-ui/react-dropdown-menu": "^2.1.14", "@radix-ui/react-tabs": "^1.1.11", "lucide-react": "^0.507.0", + "motion": "^12.26.2", "react": "^19.1.0", "react-dom": "^19.1.0", "vike-react": "^0.6.1" diff --git a/landing/pages/index/+Page.tsx b/landing/pages/index/+Page.tsx index 0468143..cff1f24 100644 --- a/landing/pages/index/+Page.tsx +++ b/landing/pages/index/+Page.tsx @@ -1,570 +1,1526 @@ +import { useRef, useState, useEffect } from 'react' +import { createPortal } from 'react-dom' import { - ArrowRight, Github, Clock, + TextCursorInput, + Copy, + Check, + ArrowUpRight, Sparkles, - Code, - Calendar, MessageSquare, - ChevronUp, - ChevronDown, - ArrowLeftRight, - ExternalLink, - CornerDownRight + Keyboard, + Globe, + Zap, + X, + ChevronDown } from 'lucide-react' +import type { LucideIcon } from 'lucide-react' +import { motion, useScroll, useTransform, AnimatePresence } from 'motion/react' import { ClientOnly } from 'vike-react/ClientOnly' import packageJson from '../../../package.json' -import { - timeSliceBasicExample, - timeSliceImplementationExample -} from '../../components/code-examples' -import * as Collapsible from '@radix-ui/react-collapsible' -export default function Page() { +// ============================================================================ +// Design Tokens +// ============================================================================ + +const colors = { + // Base + bg: '#0A0A0A', + surface: '#111111', + surfaceLight: '#1A1A1A', + border: '#222222', + borderLight: '#333333', + + // Text + text: '#FFFFFF', + textMuted: '#888888', + textDim: '#555555', + + // Neon accents + magenta: '#FF2D92', + cyan: '#00F0FF', + lime: '#B8FF00' +} as const + +// ============================================================================ +// Shared Components +// ============================================================================ + +function GutterText({ + children, + side, + top +}: { + children: React.ReactNode + side: 'left' | 'right' + top?: string +}) { return ( - - {/* Subtle background gradient */} - + + {children} + + ) +} + +function IconBox({ icon: Icon, color }: { icon: LucideIcon; color: string }) { + return ( + + + + ) +} - {/* Animated gradient accents */} +function FeatureIcon({ + icon: Icon, + color +}: { + icon: LucideIcon + color: string +}) { + return ( + + + + ) +} + +function FeatureItem({ + icon, + title, + desc, + color +}: { + icon: LucideIcon + title: string + desc: string + color: string +}) { + return ( + + + + + {title} + + + {desc} + + + + ) +} + +function TagList({ tags, color }: { tags: string[]; color: string }) { + return ( + + {tags.map((tag) => ( + + {tag} + + ))} + + ) +} + +function CopyButton({ + onCopy, + copied, + size = 'sm' +}: { + onCopy: () => void + copied: boolean + size?: 'sm' | 'xs' +}) { + const iconSize = size === 'sm' ? 'w-4 h-4' : 'w-3 h-3' + return ( + { + e.stopPropagation() + onCopy() + }} + className="opacity-40 hover:opacity-100 transition-opacity cursor-pointer" + style={{ color: copied ? colors.lime : 'inherit' }} + > + {copied ? : } + + ) +} + +// ============================================================================ +// Code Preview Component +// ============================================================================ + +// Syntax highlighting as React elements (avoids HTML injection issues) +// Syntax theme - cohesive dark mode with neon accents +const syntaxColors = { + keyword: '#FF6B9D', // soft pink - import, from, const, etc. + tag: '#7DD3FC', // sky blue - JSX tags + tagBracket: '#5EADD5', // slightly darker blue - < > / + attribute: '#C4B5FD', // soft purple - prop names + string: '#A3E635', // lime green - strings + punctuation: '#6B7280', // gray - braces, parens, equals + text: '#E5E5E5', // off-white - default text + comment: '#6B7280' // gray - comments +} + +function SyntaxHighlight({ + code, + multiline = false +}: { + code: string + multiline?: boolean +}) { + type TokenType = + | 'keyword' + | 'string' + | 'tag' + | 'tagBracket' + | 'attribute' + | 'punctuation' + | 'text' + const tokens: Array<{ type: TokenType; value: string }> = [] + + let i = 0 + while (i < code.length) { + // Whitespace + if (/\s/.test(code[i])) { + let ws = '' + while (i < code.length && /\s/.test(code[i])) { + ws += code[i++] + } + tokens.push({ type: 'text', value: ws }) + continue + } + + // JSX tags: + if (code[i] === '<') { + // Opening bracket + tokens.push({ type: 'tagBracket', value: '<' }) + i++ + + // Check for closing slash + if (code[i] === '/') { + tokens.push({ type: 'tagBracket', value: '/' }) + i++ + } + + // Tag name (PascalCase or lowercase html tags) + let tagName = '' + while (i < code.length && /[a-zA-Z0-9.]/.test(code[i])) { + tagName += code[i++] + } + if (tagName) { + tokens.push({ type: 'tag', value: tagName }) + } + continue + } + + // Self-closing or closing bracket + if (code[i] === '/' && code[i + 1] === '>') { + tokens.push({ type: 'tagBracket', value: '/>' }) + i += 2 + continue + } + + if (code[i] === '>') { + tokens.push({ type: 'tagBracket', value: '>' }) + i++ + continue + } + + // Strings + if (code[i] === '"' || code[i] === "'" || code[i] === '`') { + const quote = code[i] + let str = quote + i++ + while (i < code.length && code[i] !== quote) { + str += code[i++] + } + if (i < code.length) str += code[i++] + tokens.push({ type: 'string', value: str }) + continue + } + + // Arrow function (check before punctuation to catch => as a unit) + if (code.slice(i, i + 2) === '=>') { + tokens.push({ type: 'keyword', value: '=>' }) + i += 2 + continue + } + + // Braces and punctuation + if (/[{}()=,;]/.test(code[i])) { + tokens.push({ type: 'punctuation', value: code[i++] }) + continue + } + + // Words (identifiers, keywords) + let word = '' + while (i < code.length && /[a-zA-Z0-9_$.]/.test(code[i])) { + word += code[i++] + } + + if (word) { + const keywords = [ + 'import', + 'from', + 'export', + 'const', + 'let', + 'var', + 'function', + 'return', + 'default', + 'async', + 'await' + ] + if (keywords.includes(word)) { + tokens.push({ type: 'keyword', value: word }) + } else { + // Check if this looks like an attribute (followed by =) + let lookahead = i + while (lookahead < code.length && /\s/.test(code[lookahead])) + lookahead++ + if (code[lookahead] === '=') { + tokens.push({ type: 'attribute', value: word }) + } else { + tokens.push({ type: 'text', value: word }) + } + } + continue + } + + // Fallback: single character + tokens.push({ type: 'text', value: code[i++] }) + } + + const getColor = (type: TokenType) => { + return syntaxColors[type] || syntaxColors.text + } + + const content = tokens.map((token, idx) => ( + + {token.value} + + )) + + return multiline ? ( + {content} + ) : ( + {content} + ) +} + +function CodePreview({ + importStatement, + usageSnippet, + color +}: { + importStatement: string + usageSnippet: string + color: string +}) { + const [copiedImport, setCopiedImport] = useState(false) + const [copiedUsage, setCopiedUsage] = useState(false) + + const handleCopyImport = () => { + navigator.clipboard.writeText(importStatement) + setCopiedImport(true) + setTimeout(() => setCopiedImport(false), 2000) + } + + const handleCopyUsage = () => { + navigator.clipboard.writeText(usageSnippet) + setCopiedUsage(true) + setTimeout(() => setCopiedUsage(false), 2000) + } + + return ( + + {/* Import statement */} + className="group flex items-center justify-between gap-4 px-3 py-2.5 rounded-lg font-mono text-[11px]" + style={{ + backgroundColor: colors.surface, + border: `1px solid ${colors.border}` + }} + > + + + + + + + {/* Usage snippet */} - - {/* Subtle dot pattern */} - - - - {/* Header */} - - - - v{packageJson.version} - + className="group relative px-3 py-3 rounded-lg font-mono text-[11px]" + style={{ + backgroundColor: colors.surface, + border: `1px solid ${color}30` + }} + > + + + + + + + + + ) +} + +// ============================================================================ +// Component Cards +// ============================================================================ + +interface ComponentData { + id: string + index: string + name: string + tagline: string + description: string + icon: LucideIcon + color: string + features: Array<{ icon: LucideIcon; title: string; desc: string }> + tags: string[] + storybookPath: string + importStatement: string + usageSnippet: string + fullExample: string + documentation: string +} - - @bizarre/ - - ui - - +interface ComponentCardProps extends ComponentData { + demoContent: React.ReactNode + demoPosition?: 'left' | 'right' + indexPosition?: 'left' | 'right' +} + +function ComponentCard({ + index, + name, + tagline, + description, + icon, + color, + features, + tags, + storybookPath, + importStatement, + usageSnippet, + demoContent, + demoPosition = 'right', + indexPosition = 'left' +}: ComponentCardProps) { + const ref = useRef(null) + const { scrollYProgress } = useScroll({ + target: ref, + offset: ['start end', 'end start'] + }) + + const y = useTransform(scrollYProgress, [0, 1], [40, -40]) + const scale = useTransform( + scrollYProgress, + [0, 0.3, 0.7, 1], + [0.98, 1, 1, 0.98] + ) - - Headless components nobody asked for + const infoSection = ( + + + + + + {name} + + + {tagline} + + - - - - GitHub - - - Storybook - - - - - + + {description} + - - Wrote these so I could ship weird stuff faster. You can too. - - - - {/* Installation */} - - - - Installation - - - - - - - - - - Terminal + + {features.map((feature) => ( + + ))} + + + + + ) + + const demoSection = ( + + {/* Top section: Storybook link */} + + + Storybook + + + + {/* Middle section: Demo content - vertically centered with equal padding */} + + {demoContent} + + + {/* Bottom section: Code preview */} + + + + + ) + + return ( + + {/* Component index label */} + + {index} + + + + {demoPosition === 'left' ? ( + <> + + {demoSection} + + {infoSection} + + > + ) : ( + <> + {infoSection} + {demoSection} + > + )} + + + ) +} - - import('../../../landing/components/package-manager-tabs') - } - fallback={ - - - - } - > - {(PackageManagerTabs) => } - - - - - {/* Components */} - - - - Components - - - - {/* Component Accordion */} - - {/* TimeSlice Component Accordion */} - - - - - - +// ============================================================================ +// Fullscreen Modal +// ============================================================================ + +interface ComponentModalProps { + isOpen: boolean + onClose: () => void + components: ComponentData[] + activeIndex: number + setActiveIndex: (index: number) => void + demoContents: Record +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function _ComponentModal({ + isOpen, + onClose, + components, + activeIndex, + setActiveIndex, + demoContents +}: ComponentModalProps) { + const scrollContainerRef = useRef(null) + const [copiedInstall, setCopiedInstall] = useState(false) + const [expandedExample, setExpandedExample] = useState(null) + const [copiedCode, setCopiedCode] = useState(false) + + const handleCopyCode = (code: string) => { + navigator.clipboard.writeText(code) + setCopiedCode(true) + setTimeout(() => setCopiedCode(false), 2000) + } + + // Handle ESC key and arrow navigation + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose() + } else if (e.key === 'ArrowDown' || e.key === 'ArrowRight') { + e.preventDefault() + if (activeIndex < components.length - 1) { + setActiveIndex(activeIndex + 1) + } + } else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') { + e.preventDefault() + if (activeIndex > 0) { + setActiveIndex(activeIndex - 1) + } + } + } + + if (isOpen) { + document.addEventListener('keydown', handleKeyDown) + document.body.style.overflow = 'hidden' + } + + return () => { + document.removeEventListener('keydown', handleKeyDown) + document.body.style.overflow = '' + } + }, [isOpen, onClose, activeIndex, setActiveIndex, components.length]) + + // Scroll to active component + useEffect(() => { + if (isOpen && scrollContainerRef.current) { + const sections = scrollContainerRef.current.querySelectorAll( + '[data-component-section]' + ) + sections[activeIndex]?.scrollIntoView({ behavior: 'smooth' }) + } + }, [activeIndex, isOpen]) + + const handleCopyInstall = () => { + navigator.clipboard.writeText('bun add @bizarre/ui') + setCopiedInstall(true) + setTimeout(() => setCopiedInstall(false), 2000) + } + + if (typeof window === 'undefined') return null + + return createPortal( + + {isOpen && ( + + {/* Scrollable content with snap */} + + {components.map((comp, idx) => ( + + {/* Full-width header with component info */} + + + {/* Left: Close + Component info */} + + + + Close + + + + + + + + + + + {comp.name} + + + {comp.tagline} + + + - - - TimeSlice - - - A flexible time range picker with built-in intelligence - + + {/* Right: Navigation + Links */} + + {/* Component navigation */} + + {components.map((c, i) => ( + setActiveIndex(i)} + className="px-2 py-1 text-xs font-mono uppercase tracking-wider rounded transition-all" + style={{ + backgroundColor: + i === activeIndex + ? `${c.color}20` + : 'transparent', + color: + i === activeIndex ? c.color : colors.textMuted, + border: + i === activeIndex + ? `1px solid ${c.color}40` + : '1px solid transparent' + }} + > + {c.name} + + ))} + + + + + + + Storybook + + + + + - - - - - - - - - {/* Subtle component divider */} - - - {/* Compact Features and Use Cases - Above Example */} - - - {/* Features Section */} - - - - - - - Features - - - - - - Keyboard navigation - - - - - Natural language - - - - - Timezone-aware - - - - - Accessible - - - - + {/* Main content area */} + + {/* Component showcase - same layout as cards */} + + + {/* Info side */} + + + {comp.description} + - {/* Perfect For Section */} - - - - - - - Perfect For - - + + {comp.features.map((feature) => ( + + ))} + - - - - Analytics dashboards - - - - - Log explorers - - - - - Data visualization - - - - - Monitoring tools - - - + + {comp.tags.map((tag) => ( + + {tag} + + ))} - - {/* Clean, flat demo card */} - - - {/* Top bar */} - - - - - DEMO - + {/* Demo side */} + + + + {demoContents[comp.id]} - - - import( - '../../components/component-showcase-dialog' - ) - } - fallback={ - - } - > - {(ComponentShowcaseDialog) => ( - - import('../../components/time-slice-example') - } - fallback={} - > - {(TimeSliceExample) => ( - - - View code - - } - /> - )} - - )} - - {/* Demo container with ample space for dropdown visibility */} - - - - import('../../components/time-slice-example') - } - fallback={ - - - + {/* Code preview in demo */} + + + + + navigator.clipboard.writeText( + comp.importStatement + ) } - > - {(TimeSliceExample) => } - + copied={false} + size="xs" + /> - {/* Key Features with visual interest */} - - - {/* Feature 1: Natural Language */} - - {/* Feature header */} - - - - - Natural Language - - - - Smart - + {/* Extended content - two column layout */} + + {/* Left: Code Examples (3 cols) */} + + {/* Header with toggle */} + + + setExpandedExample(null)} + className="px-3 py-1.5 text-xs font-mono uppercase tracking-wider rounded-md transition-all" + style={{ + backgroundColor: + expandedExample !== comp.id + ? colors.bg + : 'transparent', + color: + expandedExample !== comp.id + ? colors.text + : colors.textMuted, + border: + expandedExample !== comp.id + ? `1px solid ${colors.border}` + : '1px solid transparent' + }} + > + Minimal + + setExpandedExample(comp.id)} + className="px-3 py-1.5 text-xs font-mono uppercase tracking-wider rounded-md transition-all" + style={{ + backgroundColor: + expandedExample === comp.id + ? colors.bg + : 'transparent', + color: + expandedExample === comp.id + ? colors.text + : colors.textMuted, + border: + expandedExample === comp.id + ? `1px solid ${colors.border}` + : '1px solid transparent' + }} + > + Full Example + + + handleCopyCode( + expandedExample === comp.id + ? comp.fullExample + : comp.usageSnippet + ) + } + copied={copiedCode} + size="sm" + /> + - {/* Feature content */} - - - - - - - " - - last 2 weeks - - " - - - - - - - - - - {new Date( - Date.now() - 14 * 24 * 60 * 60 * 1000 - ).toLocaleDateString()}{' '} - - {new Date().toLocaleDateString()} - - - - - - - - - - " - - yesterday to tomorrow - - " - - - - - - - - - - {new Date( - Date.now() - 24 * 60 * 60 * 1000 - ).toLocaleDateString()}{' '} - -{' '} - {new Date( - Date.now() + 24 * 60 * 60 * 1000 - ).toLocaleDateString()} - - - - + {/* Code block with file indicator */} + + {/* File tab */} + + + + + - - - Parse natural language expressions using{' '} - - chrono-node - {' '} - for intuitive input. - + + {expandedExample === comp.id + ? `${comp.id}-example.tsx` + : 'usage.tsx'} + + + {/* Code content */} + + - {/* Feature 2: Keyboard Navigation */} - - {/* Feature header */} - - - - - - - - Keyboard Navigation - - - - Accessible - + {/* Install command */} + + + Install + + + + ${' '} + + bun add @bizarre/ui + + + + + - {/* Feature content */} - - - - - - 2023- - - - 06 - - - -12 - - - - - - - - - - - Arrow keys - - - - - - - ↑↓ - - - - Modify values - - - - - - - Tab - - - - Jump dates - - - - + {/* Right: About (2 cols) */} + + + About {comp.name} + + + {comp.documentation} + - - Edit day, month, year, hour, and minute segments - with intuitive keyboard shortcuts. - - + {/* Links */} + + + Storybook + + + Source + - - - - - {/* - - - - - - - - - Future Component - - - Description of the future component goes here - - - - - - - - - - - - - - - - - More components coming soon - + + {/* Next component indicator */} + {idx < components.length - 1 && ( + + setActiveIndex(idx + 1)} + className="inline-flex flex-col items-center gap-2 text-xs font-mono uppercase tracking-wider transition-colors hover:opacity-70" + style={{ color: colors.textDim }} + > + Next: {components[idx + 1].name} + + - + )} - - */} - - - - {/* Footer */} - +// ============================================================================ +// Demo Contents +// ============================================================================ + +function InlayDemo() { + return ( + import('../../components/inlay-example')} + fallback={ + + + + } + > + {(InlayExample) => } + + ) +} + +function ChronoDemo() { + return ( + import('../../components/chrono-example')} + fallback={ + + + + } + > + {(ChronoExample) => } + + ) +} + +// ============================================================================ +// Component Data +// ============================================================================ + +const componentsData: ComponentData[] = [ + { + id: 'inlay', + index: '01 / INLAY', + name: 'Inlay', + tagline: 'Structured text input', + description: + 'A composable input primitive for building rich text experiences with tokens, mentions, search filters, and more. Fully headless, fully accessible.', + icon: TextCursorInput, + color: colors.lime, + features: [ + { + icon: Sparkles, + title: 'Token rendering', + desc: 'React components as tokens' + }, + { + icon: MessageSquare, + title: 'Mentions', + desc: '@mention support built-in' + }, + { icon: Keyboard, title: 'Native UX', desc: 'Feels like a real input' }, + { icon: Globe, title: 'Accessible', desc: 'WCAG 2.1 compliant' } + ], + tags: ['Mentions', 'Search filters', 'AI inputs', 'Tags'], + storybookPath: '/storybook/?path=/story/inlay', + importStatement: `import { Inlay } from '@bizarre/ui'`, + usageSnippet: ` + + + {tokens.map(t => )} + +`, + fullExample: `import { useState } from 'react' +import { Inlay } from '@bizarre/ui' + +function MentionInput() { + const [tokens, setTokens] = useState([]) + const [value, setValue] = useState('') + + const handleMention = (user) => { + setTokens([...tokens, { + id: user.id, + type: 'mention', + label: user.name + }]) + } + + return ( + + + + {tokens.map(token => ( + removeToken(token.id)} + > + @{token.label} + + ))} + + + + + + ) +}`, + documentation: + 'Inlay is a headless, composable input component designed for building rich text experiences. It handles the complex state management of tokens, cursor position, selection, and keyboard navigation while giving you complete control over the visual presentation. Perfect for building mention systems, tag inputs, search filters with structured tokens, or AI chat interfaces with inline components.' + }, + { + id: 'chrono', + index: '02 / CHRONO', + name: 'Chrono', + tagline: 'Intelligent time picker', + description: + 'A time range picker that understands natural language. Type "last 2 weeks" or "yesterday to tomorrow" and watch it parse your intent.', + icon: Clock, + color: colors.cyan, + features: [ + { + icon: MessageSquare, + title: 'Natural language', + desc: 'Powered by chrono-node' + }, + { icon: Keyboard, title: 'Keyboard nav', desc: 'Arrow keys to modify' }, + { icon: Globe, title: 'Timezone aware', desc: 'Handles TZ correctly' }, + { icon: Zap, title: 'Quick shortcuts', desc: '15m, 1h, 1d presets' } + ], + tags: ['Analytics', 'Logs', 'Dashboards', 'Monitoring'], + storybookPath: '/storybook/?path=/story/chrono', + importStatement: `import { Chrono } from '@bizarre/ui'`, + usageSnippet: ` + + + + + + +`, + fullExample: `import { useState } from 'react' +import { Chrono } from '@bizarre/ui' + +function LogsFilter() { + const [dateRange, setDateRange] = useState({ + startDate: new Date(Date.now() - 1000 * 60 * 15), + endDate: new Date() + }) + + return ( + + + + + + + + + + + Last 15 minutes + + + Last hour + + + Last 24 hours + + + Last week + + + + + ) +}`, + documentation: + 'Chrono is a time range picker component with natural language parsing capabilities. It uses chrono-node under the hood to parse human-readable time expressions like "last 2 weeks", "yesterday", or "past 30 minutes". The component supports keyboard navigation for quick adjustments, timezone-aware date handling, and customizable shortcut presets. Ideal for analytics dashboards, log viewers, monitoring tools, or any application that needs flexible time range selection.' + } +] + +const demoContents: Record = { + inlay: , + chrono: +} + +// ============================================================================ +// Layout Components +// ============================================================================ + +function Header() { + return ( + + + + + bizarre/ui + + + v{packageJson.version} + + + + + + Storybook + + + + Source + + + + + ) +} + +function SectionLabel({ children }: { children: React.ReactNode }) { + return ( + + + + {children} + + + + ) +} + +function InstallCTA() { + const [copied, setCopied] = useState(false) + const command = 'bun add @bizarre/ui' + + const handleCopy = () => { + navigator.clipboard.writeText(command) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + return ( + + + + Install + + + $ + {command} + + + + + github.com/bizarre/ui + + + + + ) +} + +function Footer() { + return ( + + ) +} + +// ============================================================================ +// Page +// ============================================================================ + +export default function Page() { + return ( + + {/* Fixed gutter elements */} + + @bizarre/ui · v{packageJson.version} + + + + Headless · Accessible · Composable + + + + + Components + + {/* Component Cards */} + + + + + + + + + ) } diff --git a/landing/pages/v2/+Page.tsx b/landing/pages/v2/+Page.tsx new file mode 100644 index 0000000..ce437e9 --- /dev/null +++ b/landing/pages/v2/+Page.tsx @@ -0,0 +1,1166 @@ +import type React from 'react' +import { useEffect, useState, useRef } from 'react' +import { + Github, + ExternalLink, + Clock, + TextCursorInput, + Copy as CopyIcon, + Check as CheckIcon, + Link as LinkIcon +} from 'lucide-react' +import packageJson from '../../../package.json' + +function BackgroundGrid({ visible }: { visible: boolean }) { + return ( + + ) +} + +function Kbd({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +function Divider() { + return +} + +type Accent = 'emerald' | 'violet' | 'zinc' + +function accentBarClass(accent?: Accent) { + switch (accent) { + case 'emerald': + return 'from-emerald-400/60 to-teal-400/60' + case 'violet': + return 'from-violet-500/60 to-blue-500/60' + default: + return 'from-zinc-300 to-zinc-300' + } +} + +function Panel({ + title, + subtitle, + icon, + tag, + accent, + children +}: { + title: string + subtitle?: string + icon?: React.ReactNode + tag?: string + accent?: Accent + children?: React.ReactNode +}) { + return ( + + + + + {icon ? ( + + {icon} + + ) : null} + + + {title} + + {subtitle ? ( + + {subtitle} + + ) : null} + + + {tag ? ( + + {tag} + + ) : null} + + {children} + + ) +} + +function Terminal({ + lines, + label = 'shell', + showCaret = true +}: { + lines: string[] + label?: string + showCaret?: boolean +}) { + return ( + + + + + + + + {label} + + + {lines.map((l, i) => { + const isLast = i === lines.length - 1 + return ( + + $ + {l} + {showCaret && isLast ? ( + + ) : null} + + ) + })} + + + ) +} + +function Placeholder({ + label, + height = 420 +}: { + label: string + height?: number +}) { + return ( + + {label} + + ) +} + +function AnnotatedPlaceholder({ + label, + height = 420, + markers +}: { + label: string + height?: number + markers: Array<{ xPercent: number; yPercent: number; label: string }> +}) { + return ( + + + {markers.map((m, i) => ( + + {i + 1} + + ))} + + ) +} + +function Code({ code }: { code: string }) { + const [copied, setCopied] = useState(false) + const timeoutRef = useRef(null) + useEffect( + () => () => { + if (timeoutRef.current) window.clearTimeout(timeoutRef.current) + }, + [] + ) + return ( + + { + navigator.clipboard.writeText(code) + setCopied(true) + timeoutRef.current = window.setTimeout(() => setCopied(false), 1200) + }} + className="absolute top-2 right-2 inline-flex items-center gap-1 text-[11px] h-7 px-2 border border-zinc-200 rounded-[2px] bg-white hover:bg-zinc-50 transition-colors" + aria-label="Copy code" + > + {copied ? ( + + ) : ( + + )} + {copied ? 'Copied' : 'Copy'} + + + {code} + + + ) +} + +function InlineCodeCopy({ text }: { text: string }) { + const [copied, setCopied] = useState(false) + const timeoutRef = useRef(null) + useEffect( + () => () => { + if (timeoutRef.current) window.clearTimeout(timeoutRef.current) + }, + [] + ) + return ( + + + {text} + + { + navigator.clipboard.writeText(text) + setCopied(true) + timeoutRef.current = window.setTimeout(() => setCopied(false), 1200) + }} + className="inline-flex items-center gap-1 text-[11px] h-7 px-2 border border-zinc-200 rounded-[2px] bg-white hover:bg-zinc-50 transition-colors" + aria-label="Copy" + > + {copied ? ( + + ) : ( + + )} + {copied ? 'Copied' : 'Copy'} + + + ) +} + +function SectionHeading({ + id, + index, + icon, + title, + badge +}: { + id: string + index: string + icon: React.ReactNode + title: string + badge: React.ReactNode +}) { + const [copied, setCopied] = useState(false) + return ( + + + + {index} + + {icon} + {title} + { + const url = `${window.location.origin}${window.location.pathname}#${id}` + navigator.clipboard.writeText(url) + setCopied(true) + setTimeout(() => setCopied(false), 1200) + }} + className="opacity-0 group-hover:opacity-100 transition-opacity inline-flex items-center h-6 px-1 text-[11px] border border-zinc-200 rounded-[2px] ml-1" + aria-label="Copy link" + > + {copied ? ( + + ) : ( + + )} + + + {badge} + + ) +} + +function Figure({ + number, + title, + children +}: { + number: number + title: string + children: React.ReactNode +}) { + return ( + + {children} + + Figure {number}. {title} + + + ) +} + +function ApiTable({ + rows +}: { + rows: Array<{ name: string; type: string; def?: string; desc: string }> +}) { + return ( + + + Prop + Type + Default + Description + + + {rows.map((r) => ( + + + {r.name} + navigator.clipboard.writeText(r.name)} + className="inline-flex items-center h-6 px-1 border border-zinc-200 rounded-[2px]" + aria-label="Copy prop" + > + + + + + + {r.type} + + + + {r.def ?? '—'} + + {r.desc} + + ))} + + + ) +} + +function InfoTable({ + rows +}: { + rows: Array<{ label: string; value: React.ReactNode }> +}) { + return ( + + {rows.map((r, i) => ( + + + {r.label} + + {r.value} + + ))} + + ) +} + +function AnchorRail({ activeId }: { activeId: string }) { + return ( + + + Overview + + + Inlay + + + Chrono + + + ) +} + +function useActiveSection(ids: string[]) { + const [activeId, setActiveId] = useState(ids[0] || '') + useEffect(() => { + const observers: IntersectionObserver[] = [] + ids.forEach((id) => { + const el = document.getElementById(id) + if (!el) return + const obs = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) setActiveId(id) + }) + }, + { rootMargin: '-30% 0px -60% 0px', threshold: [0, 0.2, 0.5, 1] } + ) + obs.observe(el) + observers.push(obs) + }) + return () => observers.forEach((o) => o.disconnect()) + }, [ids]) + return activeId +} + +function useScrollProgress() { + const [progress, setProgress] = useState(0) + useEffect(() => { + const handler = () => { + const scrollTop = + document.documentElement.scrollTop || document.body.scrollTop + const scrollHeight = + document.documentElement.scrollHeight - + document.documentElement.clientHeight + const p = scrollHeight > 0 ? scrollTop / scrollHeight : 0 + setProgress(p) + } + handler() + window.addEventListener('scroll', handler, { passive: true }) + return () => window.removeEventListener('scroll', handler) + }, []) + return progress +} + +function ApiEventsTabs({ + propsRows, + eventRows, + accent +}: { + propsRows: Array<{ name: string; type: string; def?: string; desc: string }> + eventRows: Array<{ name: string; type: string; def?: string; desc: string }> + accent: 'emerald' | 'violet' | 'zinc' +}) { + const [tab, setTab] = useState<'props' | 'events'>('props') + const accentBorder = + accent === 'emerald' + ? 'border-emerald-300' + : accent === 'violet' + ? 'border-violet-300' + : 'border-zinc-300' + return ( + + + setTab('props')} + className={`text-[11px] h-7 px-2 rounded-[2px] border ${tab === 'props' ? accentBorder + ' bg-zinc-50' : 'border-zinc-200 hover:bg-zinc-50'}`} + > + Props + + setTab('events')} + className={`text-[11px] h-7 px-2 rounded-[2px] border ${tab === 'events' ? accentBorder + ' bg-zinc-50' : 'border-zinc-200 hover:bg-zinc-50'}`} + > + Events + + + + ({ + ...r, + type: r.type + }))} + /> + + + ) +} + +function SpecRail({ + sections +}: { + sections: Array<{ + title: string + rows: Array<{ label: string; value: React.ReactNode }> + }> +}) { + return ( + + {sections.map((s, i) => ( + + + {s.title} + + + + ))} + + ) +} + +export default function Page() { + const activeId = useActiveSection(['overview', 'inlay', 'chrono']) + const progress = useScrollProgress() + const [showGrid, setShowGrid] = useState(false) + return ( + + + + + + + + + + @bizarre/ui + + + v{packageJson.version} + + + + + {activeId === 'overview' + ? 'Overview' + : activeId === 'inlay' + ? 'Inlay' + : 'Chrono'} + + setShowGrid((v) => !v)} + className={`inline-flex items-center gap-1 text-[11px] h-8 px-2 border rounded-[2px] ${showGrid ? 'border-zinc-400 bg-zinc-50' : 'border-zinc-200'} hover:bg-zinc-50`} + > + Grid + + + Storybook + + + + + GitHub + + + + + + + + + + + + + + 00 + + + @bizarre/ui + + + + Focused building blocks for edge‑case UX. + + + Two modules, designed for speed and clarity. + + + + + + Storybook + + + + + GitHub + + + + + + + + Version + + v{packageJson.version} + + + + Stack + + React + Vike · Bun + + + + License + + {(packageJson as { license?: string }).license ?? '—'} + + + + + + + + + + + {activeId !== 'overview' ? ( + + ) : null} + + + + {/* Vertical spine linking heading to spec rail */} + + } + title="Inlay" + badge={ + + Component + + } + /> + + + {/* Left lane: Exhibit (A), Mechanics (B), Interfaces (C) */} + + {/* Band A: Exhibit */} + + + + + + + + (1) Segment selection and movement + (2) Token insertion and suggestions + (3) Cursor position + + + {/* Band B: Mechanics drawer */} + + + Interaction map + + + + Key + + + Scope + + + Effect + + + → + + Segment + + Move to next segment + + + ← + + Segment + + Move to previous segment + + + Tab + + Global + + Jump to next focusable + + + + {/* Band C: Interfaces (API/Events tabs) */} + void', + desc: 'Change handler' + }, + { + name: 'tokens', + type: 'Array', + desc: 'Available token components' + }, + { + name: 'placeholder', + type: 'string', + def: '""', + desc: 'Input placeholder' + } + ]} + eventRows={[ + { + name: 'onTokenAdd', + type: '(token: Token) => void', + desc: 'When a token is created' + }, + { + name: 'onTokenRemove', + type: '(token: Token) => void', + desc: 'When a token is removed' + } + ]} + /> + + {/* Right lane: continuous Spec Rail */} + + + textbox + + ) + }, + { label: 'Name', value: 'Label or aria-label' }, + { + label: 'Keyboard', + value: 'Arrow navigation, Tab jump' + } + ] + }, + { + title: 'Performance', + rows: [ + { label: 'Bundle target', value: '< 8KB gz' }, + { label: 'Interaction', value: '< 16ms key handling' } + ] + }, + { + title: 'Links', + rows: [ + { + label: 'Storybook', + value: ( + + Open + + ) + }, + { + label: 'Repository', + value: ( + + GitHub + + ) + } + ] + } + ]} + /> + + + + + + + } + title="Chrono" + badge={ + + Component + + } + /> + + + + + + + + + + + (1) Start date field + (2) End date field + (3) Preset selector + + + + + Interaction map + + + + Key + + + Scope + + + Effect + + + →/← + + Field + + Navigate fields + + + ↑/↓ + + Field + Adjust values + + Enter + + Global + + Confirm selection + + + + void', + desc: 'Change handler' + }, + { + name: 'presets', + type: 'Array', + desc: 'Available presets' + }, + { + name: 'timezone', + type: 'string', + def: 'local', + desc: 'Display timezone' + } + ]} + eventRows={[ + { + name: 'onPresetSelect', + type: '(name: string) => void', + desc: 'When a preset is chosen' + }, + { + name: 'onInputFocus', + type: '(field: "from" | "to") => void', + desc: 'When a field receives focus' + } + ]} + /> + + + + group + + ) + }, + { label: 'Name', value: 'Form labels for fields' }, + { + label: 'Keyboard', + value: 'Arrow/Tab navigation across segments' + } + ] + }, + { + title: 'Performance', + rows: [ + { label: 'Bundle target', value: '< 10KB gz' }, + { label: 'Interaction', value: '< 16ms key handling' } + ] + }, + { + title: 'Links', + rows: [ + { + label: 'Storybook', + value: ( + + Open + + ) + }, + { + label: 'Repository', + value: ( + + GitHub + + ) + } + ] + } + ]} + /> + + + + + + + + + + + + Import + + + + + + Links + + + + Storybook + + + + GitHub + + + + + + + + + + + + + + + ) +} diff --git a/landing/pages/v2/+config.ts b/landing/pages/v2/+config.ts new file mode 100644 index 0000000..63de0fa --- /dev/null +++ b/landing/pages/v2/+config.ts @@ -0,0 +1,3 @@ +export default { + prerender: true +} diff --git a/landing/renderer/+onRenderHtml.tsx b/landing/renderer/+onRenderHtml.tsx index 767ce53..b3a178b 100644 --- a/landing/renderer/+onRenderHtml.tsx +++ b/landing/renderer/+onRenderHtml.tsx @@ -5,6 +5,7 @@ import type { PageContextServer } from 'vike/types' import type { ComponentType } from 'react' export function onRenderHtml(pageContext: PageContextServer) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any const Page = pageContext.Page as ComponentType const { pageProps } = pageContext as PageContextServer & { @@ -27,7 +28,7 @@ export function onRenderHtml(pageContext: PageContextServer) { @bizarre/ui - + ${dangerouslySkipEscape(pageHtml)} ` diff --git a/landing/tailwind.config.ts b/landing/tailwind.config.ts index 88fbb88..3f5844a 100644 --- a/landing/tailwind.config.ts +++ b/landing/tailwind.config.ts @@ -1,4 +1,3 @@ -// landing/tailwind.config.ts import type { Config } from 'tailwindcss' export default { @@ -6,10 +5,56 @@ export default { './pages/**/*.{js,ts,jsx,tsx}', './renderer/**/*.{js,ts,jsx,tsx}', './components/**/*.{js,ts,jsx,tsx}' - // Add other paths to your components if needed ], theme: { - extend: {} + extend: { + colors: { + cream: { + DEFAULT: '#FAF7F2', + dark: '#F0EBE3', + darker: '#E5DFD5' + }, + ink: { + DEFAULT: '#1a1816', + light: '#3d3835', + muted: '#6b6460', + faint: '#a39d98' + }, + coral: { + DEFAULT: '#E85D4C', + dark: '#C94D3E', + light: '#F18B7E' + }, + navy: { + DEFAULT: '#1E3A5F', + light: '#2D5A8A', + dark: '#152A45' + }, + sage: { + DEFAULT: '#7D9F8E', + light: '#9BB8A9', + dark: '#5F7D6C' + }, + amber: { + DEFAULT: '#D4A853', + light: '#E5C47A', + dark: '#B88F3D' + } + }, + fontFamily: { + serif: ['Instrument Serif', 'Georgia', 'serif'], + sans: ['DM Sans', 'system-ui', 'sans-serif'], + mono: ['JetBrains Mono', 'monospace'] + }, + animation: { + 'fade-up': 'fade-up 0.6s cubic-bezier(0.22, 1, 0.36, 1) forwards', + 'fade-in': 'fade-in 0.5s ease-out forwards', + 'slide-in': + 'slide-in-right 0.5s cubic-bezier(0.22, 1, 0.36, 1) forwards', + float: 'float 3s ease-in-out infinite', + 'pulse-soft': 'pulse-soft 2s ease-in-out infinite' + } + } }, plugins: [] } satisfies Config diff --git a/package.json b/package.json index 9f84fc7..ed093c8 100644 --- a/package.json +++ b/package.json @@ -17,16 +17,21 @@ "types": "./dist/index.d.ts", "require": "./dist/index/index.cjs.js" }, - "./timeslice": { - "import": "./dist/timeslice/index.es.js", - "types": "./dist/timeslice.d.ts", - "require": "./dist/timeslice/index.cjs.js" + "./chrono": { + "import": "./dist/chrono/index.es.js", + "types": "./dist/chrono.d.ts", + "require": "./dist/chrono/index.cjs.js" + }, + "./inlay": { + "import": "./dist/inlay/index.es.js", + "types": "./dist/inlay.d.ts", + "require": "./dist/inlay/index.cjs.js" } }, "typesVersions": { "*": { - "timeslice": [ - "./dist/timeslice.d.ts" + "chrono": [ + "./dist/chrono.d.ts" ] } }, @@ -53,7 +58,7 @@ "license": "MIT", "scripts": { "build": "vite build --config vite.lib.config.ts", - "storybook": "storybook dev -p 6006", + "storybook": "storybook dev -p 6006 --host 0.0.0.0", "build-storybook": "storybook build", "typecheck": "tsc --emitDeclarationOnly", "lint": "eslint ./src --ext .ts,.tsx", @@ -61,24 +66,34 @@ "test": "vitest", "test:run": "vitest run", "test:ui": "vitest --ui", - "lint-staged": "lint-staged" + "lint-staged": "lint-staged", + "test:ct": "playwright test -c playwright-ct.config.mts", + "test:ct:ui": "playwright test -c playwright-ct.config.mts --ui", + "playwright:install": "playwright install --with-deps", + "test-ct": "playwright test -c playwright-ct.config.mts" }, "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" + "react": "^19.0.0", + "react-dom": "^19.0.0" }, "dependencies": { "@formatjs/intl-datetimeformat": "^6.18.0", "@radix-ui/react-compose-refs": "^1.1.2", "@radix-ui/react-context": "^1.1.2", "@radix-ui/react-dismissable-layer": "^1.1.9", - "@radix-ui/react-slot": "^1.2.2", + "@radix-ui/react-popover": "^1.1.14", + "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-use-controllable-state": "^1.2.2", "chrono-node": "^2.8.0", "date-fns": "^4.1.0", + "graphemer": "^1.4.0", "timezone-enum": "^1.0.4" }, "devDependencies": { + "@axe-core/playwright": "^4.11.0", + "@heroicons/react": "^2.2.0", + "@playwright/experimental-ct-react": "^1.54.2", + "@playwright/test": "^1.54.2", "@semantic-release/changelog": "^6.0.3", "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/git": "^10.0.1", @@ -93,11 +108,12 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^13.5.0", + "@types/node": "^24.2.1", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", "@typescript-eslint/eslint-plugin": "^8.32.0", "@typescript-eslint/parser": "^8.32.0", - "@vitejs/plugin-react-swc": "^3.9.0", + "@vitejs/plugin-react-swc": "^4.0.0", "@vitest/ui": "^3.1.3", "ajv": "^8.17.1", "autoprefixer": "^10.4.21", @@ -117,6 +133,7 @@ "storybook": "^8.6.12", "tailwindcss": "^4.1.5", "typescript": "^5.3.3", + "typescript-eslint": "^8.39.1", "vite": "^6.3.5", "vite-plugin-dts": "^4.5.3", "vitest": "^3.1.3" diff --git a/playwright-ct.config.mts b/playwright-ct.config.mts new file mode 100644 index 0000000..af56f0f --- /dev/null +++ b/playwright-ct.config.mts @@ -0,0 +1,64 @@ +import { defineConfig, devices } from '@playwright/experimental-ct-react' +import react from '@vitejs/plugin-react-swc' + +export default defineConfig({ + testDir: './', + testMatch: /.*\.spec\.tsx?$/, + snapshotDir: './__snapshots__', + timeout: 10 * 1000, + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + + reporter: 'list', // never open the HTML reporter + + use: { + trace: 'on-first-retry', + ctPort: 3100, + + ctViteConfig: { + plugins: [react()] + } + }, + + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + permissions: ['clipboard-read', 'clipboard-write'] + } + }, + { + name: 'firefox', + use: { + ...devices['Desktop Firefox'], + launchOptions: { + firefoxUserPrefs: { + // Enable clipboard testing in Firefox + 'dom.events.testing.asyncClipboard': true, + 'dom.events.asyncClipboard.readText': true, + 'dom.events.asyncClipboard.clipboardItem': true + } + } + } + }, + { + name: 'webkit', + use: { ...devices['Desktop Safari'] } + }, + // Mobile device projects for touch/mobile testing + { + name: 'mobile-chrome', + use: { + ...devices['Pixel 5'], + permissions: ['clipboard-read', 'clipboard-write'] + } + }, + { + name: 'mobile-safari', + use: { ...devices['iPhone 14'] } + } + ] +}) diff --git a/playwright/index.html b/playwright/index.html new file mode 100644 index 0000000..610ddf8 --- /dev/null +++ b/playwright/index.html @@ -0,0 +1,12 @@ + + + + + + Testing Page + + + + + + diff --git a/playwright/index.tsx b/playwright/index.tsx new file mode 100644 index 0000000..ac6de14 --- /dev/null +++ b/playwright/index.tsx @@ -0,0 +1,2 @@ +// Import styles, initialize component theme here. +// import '../src/common.css'; diff --git a/src/timeslice/time-slice.stories.tsx b/src/chrono/chrono.stories.tsx similarity index 53% rename from src/timeslice/time-slice.stories.tsx rename to src/chrono/chrono.stories.tsx index 8c9df43..97ae3fe 100644 --- a/src/timeslice/time-slice.stories.tsx +++ b/src/chrono/chrono.stories.tsx @@ -1,57 +1,52 @@ import type { Meta } from '@storybook/react' -import { TimeSlice } from '..' -import type { TimeSliceProps } from '.' +import { Chrono } from '..' +import type { ChronoProps } from '.' import * as React from 'react' -const meta: Meta = { - component: TimeSlice.Root +const meta: Meta = { + component: Chrono.Root } export default meta export const Basic = () => { - const [dateRange, setDateRange] = React.useState( - { - startDate: undefined, - endDate: undefined - } - ) + const [dateRange, setDateRange] = React.useState({ + startDate: undefined, + endDate: undefined + }) - const onDateRangeChange = (dateRange: TimeSliceProps['dateRange']) => { + const onDateRangeChange = (dateRange: ChronoProps['dateRange']) => { setDateRange(dateRange) } return ( <> - - - + + - + 15 minutes - - + 1 hour - - + + 1 day - - + 1 year - - - + + + {JSON.stringify(dateRange, null, 2)} > @@ -59,47 +54,42 @@ export const Basic = () => { } export const Absolute = () => { - const [dateRange, setDateRange] = React.useState( - { - startDate: undefined, - endDate: undefined - } - ) + const [dateRange, setDateRange] = React.useState({ + startDate: undefined, + endDate: undefined + }) - const onDateRangeChange = (dateRange: TimeSliceProps['dateRange']) => { + const onDateRangeChange = (dateRange: ChronoProps['dateRange']) => { setDateRange(dateRange) } return ( <> - - - + + - + 15 minutes - - + 1 hour - - + + 1 day - - + 1 year - - - + + + {JSON.stringify(dateRange, null, 2)} > @@ -107,31 +97,29 @@ export const Absolute = () => { } export const WithFutureShortcuts = () => { - const [dateRange, setDateRange] = React.useState( - { - startDate: undefined, - endDate: undefined - } - ) + const [dateRange, setDateRange] = React.useState({ + startDate: undefined, + endDate: undefined + }) return ( <> - - - + + - + 15 minutes - - + Next hour - - - + + + {JSON.stringify(dateRange, null, 2)} > @@ -139,17 +127,15 @@ export const WithFutureShortcuts = () => { } export const Controlled = () => { - const [dateRange, setDateRange] = React.useState( - { - startDate: undefined, - endDate: undefined - } - ) + const [dateRange, setDateRange] = React.useState({ + startDate: undefined, + endDate: undefined + }) return ( <> Prevents future dates via controlled state - { // prevent future dates if (startDate && endDate && endDate > new Date()) { @@ -160,33 +146,30 @@ export const Controlled = () => { }} dateRange={dateRange} > - - + - + 15 minutes - - + 1 hour - - + + 1 day - - + 1 year - - - + + + {JSON.stringify(dateRange, null, 2)} > @@ -194,14 +177,12 @@ export const Controlled = () => { } export const DataDog = () => { - const [dateRange, setDateRange] = React.useState( - { - startDate: new Date(Date.now() - 1000 * 60 * 5), - endDate: new Date() - } - ) + const [dateRange, setDateRange] = React.useState({ + startDate: new Date(Date.now() - 1000 * 60 * 5), + endDate: new Date() + }) - const onDateRangeChange = (dateRange: TimeSliceProps['dateRange']) => { + const onDateRangeChange = (dateRange: ChronoProps['dateRange']) => { setDateRange(dateRange) } @@ -236,11 +217,11 @@ export const DataDog = () => { return ( - - + UTC-04:00 @@ -252,24 +233,24 @@ export const DataDog = () => { - + - - - + + + 15 minutes - - + + 1 hour - - + + 1 day - - + + 1 year - - - + + + ) } diff --git a/src/timeslice/time-slice.test.tsx b/src/chrono/chrono.test.tsx similarity index 92% rename from src/timeslice/time-slice.test.tsx rename to src/chrono/chrono.test.tsx index 95a31b7..0406baa 100644 --- a/src/timeslice/time-slice.test.tsx +++ b/src/chrono/chrono.test.tsx @@ -1,25 +1,19 @@ import React from 'react' import { render, screen, fireEvent } from '@testing-library/react' -import { - Root as TimeSlice, - Input, - Portal, - Trigger, - Shortcut -} from './time-slice' +import { Root as Chrono, Input, Portal, Trigger, Shortcut } from './chrono' import '@testing-library/jest-dom' import { vi } from 'vitest' -describe('TimeSlice Component Family', () => { - describe('TimeSlice (Root)', () => { +describe('Chrono Component Family', () => { + describe('Chrono (Root)', () => { it('should render without crashing with minimal props', () => { render( - + Portal Content - + ) expect(screen.getByRole('combobox')).toBeInTheDocument() @@ -27,12 +21,12 @@ describe('TimeSlice Component Family', () => { it('should be closed by default', () => { render( - + Portal Content - + ) expect(screen.getByTestId('portal-closed-default')).toHaveStyle({ display: 'none' @@ -41,12 +35,12 @@ describe('TimeSlice Component Family', () => { it('should respect defaultOpen prop', () => { render( - + Portal Content - + ) expect(screen.getByText('Portal Content')).toBeVisible() }) @@ -55,7 +49,7 @@ describe('TimeSlice Component Family', () => { const handleOpenChange = vi.fn() const mockOnDateRangeChange = vi.fn() const { rerender } = render( - { Portal Content - + ) expect(screen.getByTestId('portal-controlled-open')).toHaveStyle({ display: 'none' }) rerender( - { Portal Content - + ) expect(screen.getByTestId('portal-controlled-open')).not.toHaveStyle({ display: 'none' @@ -93,13 +87,13 @@ describe('TimeSlice Component Family', () => { const endDate = new Date('2024-01-01T01:00:00Z') const handleDateRangeChange = vi.fn() render( - - + ) expect(screen.getByRole('combobox')).toHaveValue( @@ -114,7 +108,7 @@ describe('TimeSlice Component Family', () => { const mockOnOpenChange = vi.fn() const { rerender } = render( - { Portal - + ) expect(screen.getByRole('combobox')).toHaveValue( 'Feb 10, 10:00\u202FAM – Feb 10, 12:00\u202FPM' ) rerender( - { Portal - + ) expect(handleDateRangeConfirm).toHaveBeenCalledWith({ startDate, @@ -154,12 +148,12 @@ describe('TimeSlice Component Family', () => { const startDateNY = new Date('2024-01-01T12:00:00Z') const endDateNY = new Date('2024-01-01T14:00:00Z') render( - - + ) expect(screen.getByRole('combobox')).toHaveValue( @@ -198,11 +192,11 @@ describe('TimeSlice Component Family', () => { const expectedDisplayValue = `${expectedStartString} – ${expectedEndString}` render( - - + ) expect(screen.getByRole('combobox')).toHaveValue(expectedDisplayValue) @@ -217,12 +211,9 @@ describe('TimeSlice Component Family', () => { return 'Custom Empty' }) render( - + - + ) expect(customFormat).toHaveBeenCalledWith({ @@ -235,15 +226,15 @@ describe('TimeSlice Component Family', () => { }) }) - describe('TimeSliceTrigger', () => { + describe('ChronoTrigger', () => { it('should render a div by default and focus input on click', () => { render( - + Click Me - + ) const triggerElement = screen.getByText('Click Me').parentElement @@ -257,12 +248,12 @@ describe('TimeSlice Component Family', () => { it('should render as child and forward props when asChild is true', () => { render( - + Custom Button - + ) const triggerButton = screen.getByRole('button', { @@ -278,14 +269,14 @@ describe('TimeSlice Component Family', () => { }) }) - describe('TimeSliceInput', () => { + describe('ChronoInput', () => { const initialStartDate = new Date('2024-07-04T10:00:00Z') const initialEndDate = new Date('2024-07-04T12:00:00Z') const initialFormattedValue = 'Jul 4, 10:00\u202FAM – Jul 4, 12:00\u202FPM' it('should render with initial value from context and open portal on focus', () => { render( - { Portal Content For Input - + ) const inputEl = screen.getByTestId('input-control') expect(inputEl).toHaveValue(initialFormattedValue) @@ -310,9 +301,9 @@ describe('TimeSlice Component Family', () => { it('should update dateRange on valid input change', () => { const handleDateRangeChange = vi.fn() render( - + - + ) const inputEl = screen.getByTestId('input-change') fireEvent.change(inputEl, { @@ -328,7 +319,7 @@ describe('TimeSlice Component Family', () => { it('should clear dateRange on empty input change', () => { const handleDateRangeChange = vi.fn() render( - { onDateRangeChange={handleDateRangeChange} > - + ) const inputEl = screen.getByTestId('input-clear') fireEvent.change(inputEl, { target: { value: '' } }) @@ -348,14 +339,14 @@ describe('TimeSlice Component Family', () => { it('should call useSegmentNavigation handleKeyDown on key press', () => { render( - - + ) const inputEl = screen.getByTestId('input-keydown') fireEvent.focus(inputEl) @@ -365,11 +356,11 @@ describe('TimeSlice Component Family', () => { it('should render as child and forward props, maintaining functionality', () => { const handleDateRangeChange = vi.fn() render( - + - + ) const textareaEl = screen.getByTestId('custom-input-aschild') expect(textareaEl.tagName).toBe('TEXTAREA') @@ -387,15 +378,15 @@ describe('TimeSlice Component Family', () => { }) }) - describe('TimeSlicePortal', () => { + describe('ChronoPortal', () => { it('should not render if open is false', () => { render( - + Portal Content Here - + ) expect(screen.getByTestId('portal-visibility-test')).toHaveStyle({ display: 'none' @@ -404,21 +395,21 @@ describe('TimeSlice Component Family', () => { it('should render if open is true', () => { render( - + {' '} {/* Or open={true} */} Portal Visible - + ) expect(screen.getByText('Portal Visible')).toBeVisible() }) it('Escape key should close portal and focus input', () => { render( - + {/* Ensure there's a genuinely focusable child for the event target */} @@ -430,7 +421,7 @@ describe('TimeSlice Component Family', () => { Some other content - + ) const focusableChild = screen.getByTestId('focusable-child-in-portal') const inputEl = screen.getByTestId('portal-input-escape') @@ -448,7 +439,7 @@ describe('TimeSlice Component Family', () => { const setupPortalWithItems = () => { const onItemClick = vi.fn() render( - + { Item 3 - + ) return { item1: screen.getByTestId('item1'), @@ -550,12 +541,12 @@ describe('TimeSlice Component Family', () => { it('should render as child and forward props, maintaining functionality', () => { render( - + Custom Portal Section - + ) const portalSection = screen.getByTestId('custom-portal-aschild') expect(portalSection.tagName).toBe('SECTION') @@ -563,7 +554,7 @@ describe('TimeSlice Component Family', () => { }) }) - describe('TimeSliceShortcut', () => { + describe('ChronoShortcut', () => { const mockSetDateRange = vi.fn() const mockSetIsRelative = vi.fn() const mockSetOpen = vi.fn() @@ -598,7 +589,7 @@ describe('TimeSlice Component Family', () => { mockInputRef.current.blur = mockInputBlur render( - { - + ) const inputElement = screen.getByTestId(inputTestId) inputElement.focus() @@ -662,7 +653,7 @@ describe('TimeSlice Component Family', () => { const mockSetDateRange = vi.fn() const mockSetOpen = vi.fn() render( - { Past Hour Custom - + ) const textareaElement = screen.getByTestId(inputId) textareaElement.focus() diff --git a/src/timeslice/time-slice.tsx b/src/chrono/chrono.tsx similarity index 79% rename from src/timeslice/time-slice.tsx rename to src/chrono/chrono.tsx index 791e0d4..8546ce4 100644 --- a/src/timeslice/time-slice.tsx +++ b/src/chrono/chrono.tsx @@ -6,7 +6,7 @@ import { DismissableLayer } from '@radix-ui/react-dismissable-layer' import { Slot } from '@radix-ui/react-slot' import { sub, add, Duration } from 'date-fns' import React, { useCallback, useMemo, useId, useState, useEffect } from 'react' -import { useTimeSliceState, type DateRange } from './hooks/use-time-slice-state' +import { useChronoState, type DateRange } from './hooks/use-chrono-state' import { useSegmentNavigation, buildSegments @@ -16,12 +16,12 @@ import { formatTimeRange } from './utils/time-range' export type TimeZone = keyof typeof Timezone -const COMPONENT_NAME = 'TimeSlice' +const COMPONENT_NAME = 'Chrono' type ScopedProps = P & { __scope?: Scope } -const [createTimeSliceContext] = createContextScope(COMPONENT_NAME) +const [createChronoContext] = createContextScope(COMPONENT_NAME) -type TimeSliceContextValue = { +type ChronoContextValue = { timeZone: TimeZone inputRef: React.RefObject open: boolean @@ -42,10 +42,10 @@ type TimeSliceContextValue = { portalId?: string } -const [TimeSliceProvider, useTimeSliceContext] = - createTimeSliceContext(COMPONENT_NAME) +const [ChronoProvider, useChronoContext] = + createChronoContext(COMPONENT_NAME) -type TimeSliceProps = ScopedProps<{ +type ChronoProps = ScopedProps<{ children: React.ReactNode timeZone?: TimeZone open?: boolean @@ -65,7 +65,7 @@ type TimeSliceProps = ScopedProps<{ onDateRangeConfirm?: (range: DateRange) => void }> -const TimeSlice: React.FC = ({ +const Chrono: React.FC = ({ children, __scope, formatInput: formatInputProp, @@ -80,7 +80,7 @@ const TimeSlice: React.FC = ({ setOpen, dateRange, setDateRange: setDateRangeInternal - } = useTimeSliceState(stateProps) + } = useChronoState(stateProps) const calculateIsRelative = useCallback((range: DateRange): boolean => { let shouldBeRelative = false @@ -213,7 +213,7 @@ const TimeSlice: React.FC = ({ return ( - = ({ portalId={portalId} > {children} - + ) } -type TimeSliceTriggerProps = ScopedProps<{ +type ChronoTriggerProps = ScopedProps<{ children: React.ReactNode asChild?: boolean }> & Omit, 'onClick'> -const TimeSliceTrigger = React.forwardRef< - HTMLDivElement, - TimeSliceTriggerProps ->(({ asChild, children, __scope, ...props }, forwardedRef) => { - const { inputRef } = useTimeSliceContext(COMPONENT_NAME, __scope) +const ChronoTrigger = React.forwardRef( + ({ asChild, children, __scope, ...props }, forwardedRef) => { + const { inputRef } = useChronoContext(COMPONENT_NAME, __scope) - const onClick = useCallback(() => { - if (inputRef.current) { - inputRef.current.focus() - } - }, [inputRef]) + const onClick = useCallback(() => { + if (inputRef.current) { + inputRef.current.focus() + } + }, [inputRef]) - const Comp = asChild ? Slot : 'div' + const Comp = asChild ? Slot : 'div' - return ( - - {children} - - ) -}) + return ( + + {children} + + ) + } +) -type TimeSliceInputProps = ScopedProps<{ +type ChronoInputProps = ScopedProps<{ children?: React.ReactNode asChild?: boolean }> & @@ -271,9 +270,9 @@ type TimeSliceInputProps = ScopedProps<{ 'onChange' | 'onKeyDown' | 'onFocus' | 'onClick' > -const TimeSliceInput = React.forwardRef( +const ChronoInput = React.forwardRef( ({ asChild, children, __scope, ...props }, forwardedRef) => { - const context = useTimeSliceContext(COMPONENT_NAME, __scope) + const context = useChronoContext(COMPONENT_NAME, __scope) const internalInputRef = React.useRef(null) const composedInputRef = composeRefs( forwardedRef, @@ -394,16 +393,16 @@ const TimeSliceInput = React.forwardRef( } ) -type TimeSlicePortalProps = ScopedProps<{ +type ChronoPortalProps = ScopedProps<{ children: React.ReactNode asChild?: boolean ariaLabel?: string }> & React.HTMLAttributes -const TimeSlicePortal = React.forwardRef( +const ChronoPortal = React.forwardRef( ({ asChild, children, __scope, ariaLabel, ...props }, forwardedRef) => { - const context = useTimeSliceContext(COMPONENT_NAME, __scope) + const context = useChronoContext(COMPONENT_NAME, __scope) const handleKeyDownInPortal = useCallback( (e: React.KeyboardEvent) => { @@ -488,7 +487,7 @@ const TimeSlicePortal = React.forwardRef( } ) -type TimeSliceShortcutProps = ScopedProps<{ +type ChronoShortcutProps = ScopedProps<{ children: React.ReactNode duration: Partial<{ years: number @@ -502,73 +501,72 @@ type TimeSliceShortcutProps = ScopedProps<{ }> & Omit, 'onClick'> -const TimeSliceShortcut = React.forwardRef< - HTMLDivElement, - TimeSliceShortcutProps ->(({ asChild, children, __scope, duration, ...props }, forwardedRef) => { - const { setDateRange, setInternalIsRelative, setOpen, inputRef } = - useTimeSliceContext(COMPONENT_NAME, __scope) - - const handleClick = useCallback( - (e: React.MouseEvent) => { - e.preventDefault() - const now = new Date() - let finalStartDate: Date - let finalEndDate: Date - - const isFutureIntent = Object.values(duration).some( - (val) => val !== undefined && val < 0 - ) - - const normalizedDuration: Duration = {} - ;(Object.keys(duration) as Array).forEach( - (key) => { - const value = duration[key] - if (value !== undefined) { - normalizedDuration[key] = Math.abs(value) +const ChronoShortcut = React.forwardRef( + ({ asChild, children, __scope, duration, ...props }, forwardedRef) => { + const { setDateRange, setInternalIsRelative, setOpen, inputRef } = + useChronoContext(COMPONENT_NAME, __scope) + + const handleClick = useCallback( + (e: React.MouseEvent) => { + e.preventDefault() + const now = new Date() + let finalStartDate: Date + let finalEndDate: Date + + const isFutureIntent = Object.values(duration).some( + (val) => val !== undefined && val < 0 + ) + + const normalizedDuration: Duration = {} + ;(Object.keys(duration) as Array).forEach( + (key) => { + const value = duration[key] + if (value !== undefined) { + normalizedDuration[key] = Math.abs(value) + } } + ) + + if (isFutureIntent) { + finalStartDate = now + finalEndDate = add(now, normalizedDuration) + } else { + finalStartDate = sub(now, normalizedDuration) + finalEndDate = now } - ) - - if (isFutureIntent) { - finalStartDate = now - finalEndDate = add(now, normalizedDuration) - } else { - finalStartDate = sub(now, normalizedDuration) - finalEndDate = now - } - setInternalIsRelative(true) - setDateRange({ startDate: finalStartDate, endDate: finalEndDate }) - setOpen(false) - if (inputRef.current) { - inputRef.current.blur() - } - }, - [setDateRange, setInternalIsRelative, setOpen, duration, inputRef] - ) + setInternalIsRelative(true) + setDateRange({ startDate: finalStartDate, endDate: finalEndDate }) + setOpen(false) + if (inputRef.current) { + inputRef.current.blur() + } + }, + [setDateRange, setInternalIsRelative, setOpen, duration, inputRef] + ) - const optionPropsAria = { - ...props, - role: 'option', - 'aria-selected': false, - ref: forwardedRef, - onClick: handleClick, - tabIndex: -1, - 'data-shortcut-item': 'true', - style: { cursor: 'pointer', ...props.style } - } + const optionPropsAria = { + ...props, + role: 'option', + 'aria-selected': false, + ref: forwardedRef, + onClick: handleClick, + tabIndex: -1, + 'data-shortcut-item': 'true', + style: { cursor: 'pointer', ...props.style } + } - const Comp = asChild ? Slot : 'div' + const Comp = asChild ? Slot : 'div' - return {children} -}) + return {children} + } +) -const Root = TimeSlice -const Trigger = TimeSliceTrigger -const Input = TimeSliceInput -const Portal = TimeSlicePortal -const Shortcut = TimeSliceShortcut +const Root = Chrono +const Trigger = ChronoTrigger +const Input = ChronoInput +const Portal = ChronoPortal +const Shortcut = ChronoShortcut export { Root, @@ -576,9 +574,9 @@ export { Input, Portal, Shortcut, - type TimeSliceProps, - type TimeSliceInputProps, - type TimeSlicePortalProps, - type TimeSliceShortcutProps, + type ChronoProps, + type ChronoInputProps, + type ChronoPortalProps, + type ChronoShortcutProps, type DateRange } diff --git a/src/timeslice/hooks/use-time-slice-state.ts b/src/chrono/hooks/use-chrono-state.ts similarity index 92% rename from src/timeslice/hooks/use-time-slice-state.ts rename to src/chrono/hooks/use-chrono-state.ts index 537b0a8..405f1ad 100644 --- a/src/timeslice/hooks/use-time-slice-state.ts +++ b/src/chrono/hooks/use-chrono-state.ts @@ -6,7 +6,7 @@ export type DateRange = { endDate?: Date } -type UseTimeSliceStateProps = { +type UseChronoStateProps = { open?: boolean defaultOpen?: boolean onOpenChange?: (open: boolean) => void @@ -15,10 +15,10 @@ type UseTimeSliceStateProps = { onDateRangeChange?: (range: DateRange) => void } -export function useTimeSliceState({ +export function useChronoState({ onDateRangeChange, ...props -}: UseTimeSliceStateProps) { +}: UseChronoStateProps) { const [open, setOpen] = useControllableState({ prop: props.open, defaultProp: props.defaultOpen ?? false, diff --git a/src/timeslice/hooks/use-segment-navigation/__tests__/date-adjustments.test.ts b/src/chrono/hooks/use-segment-navigation/__tests__/date-adjustments.test.ts similarity index 100% rename from src/timeslice/hooks/use-segment-navigation/__tests__/date-adjustments.test.ts rename to src/chrono/hooks/use-segment-navigation/__tests__/date-adjustments.test.ts diff --git a/src/timeslice/hooks/use-segment-navigation/__tests__/environment.test.ts b/src/chrono/hooks/use-segment-navigation/__tests__/environment.test.ts similarity index 100% rename from src/timeslice/hooks/use-segment-navigation/__tests__/environment.test.ts rename to src/chrono/hooks/use-segment-navigation/__tests__/environment.test.ts diff --git a/src/timeslice/hooks/use-segment-navigation/__tests__/initial-state.test.ts b/src/chrono/hooks/use-segment-navigation/__tests__/initial-state.test.ts similarity index 97% rename from src/timeslice/hooks/use-segment-navigation/__tests__/initial-state.test.ts rename to src/chrono/hooks/use-segment-navigation/__tests__/initial-state.test.ts index cc87c15..5816dda 100644 --- a/src/timeslice/hooks/use-segment-navigation/__tests__/initial-state.test.ts +++ b/src/chrono/hooks/use-segment-navigation/__tests__/initial-state.test.ts @@ -5,7 +5,7 @@ import { buildSegments // type Segment // Not used } from '../use-segment-navigation' -// import type { DateRange } from '../../use-time-slice-state' // Not used +// import type { DateRange } from '../../use-chrono-state' // Not used import { // createMockInputRef, getHook as getHookFromUtils diff --git a/src/timeslice/hooks/use-segment-navigation/__tests__/keyboard-navigation.test.ts b/src/chrono/hooks/use-segment-navigation/__tests__/keyboard-navigation.test.ts similarity index 100% rename from src/timeslice/hooks/use-segment-navigation/__tests__/keyboard-navigation.test.ts rename to src/chrono/hooks/use-segment-navigation/__tests__/keyboard-navigation.test.ts diff --git a/src/timeslice/hooks/use-segment-navigation/__tests__/keydown-interactions.test.ts b/src/chrono/hooks/use-segment-navigation/__tests__/keydown-interactions.test.ts similarity index 99% rename from src/timeslice/hooks/use-segment-navigation/__tests__/keydown-interactions.test.ts rename to src/chrono/hooks/use-segment-navigation/__tests__/keydown-interactions.test.ts index cbb9dde..e396111 100644 --- a/src/timeslice/hooks/use-segment-navigation/__tests__/keydown-interactions.test.ts +++ b/src/chrono/hooks/use-segment-navigation/__tests__/keydown-interactions.test.ts @@ -6,7 +6,7 @@ import { // type DateSegmentType, // Not used type Segment // Used in Tab suite helper } from '../use-segment-navigation' -// import type { DateRange } from '../../use-time-slice-state'; // Not used +// import type { DateRange } from '../../use-chrono-state'; // Not used import { createMockKeyboardEvent, getHook as getHookFromUtils diff --git a/src/timeslice/hooks/use-segment-navigation/__tests__/manual-input.test.ts b/src/chrono/hooks/use-segment-navigation/__tests__/manual-input.test.ts similarity index 100% rename from src/timeslice/hooks/use-segment-navigation/__tests__/manual-input.test.ts rename to src/chrono/hooks/use-segment-navigation/__tests__/manual-input.test.ts diff --git a/src/timeslice/hooks/use-segment-navigation/__tests__/segment-selection.test.ts b/src/chrono/hooks/use-segment-navigation/__tests__/segment-selection.test.ts similarity index 100% rename from src/timeslice/hooks/use-segment-navigation/__tests__/segment-selection.test.ts rename to src/chrono/hooks/use-segment-navigation/__tests__/segment-selection.test.ts diff --git a/src/timeslice/hooks/use-segment-navigation/__tests__/timezone-specific.test.ts b/src/chrono/hooks/use-segment-navigation/__tests__/timezone-specific.test.ts similarity index 100% rename from src/timeslice/hooks/use-segment-navigation/__tests__/timezone-specific.test.ts rename to src/chrono/hooks/use-segment-navigation/__tests__/timezone-specific.test.ts diff --git a/src/timeslice/hooks/use-segment-navigation/index.ts b/src/chrono/hooks/use-segment-navigation/index.ts similarity index 100% rename from src/timeslice/hooks/use-segment-navigation/index.ts rename to src/chrono/hooks/use-segment-navigation/index.ts diff --git a/src/timeslice/hooks/use-segment-navigation/test-utils.ts b/src/chrono/hooks/use-segment-navigation/test-utils.ts similarity index 99% rename from src/timeslice/hooks/use-segment-navigation/test-utils.ts rename to src/chrono/hooks/use-segment-navigation/test-utils.ts index e7415a7..7b8af28 100644 --- a/src/timeslice/hooks/use-segment-navigation/test-utils.ts +++ b/src/chrono/hooks/use-segment-navigation/test-utils.ts @@ -2,7 +2,7 @@ import { vi, type MockedFunction } from 'vitest' import type React from 'react' import { renderHook } from '@testing-library/react' import { useSegmentNavigation } from './use-segment-navigation' -import type { DateRange } from '../use-time-slice-state' +import type { DateRange } from '../use-chrono-state' import { act } from '@testing-library/react' import { buildSegments as buildSegmentsInternal, diff --git a/src/timeslice/hooks/use-segment-navigation/use-segment-navigation.ts b/src/chrono/hooks/use-segment-navigation/use-segment-navigation.ts similarity index 99% rename from src/timeslice/hooks/use-segment-navigation/use-segment-navigation.ts rename to src/chrono/hooks/use-segment-navigation/use-segment-navigation.ts index 748ba3b..2d7a22a 100644 --- a/src/timeslice/hooks/use-segment-navigation/use-segment-navigation.ts +++ b/src/chrono/hooks/use-segment-navigation/use-segment-navigation.ts @@ -1,5 +1,5 @@ import React, { useCallback, useMemo, useEffect, useState } from 'react' -import type { DateRange } from '../use-time-slice-state' +import type { DateRange } from '../use-chrono-state' import { addMonths, addDays, addHours, addMinutes, addYears } from 'date-fns' import '@formatjs/intl-datetimeformat/polyfill' import '@formatjs/intl-datetimeformat/locale-data/en' diff --git a/src/chrono/index.ts b/src/chrono/index.ts new file mode 100644 index 0000000..0d9927d --- /dev/null +++ b/src/chrono/index.ts @@ -0,0 +1,13 @@ +export { + Root, + Trigger, + Input, + Portal, + Shortcut, + type ChronoProps, + type ChronoInputProps, + type ChronoPortalProps, + type ChronoShortcutProps, + type DateRange, + type TimeZone +} from './chrono' diff --git a/src/timeslice/utils/date-parser.ts b/src/chrono/utils/date-parser.ts similarity index 96% rename from src/timeslice/utils/date-parser.ts rename to src/chrono/utils/date-parser.ts index d0a8928..365d8e7 100644 --- a/src/timeslice/utils/date-parser.ts +++ b/src/chrono/utils/date-parser.ts @@ -1,6 +1,6 @@ import * as chrono from 'chrono-node' import { fromUnixTime, isValid } from 'date-fns' -import type { DateRange } from '../hooks/use-time-slice-state' +import type { DateRange } from '../hooks/use-chrono-state' export function parseDateInput(value: string): DateRange { let parsed = chrono.parse(value, new Date()) diff --git a/src/timeslice/utils/time-range.ts b/src/chrono/utils/time-range.ts similarity index 100% rename from src/timeslice/utils/time-range.ts rename to src/chrono/utils/time-range.ts diff --git a/src/index.ts b/src/index.ts index 247e6ac..7e27701 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,10 @@ -export * as TimeSlice from './timeslice' +export * as Chrono from './chrono' +export { Inlay } from './inlay' +export type { + InlayProps, + InlayRef, + TokenState, + Plugin, + Matcher, + Match +} from './inlay' diff --git a/src/inlay/.gitignore b/src/inlay/.gitignore new file mode 100644 index 0000000..68c7b8c --- /dev/null +++ b/src/inlay/.gitignore @@ -0,0 +1,4 @@ +# Ignore local development notes +TODO.md +TODO.local.md +*.private.md \ No newline at end of file diff --git a/src/inlay/ARCHITECTURE.md b/src/inlay/ARCHITECTURE.md new file mode 100644 index 0000000..d9502c3 --- /dev/null +++ b/src/inlay/ARCHITECTURE.md @@ -0,0 +1,351 @@ +# Inlay Architecture + +Inlay is a React-based rich text editor primitive built on `contentEditable`. It provides controlled text input with support for embedded tokens—inline elements that represent structured data (mentions, tags, links) while maintaining a clean string-based value model. + +## Core Concept: Token Divergence + +The key architectural decision is **token divergence**: a token's visual representation can differ from its raw value. + +``` +Raw value: "Hello @alice_123, meet @bob_456" +Visual DOM: "Hello Alice, meet Bob" +``` + +This enables readable UI while preserving machine-readable identifiers in the underlying value. All cursor movement, selection, copy/paste, and deletion operations must account for this divergence. + +## Component Hierarchy + +``` +StructuredInlay (high-level, plugin-based) + └── Inlay.Root (core contentEditable wrapper) + ├── Inlay.Token (inline token markers) + └── Inlay.Portal (positioned overlays via Radix Popover) +``` + +### Exports + +All components are exported under the `Inlay` namespace: + +- `Inlay.Root` — Main editor component, wraps contentEditable +- `Inlay.Token` — Declares a token with `value` (raw) and `children` (visual) +- `Inlay.Portal` — Positioned popover anchored to selection or editor + - `Inlay.Portal.List` — Keyboard-navigable list container + - `Inlay.Portal.Item` — Selectable item within a Inlay.Portal.List +- `Inlay.StructuredInlay` — Higher-level component with plugin system + +Types are also exported: `InlayProps`, `InlayRef`, `TokenState`, `Plugin`, `Matcher`, `Match`. + +## Directory Structure + +``` +src/inlay/ +├── inlay.tsx # Core Root/Token/Portal components +├── portal-list.tsx # Portal.List/Item compound components +├── index.ts # Public exports +├── hooks/ +│ ├── use-clipboard.ts # Copy/cut/paste with token awareness +│ ├── use-composition.ts # IME composition handling (incl. iOS quirks) +│ ├── use-history.ts # Undo/redo stack +│ ├── use-key-handlers.ts# Keyboard input processing (incl. Android GBoard) +│ ├── use-placeholder-sync.ts +│ ├── use-selection.ts # Selection state tracking (incl. iOS selectionchange) +│ ├── use-selection-snap.ts # Cursor snapping to token boundaries +│ ├── use-token-weaver.tsx # Two-pass token rendering +│ ├── use-touch-selection.ts # Touch-based selection handling +│ └── use-virtual-keyboard.ts # Virtual keyboard detection (visualViewport) +├── internal/ +│ ├── dom-utils.ts # DOM traversal, offset calculation +│ └── string-utils.ts # Token matching/scanning +├── structured/ +│ ├── structured-inlay.tsx # Plugin-based wrapper +│ └── plugins/ +│ ├── plugin.ts # Plugin type definition +│ └── mentions.tsx # Example mentions plugin +├── __ct__/ # Playwright component tests +└── __tests__/ # Vitest unit tests +``` + +## Key Hooks + +### `useTokenWeaver` +Two-pass rendering system: +1. First pass: Children render invisibly to register tokens +2. Second pass: Tokens are "weaved" into the text at correct positions + +This solves the chicken-and-egg problem of needing to know token positions before rendering while also needing to render to know what tokens exist. + +**Empty state:** When the value is empty, a zero-width space (`\u200B`) is rendered to maintain consistent caret height. Without this, the caret position can shift vertically when transitioning between empty and non-empty states (especially with styled tokens that have padding). + +### `useKeyHandlers` +Intercepts all keyboard input via `onBeforeInput` and `onKeyDown`. Prevents default browser behavior and manually updates the controlled value. Handles: +- Text insertion (with multi-char insert tracking for iOS swipe-text) +- Backspace/Delete (with grapheme cluster awareness) +- iOS swipe-text word deletion (deletes entire swiped word, preserves auto-inserted spaces) +- Enter/Space +- Undo/Redo (Ctrl+Z, Ctrl+Y) + +**iOS DOM sync:** On iOS, text insertions bypass `preventDefault()` to avoid multi-word suggestion bugs. The `input` event handler syncs DOM content to React state using `serializeRawFromDom()`. A `valueRef` provides synchronous access to the current value for decisions that must be made before React re-renders (e.g., detecting newlines to avoid `` reconciliation crashes). + +### `useComposition` +Manages IME (Input Method Editor) composition for CJK languages. Tracks composition state to avoid interfering with in-progress input. Handles composition commit via Space/Enter. + +### `useClipboard` +Token-aware clipboard operations. When copying/cutting a token, extracts the raw value (not visual text). When pasting, inserts at correct raw offset position. + +### `useSelectionSnap` +Snaps cursor and selection to token boundaries. Prevents cursor from landing inside a token's visual representation—it either sits before or after the token in raw-value terms. + +### `useHistory` +Simple undo/redo with snapshot-based history. Coalesces rapid edits into single undo steps. + +### `useSelection` +Tracks current selection as raw offsets. Provides `activeToken` when cursor is adjacent to or within a token. + +## Internal Utilities + +### `dom-utils.ts` +Core DOM traversal functions that account for token divergence: + +- `getAbsoluteOffset(root, node, offset)` — Converts DOM selection position to raw string offset +- `getTextNodeAtOffset(root, offset)` — Converts raw offset to DOM position +- `setDomSelection(root, start, end?)` — Sets browser selection from raw offsets +- `getClosestTokenEl(node)` — Finds containing token element +- `getTokenRawRange(root, tokenEl)` — Gets raw offset range for a token + +### `string-utils.ts` +Token matching and scanning: + +- `Matcher` — Interface for token matchers (regex, prefix, custom) +- `scan(text, matchers)` — Finds all token matches in a string, with overlap resolution +- `Match` — Represents a found token with position and parsed data + +**Overlap Resolution:** When multiple matchers produce overlapping matches, `scan()` uses a longest-match-wins strategy: +1. Matches are sorted by start position, then by length (longest first) +2. A greedy algorithm accepts non-overlapping matches, preferring longer ones +3. When matches have the same range, the first matcher in the array wins + +This prevents duplicate tokens when plugins have overlapping patterns (e.g., `@alice` vs `@alice_vip`). + +## Portal Navigation + +Portal content often needs keyboard navigation (e.g., autocomplete lists). `Portal.List` and `Portal.Item` provide this with built-in keyboard handling. + +```tsx +portal: ({ replace }) => ( + replace(`@${user.id} `)}> + {users.map(user => ( + + {user.name} + + ))} + +) +``` + +**Keyboard behavior:** +- `ArrowUp/Down` — Navigate items (wraps around) +- `Enter` — Select active item +- `Escape` — Dismiss portal + +**Virtual focus:** The editor retains DOM focus while Portal.List tracks the "active" item via state. This avoids contentEditable focus issues. + +**Styling:** Use `data-active` attribute for highlighting: +```css +[data-portal-item][data-active] { background: var(--highlight); } +``` + +**Single-item pattern:** For confirmations or actions, use a single Inlay.Portal.Item: +```tsx + deleteToken()}> + Delete? Press Enter to confirm. + +``` + +**Positioning:** Portal uses manual DOM positioning instead of Radix's built-in anchor. This ensures the popover follows the caret on iOS Safari, where Radix's cached anchor position doesn't update correctly after text changes. The anchor rect is passed via `AnchorRectContext` and applied via `useLayoutEffect` on each render. + +## Plugin System (StructuredInlay) + +Plugins define token types with: + +```typescript +type Plugin = { + props: P // Plugin configuration + matcher: Matcher // How to find tokens in text + render: (ctx) => ReactNode // Token visual representation + portal: (ctx) => ReactNode // Optional popover content + onInsert: (value: T) => void + onKeyDown: (event) => boolean +} +``` + +Example: A mentions plugin matches `@username` patterns, renders styled chips, and shows a user card popover on focus. + +## Browser Compatibility + +- Handles Firefox's element-node selections (Ctrl+A sets selection on element, not text nodes) +- WebKit composition quirks (extra `beforeInput` events after `compositionend`) +- Cross-platform keyboard shortcuts via `ControlOrMeta` + +## Mobile Support + +Inlay provides full mobile device support with touch interactions, virtual keyboard handling, and platform-specific fixes. + +### Mobile Input Attributes + +The editor automatically sets mobile-friendly attributes: + +```tsx + +``` + +All attributes are configurable via props: + +```tsx +type InlayProps = { + inputMode?: 'text' | 'search' | 'email' | 'tel' | 'url' | 'numeric' | 'decimal' | 'none' + autoCapitalize?: 'off' | 'none' | 'on' | 'sentences' | 'words' | 'characters' + autoCorrect?: 'on' | 'off' + enterKeyHint?: 'enter' | 'done' | 'go' | 'next' | 'previous' | 'search' | 'send' + onVirtualKeyboardChange?: (open: boolean) => void +} +``` + +### Touch Event Handling + +The `useTouchSelection` hook handles touch-based interactions: + +- **Tap to focus:** Positions caret at touch location +- **Long press:** Triggers native selection mode +- **Token snapping:** Touch inside tokens snaps to token boundaries +- **Debouncing:** Prevents rapid touch event issues + +### Virtual Keyboard Detection + +The `useVirtualKeyboard` hook uses the `visualViewport` API to detect keyboard visibility: + +```tsx + { + console.log('Keyboard:', open ? 'open' : 'closed') + }} +/> +``` + +When the keyboard opens, the editor automatically scrolls into view. + +### Portal Touch Navigation + +`Portal.List` and `Portal.Item` support touch interactions: + +- **Touch start:** Activates item (like hover on desktop) +- **Touch end:** Selects item if touch didn't move (tap detection) +- **Scroll vs tap:** Movement >10px cancels selection + +```tsx +// Portal items work the same on touch and desktop + + + {item.label} + + +``` + +### iOS-Specific Handling + +- **Selection events:** iOS fires `selectionchange` on `document`, not the element. Added document-level listener in `useSelection`. +- **Anchor rect updates:** iOS can return stale caret rects after text changes. The `useSelection` hook listens for `input` and `visualViewport` events, using `requestAnimationFrame` to read the rect after layout stabilizes. This ensures popovers follow the caret correctly. +- **Composition data:** iOS Safari sometimes omits data in `compositionend`. Tracked via `compositionupdate` as fallback. +- **iPad detection:** Includes modern iPads that report as "MacIntel" with touch. +- **Multi-word suggestions prevention:** Calling `preventDefault()` on `beforeinput` for text insertions triggers iOS to show multi-word predictions (e.g., "I am going to the" as a single suggestion). However, iOS doesn't send usable event data for these predictions. By NOT calling `preventDefault()` on iOS for `insertText`, iOS shows only single-word suggestions which work correctly. The DOM is modified natively and synced to React state via the `input` event handler. +- **Token context exception:** When the cursor is inside a token, we use the controlled path (with `preventDefault`) even on iOS. This avoids issues where `data-token-text` attributes become stale after edits, causing `serializeRawFromDom` to return incorrect values. +- **Newline handling with React:** When content contains newlines, React renders `` elements. If iOS modifies the DOM directly around `` elements, React reconciliation fails with "NotFoundError". Solution: use a `valueRef` to detect newlines synchronously (before React re-renders) and call `preventDefault()` when newlines exist, handling the input via the controlled path. +- **Swipe-text after newlines:** iOS sends swipe data with a leading space even at the start of a line (after `\n`). This space is stripped. iOS may also send the space as a SEPARATE event before the word—single-space insertions at line start are skipped entirely. +- **Swipe-text word deletion:** When user swipe-types a word and presses backspace, iOS sends a single `deleteContentBackward` event with a targetRange covering only the last character. However, if we don't `preventDefault()`, iOS fires 5 rapid delete events and deletes the whole word natively. Since we need to `preventDefault()` to maintain controlled state, we track multi-char inserts and delete the entire chunk when backspace is pressed immediately after. +- **Swipe-text space preservation:** iOS auto-inserts a leading space when swipe-typing after existing text (e.g., "hello" + swipe "world" → "hello world"). When deleting, only the word is removed, preserving the auto-inserted space. +- **Autocomplete suggestions:** For `insertReplacementText` (autocomplete), iOS may not provide the replacement data when `preventDefault()` is called. On iOS, autocomplete is always handled via DOM sync regardless of newlines. +- **Autocomplete state reset:** After pressing Enter, the `autocomplete` attribute is briefly toggled to reset iOS's autocomplete context. This prevents iOS from suggesting merged words like "helloworld" when the actual text is "hello\nworld". + +### Android-Specific Handling + +- **GBoard predictions:** Handles `insertReplacementText` input type for word predictions. Replacement text is in `event.data`. +- **Delete variations:** Handles `deleteWordBackward`, `deleteWordForward`, `deleteSoftLineBackward`, `deleteSoftLineForward` input types. + +### iOS Safari Text Suggestions + +When a user taps a keyboard suggestion on iOS Safari: +1. iOS fires `insertReplacementText` with `data: null` and the replacement text in `event.dataTransfer.getData('text/plain')` +2. This differs from Android which puts the text in `event.data` +3. The handler checks both `data` and `dataTransfer` to support both platforms + +### Testing Mobile + +Mobile tests use Playwright with device emulation: + +```bash +# Run mobile-specific tests +bun run test:ct -- --project=mobile-chrome +bun run test:ct -- --project=mobile-safari +``` + +Test files in `__ct__/inlay.mobile.spec.tsx` cover: +- Touch-based caret positioning +- Mobile attribute presence +- Portal touch navigation +- Token interaction on touch + +## Accessibility + +Inlay provides baseline accessibility out of the box: + +- `role="textbox"` and `aria-multiline` are set automatically +- Default `aria-label="Text input"` — consumers should override with context-specific labels +- Placeholder is marked `aria-hidden="true"` to avoid duplicate announcements + +**Automated a11y testing:** Uses `@axe-core/playwright` to catch WCAG violations in CI. Tests cover empty state, with-tokens, and focused states. + +**Consumer responsibilities:** +- Provide meaningful `aria-label` or `aria-labelledby` for the editor context +- Ensure token visual styling meets contrast requirements +- Test with actual screen readers (VoiceOver, NVDA) for announcement quality + +## Testing + +- **`__ct__/`** — Playwright component tests (real browser, keyboard simulation) +- **`__tests__/`** — Vitest unit tests (JSDOM, faster iteration) + +Run with: +```bash +bun run test:ct -- src/inlay/__ct__/ # Playwright +bun run test -- src/inlay/ # Vitest +``` + +## Common Patterns + +### Adding a new keyboard shortcut +1. Add handler in `use-key-handlers.ts` `onKeyDown` +2. Check for modifier keys, prevent default, update value +3. Use `setDomSelection` to position cursor after state update + +### Adding clipboard behavior +1. Modify `use-clipboard.ts` +2. Use `getSelectionFromDom` for token-aware selection +3. Use `cfg.getValue()` to read raw value, `cfg.setValue()` to update + +### Creating a new token type +1. Define a `Matcher` in `string-utils.ts` format +2. Use with `Inlay.StructuredInlay` plugins or manually with `` + +## Known Limitations + +- Single-line by default (`multiline` prop enables multi-line) +- No rich formatting (bold, italic) — tokens only +- No nested tokens +- IME composition with tokens at boundaries can be tricky +- Mobile autocorrect is disabled by default (would interfere with tokens) +- Samsung keyboard may have composition quirks (test thoroughly) + diff --git a/src/inlay/ISSUE-ios-multiword-suggestions.md b/src/inlay/ISSUE-ios-multiword-suggestions.md new file mode 100644 index 0000000..d0a8823 --- /dev/null +++ b/src/inlay/ISSUE-ios-multiword-suggestions.md @@ -0,0 +1,184 @@ +# iOS Multi-Word Keyboard Suggestions Not Working + +## Problem Statement + +When using Inlay on iOS Safari, multi-word keyboard predictions (like "tell" → "tell them") don't work. Tapping a multi-word suggestion either does nothing or only inserts a space. + +Single-word suggestions (like "hel" → "hello") DO work correctly. + +## Root Cause Discovery + +After extensive debugging, we discovered: + +**Calling `preventDefault()` on `beforeinput` events triggers iOS to show multi-word predictions.** + +This is unexpected iOS behavior - when we prevent default, iOS interprets it as "this app wants special input handling" and switches to an advanced keyboard mode with multi-word predictions. + +### Test Results + +| ContentEditable Setup | Multi-Word Predictions? | +|----------------------|------------------------| +| Naked (no JS) | ❌ No | +| With `onInput` only | ❌ No | +| With `onBeforeInput` (no prevent) | ❌ No | +| With `onBeforeInput` + `preventDefault()` | ✅ Yes | +| Inlay.Root | ✅ Yes | +| StructuredInlay | ✅ Yes | + +### The Dilemma + +1. **We need `preventDefault()`** to control input, especially around structured tokens +2. **`preventDefault()` triggers multi-word predictions** on iOS +3. **iOS doesn't send usable events** for multi-word predictions - no `data`, no `dataTransfer`, nothing to intercept + +## What Works + +**Single-word suggestions** work correctly: +- iOS sends `inputType: "insertReplacementText"` with `data: null` +- The actual text is in `event.dataTransfer.getData('text/plain')` +- We handle this in `use-key-handlers.ts` around line 150 + +```typescript +// Get replacement text: try data first (Android), then dataTransfer (iOS Safari) +const replacementText = data ?? event.dataTransfer?.getData('text/plain') +``` + +## What Doesn't Work + +**Multi-word predictions** fail because: +1. When user taps a multi-word suggestion, iOS sends `textInput` with just `data: " "` (space) +2. No actual suggestion text is provided anywhere +3. We can't handle what we can't detect + +## Potential Solutions + +### 1. Allow + Resync on Input +Don't `preventDefault()`. Let iOS modify the DOM, then on `input`: +- Parse the DOM content +- Reconstruct token state +- Sync to React + +**Challenge**: Token integrity - if iOS types into a token span, we need to correctly parse it back. + +### 2. Invisible Input Overlay (iOS only) +Mount a real `` or `` on iOS that: +- Is invisible but captures all native input +- Gets all the proper iOS events +- Syncs to the visible contentEditable display + +**Advantage**: Clean separation - iOS talks to native input, we control display. + +### 3. Hybrid preventDefault +Only call `preventDefault()` when cursor is in/near tokens. Let iOS handle plain text areas normally. + +**Challenge**: Detecting "near tokens" reliably, edge cases. + +### 4. Parse-Based Reconciliation +After any DOM mutation: +- Diff the DOM against expected state +- Reconcile differences +- Update React state accordingly + +**Similar to #1** but more focused on diffing. + +## Relevant Code Locations + +- `src/inlay/hooks/use-key-handlers.ts` - Main input handling, `preventDefault()` calls +- `src/inlay/inlay.tsx` - ContentEditable element, attributes +- `src/inlay/stories/structured.stories.tsx` - Test stories including `NakedContentEditable` + +## Test Stories Created + +In `structured.stories.tsx`: + +### `MinimalInlay` +- Minimal Inlay.Root (no plugins) +- StructuredInlay (no plugins) +- Both show multi-word predictions (confirms it's core Inlay, not plugins) + +### `NakedContentEditable` +Key test cases to isolate the cause: + +1. **Naked (no JS)** - Single-word only ✅ +2. **With preventDefault on beforeinput** - Multi-word predictions ❌ +3. **With React controlled state** - Single-word only ✅ +4. **Handle on input instead of beforeinput** - Single-word only ✅ + +The 4th test case is the key insight: if you add a `beforeinput` handler but DON'T call `preventDefault()`, you still get single-word only. It's specifically the `preventDefault()` call that triggers multi-word. + +## Debug Logging (Currently Active) + +Extensive logging is currently in `use-key-handlers.ts` for iOS debugging. These are useful for testing on real devices: + +- `[beforeinput]` - Logs all beforeinput events with inputType, data, dataTransfer, cancelable, etc. +- `[insertText]` - Logs text insertions with position info +- `[insertReplacementText]` - Logs iOS/Android word predictions +- `[MutationObserver]` - Logs DOM changes not caught by events +- `[textInput]` - Logs native textInput events with DOM content +- `[input]` - Logs post-input events +- `[document textInput/input/beforeinput]` - Document-level listeners + +There are also document-level event listeners added in the `useEffect` for catching events iOS might send elsewhere. + +**Note**: These should be removed or made conditional before shipping to production. + +## Current State + +The code has: +1. ✅ **Single-word `insertReplacementText` fix** - Working! Checks `dataTransfer` when `data` is null +2. ✅ **Debug logging** - Active, useful for iOS testing on real devices +3. ⚠️ **Space-handling experiment** - Code at ~line 355 that doesn't `preventDefault()` for space insertions (was testing if iOS sends more events after space) +4. ⚠️ **MutationObserver with debouncing** - Syncs DOM to React state when we don't prevent default +5. ⚠️ **`lastPreventedTimeRef` tracking** - Tracks when we prevented vs didn't, so MutationObserver knows when to sync +6. ⚠️ **`preventAndMark()` helper** - Wrapper around `preventDefault()` that also updates the ref + +## Test Status + +Some tests in `inlay.ios-swipe-text.spec.tsx` are **currently failing** due to experimental changes: + +``` +3 failed: +- backspace after swipe + trailing space should delete swiped word +- backspace after multiple swipes should delete most recent word +- swipe after trailing space should not create double space +11 passed +``` + +The failures are because of the space-handling experiment (line ~355) that doesn't `preventDefault()` for spaces. This breaks the controlled input flow. + +**To fix**: Either revert the space experiment or update the tests. + +## Recommended Next Steps + +1. **Choose an approach** from the solutions above +2. **Prototype** the chosen approach +3. **Test on real iOS device** (critical - simulators may differ) +4. **Fix or update failing tests** +5. **Clean up debug logging** before shipping +6. **Update ARCHITECTURE.md** with final solution + +## iOS Keyboard Behavior Notes + +- `autocorrect="on"` vs omitted behaves the same (both show multi-word when preventDefault is used) +- `spellcheck`, `autocapitalize`, `role`, `inputMode` don't affect multi-word prediction appearance +- The trigger is specifically `preventDefault()` on `beforeinput` events +- iOS System Settings → Keyboard → Predictive controls this at OS level, but we can't control it from web + +## Code Changes Made + +### `inlay.tsx` +- Changed `autoCorrect` default from `'off'` to `undefined` (omit attribute, let iOS decide) + +### `use-key-handlers.ts` +- Added `lastPreventedTimeRef` to track when we prevent default +- Added `preventAndMark()` helper function +- Added extensive debug logging +- Added MutationObserver that syncs DOM to state when we don't prevent +- Added document-level event listeners for debugging +- Added space-handling experiment (line ~355) + +### `structured.stories.tsx` +- Removed `autoCorrect="on"` from main story (using default now) +- Added `MinimalInlay` story +- Added `NakedContentEditable` story with 4 test cases + diff --git a/src/inlay/__ct__/fixtures/auto-update-inlay.tsx b/src/inlay/__ct__/fixtures/auto-update-inlay.tsx new file mode 100644 index 0000000..fe2033b --- /dev/null +++ b/src/inlay/__ct__/fixtures/auto-update-inlay.tsx @@ -0,0 +1,78 @@ +import React from 'react' +import { StructuredInlay } from '../../structured/structured-inlay' +import { createRegexMatcher } from '../../internal/string-utils' +import type { Plugin } from '../../structured/plugins/plugin' + +type TokenData = { raw: string; name?: string } + +/** + * Token component that auto-updates after mount (like the storybook MentionToken). + * This creates divergence: raw "@user" → display "User Name" + */ +function MentionToken({ + token, + update +}: { + token: TokenData + update: (data: Partial) => void +}) { + const ref = React.useRef(null) + + React.useEffect(() => { + // Only run in the visible render (not the hidden registration pass) + // Check if we're inside a display:none container + if (ref.current && ref.current.offsetParent === null) { + return + } + + // If no name yet, "fetch" and update (simulates async user lookup) + if (!token.name && token.raw.startsWith('@')) { + const timeout = setTimeout(() => { + update({ name: 'User Name' }) + }, 10) + return () => clearTimeout(timeout) + } + }, [token.raw, token.name, update]) + + return ( + + {token.name ?? token.raw} + + ) +} + +/** + * Test fixture with auto-updating diverged tokens. + * Each @mention triggers an update() call after mount. + */ +export function AutoUpdateInlay({ initial }: { initial: string }) { + const [value, setValue] = React.useState(initial) + + const plugins: Plugin[] = React.useMemo( + () => [ + { + props: {}, + matcher: createRegexMatcher('mention', { + regex: /@\w+/g, + transform: (m) => ({ raw: m[0] }) + }), + render: ({ token, update }) => ( + + ), + portal: () => null, + onInsert: () => {}, + onKeyDown: () => false + } + ], + [] + ) + + return ( + + ) +} diff --git a/src/inlay/__ct__/fixtures/controlled-token-inlay.tsx b/src/inlay/__ct__/fixtures/controlled-token-inlay.tsx new file mode 100644 index 0000000..9c5877a --- /dev/null +++ b/src/inlay/__ct__/fixtures/controlled-token-inlay.tsx @@ -0,0 +1,17 @@ +import React from 'react' +import { Inlay } from '../..' + +export function ControlledTokenInlay({ initial }: { initial: string }) { + const [value, setValue] = React.useState(initial) + return ( + + {value.includes('@x') ? ( + + @x + + ) : ( + + )} + + ) +} diff --git a/src/inlay/__ct__/fixtures/diverged-token-inlay.tsx b/src/inlay/__ct__/fixtures/diverged-token-inlay.tsx new file mode 100644 index 0000000..aa0b5f7 --- /dev/null +++ b/src/inlay/__ct__/fixtures/diverged-token-inlay.tsx @@ -0,0 +1,35 @@ +import React from 'react' +import { Inlay } from '../..' + +/** + * A fixture with a "diverged" token where the visual display + * differs from the raw value: + * - Raw value: "@alice" (7 chars) + * - Visual display: "Alice" (5 chars) + */ +export function DivergedTokenInlay({ + initial, + onValueChange +}: { + initial: string + onValueChange?: (value: string) => void +}) { + const [value, setValue] = React.useState(initial) + + const handleChange = (newValue: string) => { + setValue(newValue) + onValueChange?.(newValue) + } + + return ( + + {value.includes('@alice') ? ( + + Alice + + ) : ( + + )} + + ) +} diff --git a/src/inlay/__ct__/fixtures/mobile-inlay.tsx b/src/inlay/__ct__/fixtures/mobile-inlay.tsx new file mode 100644 index 0000000..7fb76c5 --- /dev/null +++ b/src/inlay/__ct__/fixtures/mobile-inlay.tsx @@ -0,0 +1,137 @@ +import React from 'react' +import { Inlay } from '../../index' +import { createRegexMatcher } from '../../internal/string-utils' +import type { Plugin } from '../../structured/plugins/plugin' + +const ITEMS = [ + { id: '1', label: 'Apple' }, + { id: '2', label: 'Banana' }, + { id: '3', label: 'Cherry' } +] + +type TokenData = { mention: string } + +const mentionMatcher = createRegexMatcher('mention', { + regex: /@\w+/g, + transform: (match) => ({ mention: match[0] }) +}) + +/** + * A fixture for testing mobile interactions with Inlay. + * Includes portal for testing touch-based portal navigation. + */ +export function MobileInlay({ + initial = '', + onSelect, + onKeyboardChange +}: { + initial?: string + onSelect?: (item: (typeof ITEMS)[number]) => void + onKeyboardChange?: (open: boolean) => void +}) { + const [selectedItem, setSelectedItem] = React.useState(null) + const [rawValue, setRawValue] = React.useState(initial) + const [keyboardOpen, setKeyboardOpen] = React.useState(false) + + const handleKeyboardChange = React.useCallback( + (open: boolean) => { + setKeyboardOpen(open) + onKeyboardChange?.(open) + }, + [onKeyboardChange] + ) + + const plugin: Plugin< + Record, + TokenData, + 'mention' + > = React.useMemo( + () => ({ + props: {}, + matcher: mentionMatcher, + render: ({ token }) => ( + + {token.mention} + + ), + portal: ({ replace }) => { + return ( + { + replace(`@${item.id} `) + setSelectedItem(item.label) + onSelect?.(item) + }} + data-testid="portal-list" + className="portal-list" + > + {ITEMS.map((item) => ( + + {item.label} + + ))} + + ) + }, + onInsert: () => {}, + onKeyDown: () => false + }), + [onSelect] + ) + + return ( + + + {selectedItem || 'none'} + {rawValue} + {keyboardOpen ? 'open' : 'closed'} + + ) +} + +/** + * Simple fixture for basic mobile touch tests without plugins. + */ +export function SimpleMobileInlay({ initial = '' }: { initial?: string }) { + const [value, setValue] = React.useState(initial) + + return ( + + + + User + + + {value} + + ) +} diff --git a/src/inlay/__ct__/fixtures/overlapping-plugins-inlay.tsx b/src/inlay/__ct__/fixtures/overlapping-plugins-inlay.tsx new file mode 100644 index 0000000..24f7beb --- /dev/null +++ b/src/inlay/__ct__/fixtures/overlapping-plugins-inlay.tsx @@ -0,0 +1,191 @@ +import React from 'react' +import { StructuredInlay } from '../../structured/structured-inlay' +import { createRegexMatcher } from '../../internal/string-utils' +import type { Plugin } from '../../structured/plugins/plugin' + +/** + * Plugin A: matches @test exactly + * Uses DIVERGED rendering: raw "@test" → visual "[A:test]" + */ +type PluginAData = { raw: string } + +function createPluginA(): Plugin { + return { + props: {}, + matcher: createRegexMatcher('pluginA', { + regex: /@test(?!\w)/g, + transform: (match) => ({ raw: match[0] }) + }), + render: ({ token }) => ( + + [A:test] + + ), + portal: () => null, + onInsert: () => {}, + onKeyDown: () => false + } +} + +/** + * Plugin B: also matches @test exactly (same range as Plugin A) + * Uses DIVERGED rendering: raw "@test" → visual "[B:test]" + * For exact-same-substring test: we can tell which plugin won by the visual + */ +type PluginBData = { raw: string } + +function createPluginB(): Plugin { + return { + props: {}, + matcher: createRegexMatcher('pluginB', { + regex: /@test(?!\w)/g, + transform: (match) => ({ raw: match[0] }) + }), + render: ({ token }) => ( + + [B:test] + + ), + portal: () => null, + onInsert: () => {}, + onKeyDown: () => false + } +} + +/** + * Plugin Short: matches @alice (shorter pattern) + * This creates a genuine overlap scenario with Plugin Long + * Raw "@alice" → visual "[short:alice]" + */ +type ShortData = { raw: string } + +function createShortPlugin(): Plugin { + return { + props: {}, + matcher: createRegexMatcher('short', { + // Matches @alice even when followed by more characters + regex: /@alice/g, + transform: (match) => ({ raw: match[0] }) + }), + render: ({ token }) => ( + + [short:alice] + + ), + portal: () => null, + onInsert: () => {}, + onKeyDown: () => false + } +} + +/** + * Plugin Long: matches @alice_vip (longer pattern, same start position) + * This should WIN over Short plugin when both match starting at same position + * Raw "@alice_vip" → visual "[long:alice_vip]" + */ +type LongData = { raw: string } + +function createLongPlugin(): Plugin { + return { + props: {}, + matcher: createRegexMatcher('long', { + regex: /@alice_vip/g, + transform: (match) => ({ raw: match[0] }) + }), + render: ({ token }) => ( + + [long:alice_vip] + + ), + portal: () => null, + onInsert: () => {}, + onKeyDown: () => false + } +} + +/** + * Plugin Hashtag: matches #hashtag pattern (for non-overlapping scenarios) + */ +type HashtagData = { raw: string } + +function createHashtagPlugin(): Plugin { + return { + props: {}, + matcher: createRegexMatcher('hashtag', { + regex: /#\w+/g, + transform: (match) => ({ raw: match[0] }) + }), + render: ({ token }) => ( + + {token.raw} + + ), + portal: () => null, + onInsert: () => {}, + onKeyDown: () => false + } +} + +/** + * Plugin Mention: matches @username (for non-overlapping scenarios) + */ +type MentionData = { raw: string } + +function createMentionPlugin(): Plugin { + return { + props: {}, + matcher: createRegexMatcher('mention', { + regex: /@\w+/g, + transform: (match) => ({ raw: match[0] }) + }), + render: ({ token }) => ( + + {token.raw} + + ), + portal: () => null, + onInsert: () => {}, + onKeyDown: () => false + } +} + +export type OverlapScenario = + | 'exact-same' // Two plugins match exact same @test - diverged renders let us see which won + | 'longer-wins' // @alice vs @alice_vip at same start position - longer should win + | 'non-overlapping' // @bob and #hashtag - both should render + +type Props = { + initial: string + scenario: OverlapScenario +} + +export function OverlappingPluginsInlay({ initial, scenario }: Props) { + const [value, setValue] = React.useState(initial) + + const plugins = React.useMemo(() => { + switch (scenario) { + case 'exact-same': + // Both plugins match @test at the same range + // Plugin A renders "[A:test]", Plugin B renders "[B:test]" + // We can tell which won by the visual output + return [createPluginA(), createPluginB()] as const + case 'longer-wins': + // Short matches "@alice" (6 chars), Long matches "@alice_vip" (10 chars) + // Both start at the same position, but Long is longer + // Long should win per longest-match-wins + return [createShortPlugin(), createLongPlugin()] as const + case 'non-overlapping': + // Different patterns that don't overlap + return [createMentionPlugin(), createHashtagPlugin()] as const + } + }, [scenario]) + + return ( + + ) +} diff --git a/src/inlay/__ct__/fixtures/portal-navigation-inlay.tsx b/src/inlay/__ct__/fixtures/portal-navigation-inlay.tsx new file mode 100644 index 0000000..eabd7b3 --- /dev/null +++ b/src/inlay/__ct__/fixtures/portal-navigation-inlay.tsx @@ -0,0 +1,92 @@ +import React from 'react' +import { Inlay } from '../../index' +import { createRegexMatcher } from '../../internal/string-utils' +import type { Plugin } from '../../structured/plugins/plugin' + +const ITEMS = [ + { id: '1', label: 'Apple' }, + { id: '2', label: 'Banana' }, + { id: '3', label: 'Cherry' } +] + +type TokenData = { mention: string; resolved?: boolean } + +const mentionMatcher = createRegexMatcher('mention', { + regex: /@\w+/g, // Use + to require at least one char after @ + transform: (match) => ({ mention: match[0] }) +}) + +/** + * A fixture for testing Inlay.Portal.List keyboard navigation. + * Uses Inlay.StructuredInlay with a mention-style plugin. + */ +export function PortalNavigationInlay({ + initial = '', + onSelect +}: { + initial?: string + onSelect?: (item: (typeof ITEMS)[number]) => void +}) { + const [selectedItem, setSelectedItem] = React.useState(null) + const [rawValue, setRawValue] = React.useState(initial) + + const plugin: Plugin< + Record, + TokenData, + 'mention' + > = React.useMemo( + () => ({ + props: {}, + matcher: mentionMatcher, + render: ({ token }) => ( + + {token.mention} + + ), + portal: ({ replace }) => { + // Always show portal for testing + return ( + { + replace(`@${item.id} `) + setSelectedItem(item.label) + setRawValue((prev) => prev.replace(/@\w*$/, `@${item.id} `)) + onSelect?.(item) + }} + data-testid="portal-list" + className="portal-list" + > + {ITEMS.map((item) => ( + + {item.label} + + ))} + + ) + }, + onInsert: () => {}, + onKeyDown: () => false + }), + [onSelect] + ) + + return ( + + + {selectedItem || 'none'} + {rawValue} + + ) +} diff --git a/src/inlay/__ct__/fixtures/structured-actions-inlay.tsx b/src/inlay/__ct__/fixtures/structured-actions-inlay.tsx new file mode 100644 index 0000000..54f3549 --- /dev/null +++ b/src/inlay/__ct__/fixtures/structured-actions-inlay.tsx @@ -0,0 +1,61 @@ +import React from 'react' +import { StructuredInlay } from '../../structured/structured-inlay' +import { createRegexMatcher } from '../../internal/string-utils' +import type { Plugin } from '../../structured/plugins/plugin' + +type TokenData = { raw: string; label?: string } + +/** + * Test fixture that exposes replace/update via buttons in the portal for easy testing + */ +export function StructuredActionsInlay({ initial }: { initial: string }) { + const [value, setValue] = React.useState(initial) + + const plugins: Plugin[] = React.useMemo( + () => [ + { + props: {}, + matcher: createRegexMatcher('mention', { + regex: /@\w+/g, + transform: (m) => ({ raw: m[0] }) + }), + render: ({ token }) => ( + {token.label ?? token.raw} + ), + portal: ({ replace, update }) => { + return ( + e.preventDefault()}> + update({ label: 'UpdatedLabel' })} + > + Update + + replace('@replaced')} + > + Replace + + + ) + }, + onInsert: () => {}, + onKeyDown: () => false + } + ], + [] + ) + + return ( + + + {value} + + ) +} diff --git a/src/inlay/__ct__/inlay.a11y.spec.tsx b/src/inlay/__ct__/inlay.a11y.spec.tsx new file mode 100644 index 0000000..06f5fcc --- /dev/null +++ b/src/inlay/__ct__/inlay.a11y.spec.tsx @@ -0,0 +1,51 @@ +import { test, expect } from '@playwright/experimental-ct-react' +import AxeBuilder from '@axe-core/playwright' +import { DivergedTokenInlay } from './fixtures/diverged-token-inlay' +import { Inlay } from '../..' + +test.describe('Inlay accessibility', () => { + test('empty state has no a11y violations', async ({ mount, page }) => { + await mount() + const results = await new AxeBuilder({ page }) + .include('[role="textbox"]') + .analyze() + expect(results.violations).toEqual([]) + }) + + test('with tokens has no a11y violations', async ({ mount, page }) => { + await mount() + const results = await new AxeBuilder({ page }) + .include('[role="textbox"]') + .analyze() + expect(results.violations).toEqual([]) + }) + + test('focused state has no a11y violations', async ({ mount, page }) => { + await mount() + await page.getByRole('textbox').focus() + const results = await new AxeBuilder({ page }) + .include('[role="textbox"]') + .analyze() + expect(results.violations).toEqual([]) + }) + + test('has default aria-label', async ({ mount, page }) => { + await mount( + + + + ) + const editor = page.getByRole('textbox') + await expect(editor).toHaveAttribute('aria-label', 'Text input') + }) + + test('aria-label can be overridden', async ({ mount, page }) => { + await mount( + + + + ) + const editor = page.getByRole('textbox') + await expect(editor).toHaveAttribute('aria-label', 'Message composer') + }) +}) diff --git a/src/inlay/__ct__/inlay.basic.spec.tsx b/src/inlay/__ct__/inlay.basic.spec.tsx new file mode 100644 index 0000000..b623d29 --- /dev/null +++ b/src/inlay/__ct__/inlay.basic.spec.tsx @@ -0,0 +1,23 @@ +import { test, expect } from '@playwright/experimental-ct-react' +import { Inlay } from '../' + +test('basic typing/backspace', async ({ mount, page }) => { + await mount( + + + + ) + + const root = page.getByTestId('root') + await expect(root).toHaveCount(1) + + const ed = page.getByRole('textbox') + await ed.click() + await page.keyboard.press('ArrowRight') + await page.keyboard.press('ArrowRight') + await page.keyboard.type('c') + await expect(ed).toHaveText('abc') + for (let i = 0; i < 10; i++) await page.keyboard.press('ArrowRight') + await page.keyboard.press('Backspace') + await expect(ed).toHaveText('ab') +}) diff --git a/src/inlay/__ct__/inlay.clipboard.spec.tsx b/src/inlay/__ct__/inlay.clipboard.spec.tsx new file mode 100644 index 0000000..8f03867 --- /dev/null +++ b/src/inlay/__ct__/inlay.clipboard.spec.tsx @@ -0,0 +1,216 @@ +import { test, expect } from '@playwright/experimental-ct-react' +import { DivergedTokenInlay } from './fixtures/diverged-token-inlay' +import { AutoUpdateInlay } from './fixtures/auto-update-inlay' + +// Run clipboard tests serially to avoid clipboard state pollution between parallel tests +test.describe.serial('Clipboard operations with diverged tokens (CT)', () => { + // Skip on mobile-safari: WebKit's mobile emulation has different clipboard behavior + // that causes copy/paste operations to behave unexpectedly. Desktop webkit passes. + test.beforeEach(async ({}, testInfo) => { + test.skip( + testInfo.project.name === 'mobile-safari', + 'Skipped on mobile-safari: clipboard behavior differs in mobile WebKit emulation' + ) + }) + test('Select all (Ctrl/Cmd+a) selects entire content', async ({ + mount, + page + }) => { + await mount() + + const ed = page.getByRole('textbox') + await ed.click() + await expect(ed).toHaveText('Hello World') + + await page.keyboard.press('ControlOrMeta+a') + await page.keyboard.press('Backspace') + await expect(ed).toHaveText('') + }) + + test('Copy and paste a diverged token preserves raw value', async ({ + mount, + page + }) => { + await mount() + + const ed = page.getByRole('textbox') + await ed.click() + await expect(ed).toHaveText('Hello Alice!') + + // Navigate to token: start → right 6 (past "Hello ") → select token + for (let i = 0; i < 20; i++) await page.keyboard.press('ArrowLeft') + for (let i = 0; i < 6; i++) await page.keyboard.press('ArrowRight') + await page.keyboard.press('Shift+ArrowRight') + + await page.keyboard.press('ControlOrMeta+c') + + // Move to end and paste + for (let i = 0; i < 20; i++) await page.keyboard.press('ArrowRight') + await page.keyboard.press('ControlOrMeta+v') + + await expect(ed).toHaveText('Hello Alice!Alice') + await expect(ed.locator('[data-token-text="@alice"]')).toHaveCount(2) + }) + + test('Copy text containing diverged token includes raw value', async ({ + mount, + page + }) => { + await mount() + + const ed = page.getByRole('textbox') + await ed.click() + await expect(ed).toHaveText('AAliceB') + + await page.keyboard.press('ControlOrMeta+a') + await page.keyboard.press('ControlOrMeta+c') + + // Clear and type X + await page.keyboard.press('Backspace') + await expect(ed).toHaveText('') + await page.keyboard.type('X') + await expect(ed).toHaveText('X') + + // Paste at end + await page.keyboard.press('ArrowRight') + await page.keyboard.press('ControlOrMeta+v') + + await expect(ed).toHaveText('XAAliceB') + await expect(ed.locator('[data-token-text="@alice"]')).toHaveCount(1) + }) + + test('Cut diverged token removes it and pastes raw value', async ({ + mount, + page + }) => { + await mount() + + const ed = page.getByRole('textbox') + await ed.click() + await expect(ed).toHaveText('XAliceY') + + // Navigate past X and select token + for (let i = 0; i < 20; i++) await page.keyboard.press('ArrowLeft') + await page.keyboard.press('ArrowRight') + await page.keyboard.press('Shift+ArrowRight') + + await page.keyboard.press('ControlOrMeta+x') + + await expect(ed).toHaveText('XY') + await expect(ed.locator('[data-token-text="@alice"]')).toHaveCount(0) + + // Paste at end + for (let i = 0; i < 20; i++) await page.keyboard.press('ArrowRight') + await page.keyboard.press('ControlOrMeta+v') + + await expect(ed).toHaveText('XYAlice') + await expect(ed.locator('[data-token-text="@alice"]')).toHaveCount(1) + }) + + test('Paste plain text into editor works correctly', async ({ + mount, + page + }) => { + await mount() + + const ed = page.getByRole('textbox') + await ed.click() + + // Move to end + for (let i = 0; i < 20; i++) await page.keyboard.press('ArrowRight') + + // Write to clipboard and paste using real keyboard shortcut + await page.evaluate(() => navigator.clipboard.writeText('world')) + await page.keyboard.press('ControlOrMeta+v') + + await expect(ed).toHaveText('Hello world') + }) + + test('Paste text that matches token pattern creates new token', async ({ + mount, + page + }) => { + await mount() + + const ed = page.getByRole('textbox') + await ed.click() + + // Move to end + for (let i = 0; i < 20; i++) await page.keyboard.press('ArrowRight') + + // Write to clipboard and paste using real keyboard shortcut + await page.evaluate(() => navigator.clipboard.writeText('@alice')) + await page.keyboard.press('ControlOrMeta+v') + + await expect(ed.locator('[data-token-text="@alice"]')).toHaveCount(1) + await expect(ed).toHaveText('Hi Alice') + }) + + test('Rapid paste maintains caret at end of inserted text', async ({ + mount, + page + }) => { + await mount() + + const ed = page.getByRole('textbox') + await ed.click() + + // Write text to clipboard + await page.evaluate(() => navigator.clipboard.writeText('abc')) + + // Simulate rapid pasting (like holding Ctrl+V) + // Fire multiple paste events in quick succession without waiting + const pasteCount = 5 + for (let i = 0; i < pasteCount; i++) { + await page.keyboard.press('ControlOrMeta+v', { delay: 0 }) + } + + // Expected: "abcabcabcabcabc" with caret at position 15 (end) + await expect(ed).toHaveText('abcabcabcabcabc') + + // Type a character to verify caret position - should appear at the end + await page.keyboard.type('X') + await expect(ed).toHaveText('abcabcabcabcabcX') + }) + + test('Pasting many auto-updating tokens does not cause infinite loop', async ({ + mount, + page + }) => { + // Capture React errors (error #185 = Maximum update depth exceeded) + const errors: string[] = [] + page.on('pageerror', (err) => errors.push(err.message)) + + await mount() + + const ed = page.getByRole('textbox') + await ed.click() + + // Paste many tokens that will each trigger an update() call + const manyTokens = Array(100).fill('@test').join(' ') + ' ' + await page.evaluate( + (text) => navigator.clipboard.writeText(text), + manyTokens + ) + await page.keyboard.press('ControlOrMeta+v') + + // Wait for all updates to process + await page.waitForTimeout(500) + + // Should not have crashed with "Maximum update depth exceeded" + expect(errors).toHaveLength(0) + + // Should render exactly 100 tokens (no duplicates) in the visible editor + // Note: 2-pass rendering means tokens exist in both hidden and visible divs + const editor = page.getByRole('textbox') + await expect(editor.locator('[data-testid="token-render"]')).toHaveCount( + 100, + { timeout: 5000 } + ) + + // Caret should be at end after all updates, not position 0 + await page.keyboard.type('X') + const text = await editor.textContent() + expect(text?.endsWith('X')).toBe(true) + }) +}) diff --git a/src/inlay/__ct__/inlay.composition.cdp.spec.tsx b/src/inlay/__ct__/inlay.composition.cdp.spec.tsx new file mode 100644 index 0000000..63fffcb --- /dev/null +++ b/src/inlay/__ct__/inlay.composition.cdp.spec.tsx @@ -0,0 +1,78 @@ +import { test, expect, type Locator } from '@playwright/experimental-ct-react' +import { Inlay } from '../' + +// Chromium-only: use CDP to simulate IME composition end-to-end +const composeWithCDP = async ( + page: import('@playwright/test').Page, + text: string +) => { + const client = await page.context().newCDPSession(page) + await client.send('Input.imeSetComposition', { + text, + selectionStart: text.length, + selectionEnd: text.length + }) + await client.send('Input.insertText', { text }) + await client.send('Input.imeSetComposition', { + text: '', + selectionStart: 0, + selectionEnd: 0 + }) +} + +// Check that composition didn't leave broken DOM structure (multiple text nodes, etc.) +async function assertCleanTextContent(ed: Locator): Promise { + return ed.evaluate((el: HTMLElement) => { + // The editor should not have stray elements or deeply nested structures + // after composition. Text content check is the primary validation. + const brs = el.querySelectorAll('br') + return brs.length === 0 + }) +} + +test.describe.serial('IME composition via CDP (Chromium)', () => { + test.skip( + ({ browserName }) => browserName !== 'chromium', + 'CDP IME APIs are Chromium-only' + ) + + test('Space commit produces composed text and a trailing space', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + const ed = page.getByRole('textbox') + await ed.click() + + await composeWithCDP(page, 'にほん') + await page.keyboard.press('Space') + + await expect(ed).toHaveText('にほん ') + const ok = await assertCleanTextContent(ed) + expect(ok).toBe(true) + }) + + test('Enter commit composes text and does not add a stray newline immediately', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + const ed = page.getByRole('textbox') + await ed.click() + + await composeWithCDP(page, 'テスト') + await page.keyboard.press('Enter') + + await expect(ed).toHaveText('テスト') + const ok = await assertCleanTextContent(ed) + expect(ok).toBe(true) + }) +}) diff --git a/src/inlay/__ct__/inlay.grapheme.spec.tsx b/src/inlay/__ct__/inlay.grapheme.spec.tsx new file mode 100644 index 0000000..7e73627 --- /dev/null +++ b/src/inlay/__ct__/inlay.grapheme.spec.tsx @@ -0,0 +1,149 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { test, expect } from '@playwright/experimental-ct-react' +import { Inlay } from '../' + +test.describe('Grapheme handling (CT)', () => { + test('Backspace deletes an entire emoji grapheme cluster', async ({ + mount, + page + }) => { + const cluster = '👍🏼' + await mount( + + + + ) + + page.on('console', (msg) => console.log('EMOJI TEST LOG:', msg.text())) + + const ed = page.getByRole('textbox') + await ed.click() + // Normalize caret to start by overshooting ArrowLeft + for (let i = 0; i < 10; i++) await page.keyboard.press('ArrowLeft') + // Move caret to end using ArrowRight (cluster is one grapheme) + await page.keyboard.press('ArrowRight') + + await page.keyboard.press('Backspace') + await expect(ed).toHaveText('') + }) + + test('Backspace with selection slicing through a grapheme removes the whole grapheme', async ({ + mount, + page + }) => { + const cluster = '👍🏼' + const text = `a${cluster}b` + await mount( + + + + ) + + const ed = page.getByRole('textbox') + await ed.click() + // Normalize caret to start by overshooting ArrowLeft + for (let i = 0; i < 10; i++) await page.keyboard.press('ArrowLeft') + // Move to just before the grapheme (after the initial 'a') + await page.keyboard.press('ArrowRight') + // Extend selection across exactly one grapheme + await page.keyboard.press('Shift+ArrowRight') + + await page.keyboard.press('Backspace') + await expect(ed).toHaveText('ab') + }) + + test('Backspace deletes entire flag grapheme (regional indicators) before caret', async ({ + mount, + page + }) => { + const flag = '🇺🇸' + await mount( + + + + ) + + const ed = page.getByRole('textbox') + await ed.click() + // Normalize caret to start by overshooting ArrowLeft + for (let i = 0; i < 10; i++) await page.keyboard.press('ArrowLeft') + // Move to after 'a' and then after the flag grapheme + await page.keyboard.press('ArrowRight') + await page.keyboard.press('ArrowRight') + + await page.keyboard.press('Backspace') + await expect(ed).toHaveText('ab') + }) + + test('Backspace deletes composed character with combining mark as a single grapheme', async ({ + mount, + page + }) => { + const composed = 'e\u0301' + await mount( + + + + ) + + const ed = page.getByRole('textbox') + await ed.click() + // Move cursor to end + await page.keyboard.press('End') + + // Capture page console logs + page.on('console', (msg) => console.log('PAGE:', msg.text())) + + // Test log to verify capturing works + await page.evaluate(() => console.log('TEST LOG FROM PAGE')) + + // Check if React's onBeforeInput is attached + await page.evaluate(() => { + const editor = document.querySelector('[role="textbox"]') as HTMLElement + const reactKey = Object.keys(editor).find((k) => + k.startsWith('__reactProps$') + ) + if (reactKey) { + const props = (editor as any)[reactKey] + console.log( + 'React props keys:', + Object.keys(props || {}).filter((k) => + k.toLowerCase().includes('input') + ) + ) + console.log('Has onBeforeInput:', typeof props?.onBeforeInput) + } else { + console.log('No React props found') + } + }) + + // Check value before backspace + const valueBefore = await page.evaluate(() => { + const editor = document.querySelector('[role="textbox"]') as HTMLElement + return { + textContent: editor.textContent, + innerHTML: editor.innerHTML, + charCodes: Array.from(editor.textContent || '').map((c) => + c.charCodeAt(0) + ) + } + }) + console.log('Before backspace:', JSON.stringify(valueBefore)) + + await page.keyboard.press('Backspace') + + // Check what the actual text content is + const valueAfter = await page.evaluate(() => { + const editor = document.querySelector('[role="textbox"]') as HTMLElement + return { + textContent: editor.textContent, + charCodes: Array.from(editor.textContent || '').map((c) => + c.charCodeAt(0) + ) + } + }) + console.log('After backspace:', JSON.stringify(valueAfter)) + + await expect(ed).toHaveText('') + }) +}) diff --git a/src/inlay/__ct__/inlay.ios-swipe-text.spec.tsx b/src/inlay/__ct__/inlay.ios-swipe-text.spec.tsx new file mode 100644 index 0000000..d7901dd --- /dev/null +++ b/src/inlay/__ct__/inlay.ios-swipe-text.spec.tsx @@ -0,0 +1,1394 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { test, expect } from '@playwright/experimental-ct-react' +import { Inlay } from '../' + +/** + * iOS Swipe-Text Bug Reproduction + * + * THE BUG (from real iOS device testing): + * When user swipe-types "hello" and presses backspace ONCE: + * 1. iOS fires a SINGLE insertText event with data="hello" (whole word) + * 2. iOS fires a SINGLE deleteContentBackward event with targetRanges=4-5 (last char only!) + * 3. If we DON'T preventDefault, iOS fires 5 rapid deleteContentBackward events + * and natively deletes the whole word + * 4. But if we DO preventDefault on the first event, iOS stops sending the + * remaining events, so we only see the range for the last char + * + * EXPECTED: Entire swipe-typed word deleted + * ACTUAL BUG: Only last char deleted, "hello" -> "hell" + * + * THE FIX: Track multi-char inserts, and if deleteContentBackward happens + * immediately after at the end of the inserted chunk, delete the whole chunk. + * + * SPACE PRESERVATION: When swipe-typing after existing text, iOS inserts + * " word" (with leading space). On backspace, only "word" should be deleted, + * preserving the auto-inserted space. + * + * NOTE: These tests use synthetic events to simulate iOS/Android native IME behavior. + * They pass on desktop browsers but fail on Playwright's mobile-safari emulation + * because WebKit in mobile touch mode has different event handling. The mobile-safari + * project is NOT real iOS Safari - it's desktop WebKit with iPhone viewport/user-agent. + * Real iOS testing requires actual devices or cloud device farms. + */ + +test.describe('iOS swipe-text bug', () => { + // Skip on mobile-safari: Playwright's mobile-safari is desktop WebKit with mobile viewport, + // not real iOS Safari. It has bugs with keyboard input (text reversal) and synthetic events. + test.beforeEach(async ({}, testInfo) => { + test.skip( + testInfo.project.name === 'mobile-safari', + 'Skipped on mobile-safari: cannot emulate iOS native IME behavior' + ) + }) + + /** + * Test that input works after deleting content. + * Verifies no crash when typing after deletion. + */ + test('input after deleting swipe-typed word should not crash', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + await page.keyboard.press('End') + await expect(ed).toHaveText('hello') + + // Delete all characters with delay between presses + for (let i = 0; i < 5; i++) { + await page.keyboard.press('Backspace') + await page.waitForTimeout(30) + } + + // Should be empty (or have zero-width space) + await expect(ed).toHaveText(/^[\u200B]?$/) + + // Wait for selection to settle + await page.waitForTimeout(50) + + // Type new text - should not crash + await page.keyboard.type('world', { delay: 30 }) + + await expect(ed).toHaveText('world') + }) + + /** + * Test that selection updates work correctly even after DOM nodes are replaced. + * This tests the robustness of setDomSelection when React re-renders. + */ + test('setDomSelection should not crash when nodes are replaced', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + await page.keyboard.press('End') + await expect(ed).toHaveText('hello') + + // Delete all characters - this causes DOM nodes to be replaced + for (let i = 0; i < 5; i++) { + await page.keyboard.press('Backspace') + await page.waitForTimeout(30) + } + + // Should be empty + await expect(ed).toHaveText(/^[\u200B]?$/) + + // Wait for selection to settle + await page.waitForTimeout(50) + + // Insert new text - should work without crash + await page.keyboard.type('x') + + await expect(ed).toHaveText('x') + }) + + /** + * CRASH BUG VARIANT: Selection points outside editor after deletion. + * On iOS, after rapid deletions the selection may end up pointing to + * a node that's no longer in the editor, causing getAbsoluteOffset to fail. + */ + test('insert should handle selection outside editor gracefully', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + await page.keyboard.press('End') + + const result = await page.evaluate(async () => { + const editor = document.querySelector('[role="textbox"]') as HTMLElement + + const reactPropsKey = Object.keys(editor).find((k) => + k.startsWith('__reactProps$') + ) + if (!reactPropsKey) return { error: 'No React props' } + + const reactProps = (editor as any)[reactPropsKey] + const onBeforeInput = reactProps?.onBeforeInput + if (!onBeforeInput) return { error: 'No onBeforeInput' } + + // Force selection to be outside the editor (simulating iOS bug) + const bodyTextNode = document.createTextNode('outside') + document.body.appendChild(bodyTextNode) + const sel = window.getSelection() + sel?.removeAllRanges() + const badRange = document.createRange() + badRange.setStart(bodyTextNode, 0) + badRange.setEnd(bodyTextNode, 0) + sel?.addRange(badRange) + + let insertError: string | undefined + + const insertEvent = { + type: 'beforeinput', + inputType: 'insertText', + data: 'x', + preventDefault: () => {}, + stopPropagation: () => {}, + nativeEvent: { + type: 'beforeinput', + inputType: 'insertText', + data: 'x', + getTargetRanges: () => [] + } + } + + try { + onBeforeInput(insertEvent) + } catch (e) { + insertError = String(e) + } + + // Cleanup + document.body.removeChild(bodyTextNode) + + await new Promise((r) => setTimeout(r, 50)) + + return { + insertError, + finalText: editor.textContent + } + }) + + console.log('Result:', JSON.stringify(result, null, 2)) + + // Should NOT crash - should handle gracefully + expect(result.insertError).toBeUndefined() + }) + + /** + * iOS VALUE DIVERGENCE BUG: + * After swipe-typing and pressing backspace, the React value state + * must stay in sync with the DOM. + * + * This test verifies that backspace via native beforeinput correctly + * updates the value even when coming from swipe-text scenarios. + */ + test('backspace via beforeinput after insert should update value', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + + // Ensure we're at the end of the content + await page.keyboard.press('End') + await expect(ed).toHaveText('hello') + + // Press backspace - this fires native beforeinput which our handler catches + await page.keyboard.press('Backspace') + + // Value should update from "hello" to "hell" + await expect(ed).toHaveText('hell') + }) + + /** + * iOS Safari crash: backspace then swipe-type causes "The object can not be found here" + * + * This test verifies that pressing backspace then typing doesn't crash + * after DOM updates from deletion. + */ + test('iOS Safari: backspace then textInput should not crash', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + await page.keyboard.press('End') + + // Step 1: Backspace to delete "o" + await page.keyboard.press('Backspace') + await expect(ed).toHaveText('hell') + + // Wait for selection to settle after React re-render + await page.waitForTimeout(50) + + // Step 2: Type new text (one char at a time with delay to avoid race) + await page.keyboard.type('world', { delay: 30 }) + + // Should have backspaced then typed + await expect(ed).toHaveText('hellworld') + }) + + /** + * Test that text insertion works correctly via real browser events. + * Note: iOS Safari's legacy textInput event is handled by the native + * beforeinput listener on real iOS devices. + */ + test('iOS Safari textInput event should insert text', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + + // Type text using real browser events + await page.keyboard.type('hello') + + // Text should be inserted + await expect(ed).toHaveText('hello') + }) + + test('swipe-text after backspace should not crash', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + await page.keyboard.press('End') + await expect(ed).toHaveText('hello') + + // Delete all characters with backspace (with delay to avoid race conditions) + for (let i = 0; i < 5; i++) { + await page.keyboard.press('Backspace') + await page.waitForTimeout(30) + } + + // Wait for deletions to complete + await expect(ed).toHaveText(/^[\u200B]?$/) // empty or zero-width space + + // Wait for selection to settle + await page.waitForTimeout(50) + + // Now type new text + await page.keyboard.type('world', { delay: 30 }) + + // Text should be "world" after inserting + await expect(ed).toHaveText('world') + }) + + /** + * iOS swipe-text word deletion - ACTUAL BEHAVIOR (from real device testing): + * + * When user swipe-types "hello" and presses backspace ONCE: + * 1. iOS fires a SINGLE insertText event with data="hello" (whole word) + * 2. iOS fires a SINGLE deleteContentBackward event with targetRanges=4-5 (last char only!) + * 3. BUT if we don't preventDefault, iOS natively deletes the whole word + * + * The bug: When we preventDefault and handle it ourselves, we only see the + * targetRange for the last character and delete only that character. + * + * The fix (Option 2): Track when a multi-char insert happens, and if + * deleteContentBackward is pressed immediately after at the end of that + * inserted chunk, delete the whole chunk. + */ + test('iOS swipe-text: backspace after swipe-typed word should delete entire word', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + + // Simulate iOS swipe-text behavior + const result = await page.evaluate(async () => { + const editor = document.querySelector('[role="textbox"]') as HTMLElement + + // Step 1: Simulate swipe-typing "hello" - iOS sends a single insertText with the whole word + const insertEvent = new InputEvent('beforeinput', { + inputType: 'insertText', + data: 'hello', + bubbles: true, + cancelable: true + }) + + // Set up a collapsed range at position 0 for the insertion point + const sel = window.getSelection() + if (sel && sel.rangeCount > 0) { + const range = sel.getRangeAt(0) + ;(insertEvent as any).getTargetRanges = () => [ + { + startContainer: range.startContainer, + startOffset: range.startOffset, + endContainer: range.endContainer, + endOffset: range.endOffset, + collapsed: true + } + ] + } else { + ;(insertEvent as any).getTargetRanges = () => [] + } + + editor.dispatchEvent(insertEvent) + + // Wait for React to process the insert + await new Promise((r) => setTimeout(r, 50)) + + const afterInsert = editor.textContent?.replace(/\u200B/g, '') + + // Step 2: Simulate pressing backspace - iOS sends ONE deleteContentBackward + // with targetRange covering only the LAST character (4-5), NOT the whole word + const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT) + let textNode: Text | null = null + while (walker.nextNode()) { + const node = walker.currentNode as Text + if (node.textContent && node.textContent.length > 0) { + textNode = node + break + } + } + + if (!textNode) { + return { error: 'No text node found after insert', afterInsert } + } + + const deleteEvent = new InputEvent('beforeinput', { + inputType: 'deleteContentBackward', + bubbles: true, + cancelable: true + }) + + // iOS only provides targetRange for the LAST character, not the whole word! + // This is the key part of the bug - even though iOS would natively delete + // the whole word, the beforeinput event only reports the last char range. + const textLen = textNode.textContent?.length || 0 + ;(deleteEvent as any).getTargetRanges = () => [ + { + startContainer: textNode, + startOffset: textLen - 1, // e.g., 4 for "hello" + endContainer: textNode, + endOffset: textLen, // e.g., 5 for "hello" + collapsed: false + } + ] + + editor.dispatchEvent(deleteEvent) + + // Wait for React to process the delete + await new Promise((r) => setTimeout(r, 50)) + + return { + afterInsert, + finalText: editor.textContent, + finalLength: editor.textContent?.replace(/\u200B/g, '').length + } + }) + + console.log('Result:', JSON.stringify(result, null, 2)) + + // Verify the insert worked + expect(result.afterInsert).toBe('hello') + + // EXPECTED: Entire swipe-typed word should be deleted + // ACTUAL BUG: Only last char deleted, "hello" -> "hell" + expect(result.finalLength).toBe(0) + }) + + /** + * iOS swipe-text space preservation: + * + * When user has "hello|" and swipe-types "world", iOS inserts " world" (with leading space). + * When backspace is pressed, iOS native deletes only "world" and preserves the space. + * Result should be "hello " (with trailing space), not "hello" (no space). + */ + test('iOS swipe-text: backspace after swipe-typed word should preserve auto-inserted space', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + await page.keyboard.press('End') + await expect(ed).toHaveText('hello') + + // Simulate iOS swipe-text behavior with leading space + const result = await page.evaluate(async () => { + const editor = document.querySelector('[role="textbox"]') as HTMLElement + + // Step 1: Simulate swipe-typing " world" (with leading space) after "hello" + // iOS automatically adds a space when swipe-typing after existing text + const insertEvent = new InputEvent('beforeinput', { + inputType: 'insertText', + data: ' world', // Note the leading space! + bubbles: true, + cancelable: true + }) + + const sel = window.getSelection() + if (sel && sel.rangeCount > 0) { + const range = sel.getRangeAt(0) + ;(insertEvent as any).getTargetRanges = () => [ + { + startContainer: range.startContainer, + startOffset: range.startOffset, + endContainer: range.endContainer, + endOffset: range.endOffset, + collapsed: true + } + ] + } else { + ;(insertEvent as any).getTargetRanges = () => [] + } + + editor.dispatchEvent(insertEvent) + + // Wait for React to process the insert + await new Promise((r) => setTimeout(r, 50)) + + const afterInsert = editor.textContent?.replace(/\u200B/g, '') + + // Step 2: Simulate pressing backspace + const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT) + let textNode: Text | null = null + while (walker.nextNode()) { + const node = walker.currentNode as Text + if (node.textContent && node.textContent.length > 0) { + textNode = node + break + } + } + + if (!textNode) { + return { error: 'No text node found after insert', afterInsert } + } + + const deleteEvent = new InputEvent('beforeinput', { + inputType: 'deleteContentBackward', + bubbles: true, + cancelable: true + }) + + // iOS only provides targetRange for the last character + const textLen = textNode.textContent?.length || 0 + ;(deleteEvent as any).getTargetRanges = () => [ + { + startContainer: textNode, + startOffset: textLen - 1, + endContainer: textNode, + endOffset: textLen, + collapsed: false + } + ] + + editor.dispatchEvent(deleteEvent) + + // Wait for React to process the delete + await new Promise((r) => setTimeout(r, 50)) + + return { + afterInsert, + finalText: editor.textContent?.replace(/\u200B/g, ''), + // Check if trailing space is preserved + hasTrailingSpace: editor.textContent + ?.replace(/\u200B/g, '') + .endsWith(' ') + } + }) + + console.log('Result:', JSON.stringify(result, null, 2)) + + // Verify the insert worked (including the space) + expect(result.afterInsert).toBe('hello world') + + // EXPECTED: "world" deleted but space preserved -> "hello " + // ACTUAL BUG: Both space and word deleted -> "hello" + expect(result.finalText).toBe('hello ') + expect(result.hasTrailingSpace).toBe(true) + }) +}) + +/** + * iOS Safari Text Suggestion Bug + * + * THE BUG (from real iOS device testing): + * When user types "hel" and taps a keyboard suggestion "hello": + * 1. iOS fires insertReplacementText with data=null but dataTransfer="hello" + * 2. Our code checks `if (!data) return` and bails out + * 3. iOS then fires insertText with data=" " to insert a space + * 4. Result: "hel" becomes " " instead of "hello" + * + * EXPECTED: "hel" → "hello" (the suggested word) + * ACTUAL BUG: "hel" → " " (just a space) + * + * THE FIX: Check event.dataTransfer.getData('text/plain') as fallback when data is null + */ +test.describe('iOS Safari text suggestion', () => { + // Skip on mobile-safari: cannot emulate iOS native IME behavior + test.beforeEach(async ({}, testInfo) => { + test.skip( + testInfo.project.name === 'mobile-safari', + 'Skipped on mobile-safari: cannot emulate iOS native IME behavior' + ) + }) + + /** + * iOS Safari sends insertReplacementText with the replacement text in + * dataTransfer instead of data (unlike Android GBoard which uses data). + * + * Note: This test is skipped on webkit/mobile-safari because the test environment + * doesn't support passing DataTransfer to InputEvent constructor. The fix has been + * verified on real iOS Safari devices via console logging. + */ + test('tapping a keyboard suggestion should replace in-progress word', async ({ + mount, + page, + browserName + }) => { + // Skip webkit in test environment - DataTransfer constructor doesn't work the same way + // Real iOS Safari behavior verified via console logging on actual device + test.skip( + browserName === 'webkit', + 'webkit test env does not support DataTransfer in InputEvent constructor' + ) + + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + + // Step 1: Type "hel" (simulating user typing before suggestion) + await page.keyboard.type('hel') + await expect(ed).toHaveText('hel') + + // Step 2: Simulate iOS Safari text suggestion tap + // iOS sends insertReplacementText with data=null and replacement in dataTransfer + const result = await page.evaluate(async () => { + const editor = document.querySelector('[role="textbox"]') as HTMLElement + + // Find the text node containing "hel" + const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT) + let textNode: Text | null = null + while (walker.nextNode()) { + const node = walker.currentNode as Text + if (node.textContent && node.textContent.includes('hel')) { + textNode = node + break + } + } + + if (!textNode) { + return { + error: 'No text node found', + editorContent: editor.textContent + } + } + + // Create a mock DataTransfer with the suggestion text + const dataTransfer = new DataTransfer() + dataTransfer.setData('text/plain', 'hello') + + // Create the insertReplacementText event (iOS Safari style) + const replaceEvent = new InputEvent('beforeinput', { + inputType: 'insertReplacementText', + data: null, // iOS Safari puts null here! + dataTransfer: dataTransfer, + bubbles: true, + cancelable: true + }) + + // Set up getTargetRanges to return the range to replace ("hel" at 0-3) + const textContent = textNode.textContent || '' + const helStart = textContent.indexOf('hel') + ;(replaceEvent as any).getTargetRanges = () => [ + { + startContainer: textNode, + startOffset: helStart >= 0 ? helStart : 0, + endContainer: textNode, + endOffset: helStart >= 0 ? helStart + 3 : 3, + collapsed: false + } + ] + + editor.dispatchEvent(replaceEvent) + + // Wait for React to process + await new Promise((r) => setTimeout(r, 50)) + + return { + finalText: editor.textContent?.replace(/\u200B/g, ''), + wasReplaced: editor.textContent?.includes('hello') + } + }) + + console.log('iOS text suggestion result:', JSON.stringify(result, null, 2)) + + // EXPECTED: "hel" should be replaced with "hello" + // ACTUAL BUG: "hel" remains unchanged because we bail out when data is null + expect(result.error).toBeUndefined() + expect(result.finalText).toBe('hello') + expect(result.wasReplaced).toBe(true) + }) + + /** + * Verify that Android-style insertReplacementText (with data in event.data) + * still works after the iOS fix. + */ + test('Android GBoard style suggestion should still work', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + + // Type "hel" + await page.keyboard.type('hel') + await expect(ed).toHaveText('hel') + + // Simulate Android GBoard style - data is in event.data (not dataTransfer) + const result = await page.evaluate(async () => { + const editor = document.querySelector('[role="textbox"]') as HTMLElement + + const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT) + let textNode: Text | null = null + while (walker.nextNode()) { + const node = walker.currentNode as Text + if (node.textContent && node.textContent.includes('hel')) { + textNode = node + break + } + } + + if (!textNode) { + return { error: 'No text node found' } + } + + // Android style: data is in event.data + const replaceEvent = new InputEvent('beforeinput', { + inputType: 'insertReplacementText', + data: 'hello', // Android puts the replacement here + bubbles: true, + cancelable: true + }) + + const textContent = textNode.textContent || '' + const helStart = textContent.indexOf('hel') + ;(replaceEvent as any).getTargetRanges = () => [ + { + startContainer: textNode, + startOffset: helStart >= 0 ? helStart : 0, + endContainer: textNode, + endOffset: helStart >= 0 ? helStart + 3 : 3, + collapsed: false + } + ] + + editor.dispatchEvent(replaceEvent) + + await new Promise((r) => setTimeout(r, 50)) + + return { + finalText: editor.textContent?.replace(/\u200B/g, '') + } + }) + + expect(result.error).toBeUndefined() + expect(result.finalText).toBe('hello') + }) +}) + +/** + * iOS Swipe-Text Trailing Space Bug + * + * THE BUG (from real iOS device testing): + * When user swipe-types a word, iOS sends: + * 1. insertText with the swiped word (e.g., "hello") - multi-char, we track it + * 2. insertText with a single space " " - this CLEARS our tracking! + * 3. User presses backspace - tracking is null, so we only delete one char + * + * EXPECTED: Backspace after swipe+space should delete the swiped word + * ACTUAL BUG: Only deletes the trailing space because tracking was cleared + * + * THE FIX: When a single space follows a multi-char insert within a short + * time window, extend the tracking to include the space instead of clearing it. + */ +test.describe('iOS swipe-text trailing space', () => { + // Skip on mobile-safari: cannot emulate iOS native IME behavior + test.beforeEach(async ({}, testInfo) => { + test.skip( + testInfo.project.name === 'mobile-safari', + 'Skipped on mobile-safari: cannot emulate iOS native IME behavior' + ) + }) + + /** + * iOS sends a trailing space after swipe-typing, which clears our tracking. + * Backspace should still delete the whole swiped word. + */ + test('backspace after swipe + trailing space should delete swiped word', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + + const result = await page.evaluate(async () => { + const editor = document.querySelector('[role="textbox"]') as HTMLElement + + // Step 1: Simulate swipe-typing "hello" (multi-char insert) + const swipeEvent = new InputEvent('beforeinput', { + inputType: 'insertText', + data: 'hello', + bubbles: true, + cancelable: true + }) + + const sel = window.getSelection() + if (sel && sel.rangeCount > 0) { + const range = sel.getRangeAt(0) + ;(swipeEvent as any).getTargetRanges = () => [ + { + startContainer: range.startContainer, + startOffset: range.startOffset, + endContainer: range.endContainer, + endOffset: range.endOffset, + collapsed: true + } + ] + } else { + ;(swipeEvent as any).getTargetRanges = () => [] + } + + editor.dispatchEvent(swipeEvent) + await new Promise((r) => setTimeout(r, 30)) + + const afterSwipe = editor.textContent?.replace(/\u200B/g, '') + + // Step 2: iOS sends a trailing space as a SEPARATE single-char event + // This is the bug trigger - it clears our multi-char tracking! + const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT) + let textNode: Text | null = null + while (walker.nextNode()) { + const node = walker.currentNode as Text + if (node.textContent && node.textContent.length > 0) { + textNode = node + break + } + } + + if (!textNode) { + return { error: 'No text node found after swipe', afterSwipe } + } + + const spaceEvent = new InputEvent('beforeinput', { + inputType: 'insertText', + data: ' ', // Single space - this triggers the bug! + bubbles: true, + cancelable: true + }) + + const textLen = textNode.textContent?.length || 0 + ;(spaceEvent as any).getTargetRanges = () => [ + { + startContainer: textNode, + startOffset: textLen, + endContainer: textNode, + endOffset: textLen, + collapsed: true + } + ] + + editor.dispatchEvent(spaceEvent) + await new Promise((r) => setTimeout(r, 30)) + + const afterSpace = editor.textContent?.replace(/\u200B/g, '') + + // Step 3: Press backspace - should delete the whole swiped word + // Find the text node again after React re-render + const walker2 = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT) + let textNode2: Text | null = null + while (walker2.nextNode()) { + const node = walker2.currentNode as Text + if (node.textContent && node.textContent.length > 0) { + textNode2 = node + break + } + } + + if (!textNode2) { + return { + error: 'No text node found after space', + afterSwipe, + afterSpace + } + } + + const deleteEvent = new InputEvent('beforeinput', { + inputType: 'deleteContentBackward', + bubbles: true, + cancelable: true + }) + + const textLen2 = textNode2.textContent?.length || 0 + ;(deleteEvent as any).getTargetRanges = () => [ + { + startContainer: textNode2, + startOffset: textLen2 - 1, + endContainer: textNode2, + endOffset: textLen2, + collapsed: false + } + ] + + editor.dispatchEvent(deleteEvent) + await new Promise((r) => setTimeout(r, 50)) + + return { + afterSwipe, + afterSpace, + finalText: editor.textContent?.replace(/\u200B/g, ''), + // Check what was deleted + deletedCorrectly: + editor.textContent?.replace(/\u200B/g, '') === '' || + editor.textContent?.replace(/\u200B/g, '') === ' ' + } + }) + + console.log('Trailing space test result:', JSON.stringify(result, null, 2)) + + // Verify setup worked + expect(result.error).toBeUndefined() + expect(result.afterSwipe).toBe('hello') + expect(result.afterSpace).toBe('hello ') + + // EXPECTED: Entire swiped word deleted (leaving empty or just the space) + // ACTUAL BUG: Only one char deleted because tracking was cleared by space + // We expect the result to be empty string (whole word + space deleted) + // or just a space (word deleted, space preserved) + expect(result.finalText).toMatch(/^[ ]?$/) // Empty or single space + }) + + /** + * Multiple swipes in sequence - each swipe's trailing space should not + * break backspace behavior for the most recent swipe. + */ + test('backspace after multiple swipes should delete most recent word', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + + const result = await page.evaluate(async () => { + const editor = document.querySelector('[role="textbox"]') as HTMLElement + + const dispatchInsert = async (text: string) => { + const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT) + let textNode: Text | null = null + let textLen = 0 + while (walker.nextNode()) { + const node = walker.currentNode as Text + if (node.textContent) { + textNode = node + textLen = node.textContent.length + } + } + + const event = new InputEvent('beforeinput', { + inputType: 'insertText', + data: text, + bubbles: true, + cancelable: true + }) + + if (textNode) { + ;(event as any).getTargetRanges = () => [ + { + startContainer: textNode, + startOffset: textLen, + endContainer: textNode, + endOffset: textLen, + collapsed: true + } + ] + } else { + const sel = window.getSelection() + if (sel && sel.rangeCount > 0) { + const range = sel.getRangeAt(0) + ;(event as any).getTargetRanges = () => [ + { + startContainer: range.startContainer, + startOffset: range.startOffset, + endContainer: range.endContainer, + endOffset: range.endOffset, + collapsed: true + } + ] + } else { + ;(event as any).getTargetRanges = () => [] + } + } + + editor.dispatchEvent(event) + await new Promise((r) => setTimeout(r, 30)) + } + + // Swipe "hello" + trailing space + await dispatchInsert('hello') + await dispatchInsert(' ') + + // Swipe "world" + trailing space + await dispatchInsert('world') + await dispatchInsert(' ') + + const beforeDelete = editor.textContent?.replace(/\u200B/g, '') + + // Press backspace + const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT) + let textNode: Text | null = null + while (walker.nextNode()) { + const node = walker.currentNode as Text + if (node.textContent && node.textContent.length > 0) { + textNode = node + break + } + } + + if (!textNode) { + return { error: 'No text node', beforeDelete } + } + + const deleteEvent = new InputEvent('beforeinput', { + inputType: 'deleteContentBackward', + bubbles: true, + cancelable: true + }) + + const textLen = textNode.textContent?.length || 0 + ;(deleteEvent as any).getTargetRanges = () => [ + { + startContainer: textNode, + startOffset: textLen - 1, + endContainer: textNode, + endOffset: textLen, + collapsed: false + } + ] + + editor.dispatchEvent(deleteEvent) + await new Promise((r) => setTimeout(r, 50)) + + return { + beforeDelete, + finalText: editor.textContent?.replace(/\u200B/g, '') + } + }) + + console.log('Multiple swipes test result:', JSON.stringify(result, null, 2)) + + expect(result.error).toBeUndefined() + expect(result.beforeDelete).toBe('hello world ') + + // EXPECTED: "world " deleted, leaving "hello " or "hello" + // ACTUAL BUG: Only one char deleted, leaving "hello world" + expect(result.finalText).toMatch(/^hello ?$/) + }) +}) + +/** + * iOS Swipe-Text Double Space Prevention + * + * THE BUG (from real iOS device testing): + * When user swipes "hello", iOS auto-adds a trailing space → "hello " + * When user then swipes "world", iOS sends " world" (with leading space) + * Result: "hello world" with DOUBLE SPACE + * + * EXPECTED: "hello world" (single space between words) + * ACTUAL BUG: "hello world" (double space) + * + * THE FIX: When inserting a multi-char string that starts with a space, + * check if the character before is already a space. If so, strip the + * leading space from the insert to avoid double-spacing. + */ +test.describe('iOS swipe-text double space prevention', () => { + // Skip on mobile-safari: cannot emulate iOS native IME behavior + test.beforeEach(async ({}, testInfo) => { + test.skip( + testInfo.project.name === 'mobile-safari', + 'Skipped on mobile-safari: cannot emulate iOS native IME behavior' + ) + }) + + /** + * When swiping " world" after "hello " (which already has trailing space), + * the leading space should be stripped to avoid double-spacing. + */ + test('swipe after trailing space should not create double space', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + + const result = await page.evaluate(async () => { + const editor = document.querySelector('[role="textbox"]') as HTMLElement + + const dispatchInsert = async (text: string) => { + const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT) + let textNode: Text | null = null + let textLen = 0 + while (walker.nextNode()) { + const node = walker.currentNode as Text + if (node.textContent) { + textNode = node + textLen = node.textContent.length + } + } + + const event = new InputEvent('beforeinput', { + inputType: 'insertText', + data: text, + bubbles: true, + cancelable: true + }) + + if (textNode) { + ;(event as any).getTargetRanges = () => [ + { + startContainer: textNode, + startOffset: textLen, + endContainer: textNode, + endOffset: textLen, + collapsed: true + } + ] + } else { + const sel = window.getSelection() + if (sel && sel.rangeCount > 0) { + const range = sel.getRangeAt(0) + ;(event as any).getTargetRanges = () => [ + { + startContainer: range.startContainer, + startOffset: range.startOffset, + endContainer: range.endContainer, + endOffset: range.endOffset, + collapsed: true + } + ] + } else { + ;(event as any).getTargetRanges = () => [] + } + } + + editor.dispatchEvent(event) + await new Promise((r) => setTimeout(r, 30)) + } + + // Step 1: Swipe "hello" + await dispatchInsert('hello') + const afterHello = editor.textContent?.replace(/\u200B/g, '') + + // Step 2: iOS adds trailing space + await dispatchInsert(' ') + const afterSpace = editor.textContent?.replace(/\u200B/g, '') + + // Step 3: Swipe " world" (iOS sends with leading space) + await dispatchInsert(' world') + const afterWorld = editor.textContent?.replace(/\u200B/g, '') + + // Count spaces between hello and world + const match = afterWorld?.match(/hello( +)world/) + const spaceCount = match ? match[1].length : 0 + + return { + afterHello, + afterSpace, + afterWorld, + spaceCount, + hasDoubleSpace: afterWorld?.includes(' ') + } + }) + + console.log('Double space test result:', JSON.stringify(result, null, 2)) + + expect(result.afterHello).toBe('hello') + expect(result.afterSpace).toBe('hello ') + + // EXPECTED: "hello world" (single space) + // ACTUAL BUG: "hello world" (double space) + expect(result.afterWorld).toBe('hello world') + expect(result.spaceCount).toBe(1) + expect(result.hasDoubleSpace).toBe(false) + }) +}) + +/** + * iOS swipe after newline tests + * + * When swiping on a new line (after Enter), iOS often adds a leading space. + * This space should be stripped since it's at the start of a line. + */ +test.describe('iOS swipe-text after newline', () => { + // Skip on mobile-safari: cannot emulate iOS native IME behavior + test.beforeEach(async ({}, testInfo) => { + test.skip( + testInfo.project.name === 'mobile-safari', + 'Skipped on mobile-safari: cannot emulate iOS native IME behavior' + ) + }) + + /** + * When swiping "world" after "hello\n", the leading space should be stripped. + * iOS sends swipe data with leading space even at start of line. + */ + test('swipe after newline should not have leading space', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + + const result = await page.evaluate(async () => { + const editor = document.querySelector('[role="textbox"]') as HTMLElement + + // Helper to dispatch beforeinput and let it be handled + const dispatchBeforeInput = async ( + inputType: string, + data: string | null + ) => { + const event = new InputEvent('beforeinput', { + inputType, + data, + bubbles: true, + cancelable: true + }) + + const sel = window.getSelection() + if (sel && sel.rangeCount > 0) { + const range = sel.getRangeAt(0) + ;(event as any).getTargetRanges = () => [ + { + startContainer: range.startContainer, + startOffset: range.startOffset, + endContainer: range.endContainer, + endOffset: range.endOffset, + collapsed: range.collapsed + } + ] + } else { + ;(event as any).getTargetRanges = () => [] + } + + editor.dispatchEvent(event) + await new Promise((r) => setTimeout(r, 50)) + } + + // Step 1: Type "hello" (simulating char-by-char typing via swipe for simplicity) + await dispatchBeforeInput('insertText', 'hello') + + // Step 2: Press Enter (simulated via keydown) + const enterEvent = new KeyboardEvent('keydown', { + key: 'Enter', + bubbles: true, + cancelable: true + }) + editor.dispatchEvent(enterEvent) + await new Promise((r) => setTimeout(r, 50)) + + const afterEnter = editor.innerText?.replace(/\u200B/g, '').trim() + + // Step 3: Swipe " world" on the new line (iOS sends with leading space) + await dispatchBeforeInput('insertText', ' world') + + const finalValue = editor.innerText?.replace(/\u200B/g, '') + + return { + afterEnter, + finalValue, + startsWithSpaceOnLine2: finalValue?.split('\n')[1]?.startsWith(' ') + } + }) + + console.log('Swipe after newline result:', JSON.stringify(result, null, 2)) + + // The second line should NOT start with a space + expect(result.startsWithSpaceOnLine2).toBe(false) + // Final value should be "hello\nworld" not "hello\n world" + expect(result.finalValue).toMatch(/hello\n\s*world/) + expect(result.finalValue).not.toContain('\n ') + }) + + /** + * iOS sometimes sends a single space as a separate event BEFORE the swiped word. + * This space should be skipped entirely at the start of a line. + */ + test('single space before swipe word at start of line should be skipped', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + + const result = await page.evaluate(async () => { + const editor = document.querySelector('[role="textbox"]') as HTMLElement + + const dispatchBeforeInput = async ( + inputType: string, + data: string | null + ) => { + const event = new InputEvent('beforeinput', { + inputType, + data, + bubbles: true, + cancelable: true + }) + + const sel = window.getSelection() + if (sel && sel.rangeCount > 0) { + const range = sel.getRangeAt(0) + ;(event as any).getTargetRanges = () => [ + { + startContainer: range.startContainer, + startOffset: range.startOffset, + endContainer: range.endContainer, + endOffset: range.endOffset, + collapsed: range.collapsed + } + ] + } else { + ;(event as any).getTargetRanges = () => [] + } + + editor.dispatchEvent(event) + await new Promise((r) => setTimeout(r, 50)) + } + + // Step 1: Insert "hello" + await dispatchBeforeInput('insertText', 'hello') + + // Step 2: Press Enter + const enterEvent = new KeyboardEvent('keydown', { + key: 'Enter', + bubbles: true, + cancelable: true + }) + editor.dispatchEvent(enterEvent) + await new Promise((r) => setTimeout(r, 50)) + + // Step 3: iOS sends JUST a space first (separate event before the word) + await dispatchBeforeInput('insertText', ' ') + const afterSpace = editor.innerText?.replace(/\u200B/g, '') + + // Step 4: iOS sends the actual word + await dispatchBeforeInput('insertText', 'world') + const finalValue = editor.innerText?.replace(/\u200B/g, '') + + return { + afterSpace, + finalValue, + line2: finalValue?.split('\n')[1] + } + }) + + console.log( + 'Single space before swipe result:', + JSON.stringify(result, null, 2) + ) + + // The space should have been skipped, so line 2 should be "world" not " world" + expect(result.line2).toBe('world') + expect(result.finalValue).not.toContain('\n ') + }) +}) diff --git a/src/inlay/__ct__/inlay.mobile.spec.tsx b/src/inlay/__ct__/inlay.mobile.spec.tsx new file mode 100644 index 0000000..b6ec115 --- /dev/null +++ b/src/inlay/__ct__/inlay.mobile.spec.tsx @@ -0,0 +1,155 @@ +import { test, expect } from '@playwright/experimental-ct-react' +import { MobileInlay, SimpleMobileInlay } from './fixtures/mobile-inlay' + +// These tests run on mobile projects defined in playwright-ct.config.mts +// Run with: bun run test:ct -- --project=mobile-chrome +// or: bun run test:ct -- --project=mobile-safari + +test.describe('Mobile touch interaction', () => { + test('tap positions caret in editor', async ({ mount, page }) => { + await mount() + + const editor = page.getByRole('textbox') + await expect(editor).toHaveCount(1) + + // Click to focus (tap in mobile projects) + await editor.click() + await expect(editor).toBeFocused() + }) + + test('editor has correct mobile attributes', async ({ mount, page }) => { + await mount() + + const editor = page.getByRole('textbox') + await expect(editor).toHaveAttribute('inputmode', 'text') + await expect(editor).toHaveAttribute('autocapitalize', 'sentences') + // Note: autoCorrect is intentionally omitted (undefined) to let iOS use native behavior + // for keyboard suggestions. When not set, iOS defaults to system settings. + await expect(editor).toHaveAttribute('spellcheck', 'false') + }) + + test('editor has touch-action manipulation style', async ({ + mount, + page + }) => { + await mount() + + const editor = page.getByRole('textbox') + const touchAction = await editor.evaluate( + (el) => window.getComputedStyle(el).touchAction + ) + expect(touchAction).toBe('manipulation') + }) + + test('typing works after focus', async ({ mount, page }) => { + await mount() + + const editor = page.getByRole('textbox') + await editor.click() + + // Type one character at a time and verify it appears + await page.keyboard.type('a') + const rawValue = await page.getByTestId('raw-value').textContent() + expect(rawValue).toContain('a') + }) + + test('text editing works', async ({ mount, page }) => { + await mount() + + const editor = page.getByRole('textbox') + await editor.click() + await expect(editor).toBeFocused() + + // Type at caret position + await page.keyboard.type('!') + // Value should have changed + const rawValue = await page.getByTestId('raw-value').textContent() + expect(rawValue).toContain('!') + }) +}) + +test.describe('Portal interaction', () => { + test('portal items respond to click/tap', async ({ mount, page }) => { + await mount() + + const editor = page.getByRole('textbox') + await editor.click() + + // Portal should be visible when there's a mention token + const portal = page.getByTestId('portal') + await expect(portal).toBeVisible() + + // Click on a portal item + const item = page.getByTestId('item-1') + await expect(item).toBeVisible() + await item.click() + + // Should have selected the item + await expect(page.getByTestId('selected')).toHaveText('Apple') + }) + + test('portal item selection works', async ({ mount, page }) => { + await mount() + + const editor = page.getByRole('textbox') + await editor.click() + + const portal = page.getByTestId('portal') + await expect(portal).toBeVisible() + + // Click item 2 + const item2 = page.getByTestId('item-2') + await item2.click() + + // Should have selected item 2 + await expect(page.getByTestId('selected')).toHaveText('Banana') + }) + + test('portal has touch-action manipulation style', async ({ + mount, + page + }) => { + await mount() + + const editor = page.getByRole('textbox') + await editor.click() + + const portal = page.getByTestId('portal-list') + await expect(portal).toBeVisible() + + const touchAction = await portal.evaluate( + (el) => window.getComputedStyle(el).touchAction + ) + expect(touchAction).toBe('manipulation') + }) +}) + +test.describe('Token interaction', () => { + test('interacting near token works correctly', async ({ mount, page }) => { + await mount() + + const editor = page.getByRole('textbox') + await editor.click() + + // Should be able to type + await page.keyboard.type('!') + const rawValue = await page.getByTestId('raw-value').textContent() + expect(rawValue).toContain('!') + }) + + test('backspace near token works', async ({ mount, page }) => { + await mount() + + const editor = page.getByRole('textbox') + await editor.click() + + // Move to end + for (let i = 0; i < 10; i++) await page.keyboard.press('ArrowRight') + + // Backspace should delete token characters + await page.keyboard.press('Backspace') + const rawValue = await page.getByTestId('raw-value').textContent() + // Should have deleted something + expect(rawValue?.length).toBeLessThan('a@user'.length) + }) +}) diff --git a/src/inlay/__ct__/inlay.overlap.spec.tsx b/src/inlay/__ct__/inlay.overlap.spec.tsx new file mode 100644 index 0000000..4037fba --- /dev/null +++ b/src/inlay/__ct__/inlay.overlap.spec.tsx @@ -0,0 +1,134 @@ +import { test, expect } from '@playwright/experimental-ct-react' +import { OverlappingPluginsInlay } from './fixtures/overlapping-plugins-inlay' + +test.describe('Plugin overlap resolution (CT)', () => { + test.describe('Exact same substring - two plugins match @test at identical range', () => { + test('only one token renders and first plugin wins (diverged output confirms which)', async ({ + mount, + page + }) => { + // Input: "hello @test world" + // Plugin A matches @test → renders "[A:test]" + // Plugin B matches @test → renders "[B:test]" + // Both match the EXACT same range. Only one should render. + // With proper overlap resolution, first plugin (A) should win. + await mount( + + ) + + const ed = page.getByRole('textbox') + await expect(ed).toBeVisible() + + // Should have exactly one token, not two + const allTokens = ed.locator('[data-plugin]') + await expect(allTokens).toHaveCount(1) + + // The visual output should be from Plugin A (first in array) + // This confirms which plugin "won" via diverged rendering + await expect(ed).toContainText('[A:test]') + await expect(ed).not.toContainText('[B:test]') + + // Confirm it's Plugin A's token + await expect(allTokens.first()).toHaveAttribute('data-plugin', 'pluginA') + }) + }) + + test.describe('Longer match wins - @alice vs @alice_vip at same start', () => { + test('longer @alice_vip token wins over shorter @alice match', async ({ + mount, + page + }) => { + // Input: "hello @alice_vip world" + // Short plugin matches "@alice" at position 6-12 + // Long plugin matches "@alice_vip" at position 6-16 + // Both start at position 6, but Long is 10 chars vs Short's 6 chars + // Longest-match-wins: Long plugin should win + await mount( + + ) + + const ed = page.getByRole('textbox') + await expect(ed).toBeVisible() + + // Should have exactly one token (the longer match consumed the range) + const allTokens = ed.locator('[data-plugin]') + await expect(allTokens).toHaveCount(1) + + // The visual should be from Long plugin, not Short + await expect(ed).toContainText('[long:alice_vip]') + await expect(ed).not.toContainText('[short:alice]') + + // Confirm it's Long plugin's token + const token = allTokens.first() + await expect(token).toHaveAttribute('data-plugin', 'long') + await expect(token).toHaveAttribute('data-token-raw', '@alice_vip') + }) + + test('short match still works when long pattern does not apply', async ({ + mount, + page + }) => { + // Input: "hello @alice world" (no _vip suffix) + // Short plugin matches "@alice" at position 6-12 + // Long plugin does NOT match (no @alice_vip in text) + // Short plugin should render since there's no competition + await mount( + + ) + + const ed = page.getByRole('textbox') + await expect(ed).toBeVisible() + + // Should have exactly one token from Short plugin + const allTokens = ed.locator('[data-plugin]') + await expect(allTokens).toHaveCount(1) + + await expect(ed).toContainText('[short:alice]') + await expect(allTokens.first()).toHaveAttribute('data-plugin', 'short') + }) + }) + + test.describe('Non-overlapping tokens preserved', () => { + test('multiple non-overlapping tokens from different plugins are all rendered', async ({ + mount, + page + }) => { + await mount( + + ) + + const ed = page.getByRole('textbox') + await expect(ed).toBeVisible() + + // Should have 4 tokens total (2 mentions, 2 hashtags) + const allTokens = ed.locator('[data-plugin]') + await expect(allTokens).toHaveCount(4) + + // Two mentions + const mentionTokens = ed.locator('[data-plugin="mention"]') + await expect(mentionTokens).toHaveCount(2) + + // Two hashtags + const hashtagTokens = ed.locator('[data-plugin="hashtag"]') + await expect(hashtagTokens).toHaveCount(2) + + // Verify specific values exist + await expect(ed.locator('[data-token-raw="@alice"]')).toHaveCount(1) + await expect(ed.locator('[data-token-raw="@bob"]')).toHaveCount(1) + await expect(ed.locator('[data-token-raw="#react"]')).toHaveCount(1) + await expect(ed.locator('[data-token-raw="#vue"]')).toHaveCount(1) + }) + }) +}) diff --git a/src/inlay/__ct__/inlay.portal-navigation.spec.tsx b/src/inlay/__ct__/inlay.portal-navigation.spec.tsx new file mode 100644 index 0000000..a80dc98 --- /dev/null +++ b/src/inlay/__ct__/inlay.portal-navigation.spec.tsx @@ -0,0 +1,157 @@ +import { test, expect } from '@playwright/experimental-ct-react' +import type { Page } from '@playwright/test' +import type { MountResult } from '@playwright/experimental-ct-react' +import { PortalNavigationInlay } from './fixtures/portal-navigation-inlay' + +test.describe('Portal keyboard navigation (CT)', () => { + // Helper to set up the test with portal open + async function setupPortal( + mount: (component: React.ReactElement) => Promise, + page: Page + ) { + await mount() + + const editor = page.getByTestId('editor') + await expect(editor).toBeVisible() + + // Wait for token to be rendered - scope to editor to avoid hidden first-pass + const tokenRender = editor.getByTestId('token-render') + await expect(tokenRender).toBeVisible() + + // Click on the token to activate portal + await tokenRender.click() + + // Wait for portal to appear + const portal = page.getByTestId('portal') + await expect(portal).toBeVisible() + + return { editor, portal } + } + + test('ArrowDown navigates to next item', async ({ mount, page }) => { + await setupPortal(mount, page) + + // First item should be active by default + const item1 = page.getByTestId('item-1') + const item2 = page.getByTestId('item-2') + + await expect(item1).toHaveAttribute('data-active') + await expect(item2).not.toHaveAttribute('data-active') + + // Press ArrowDown + await page.keyboard.press('ArrowDown') + + await expect(item1).not.toHaveAttribute('data-active') + await expect(item2).toHaveAttribute('data-active') + }) + + test('ArrowUp navigates to previous item', async ({ mount, page }) => { + await setupPortal(mount, page) + + // Navigate down first + await page.keyboard.press('ArrowDown') + await page.keyboard.press('ArrowDown') + + const item3 = page.getByTestId('item-3') + await expect(item3).toHaveAttribute('data-active') + + // Press ArrowUp + await page.keyboard.press('ArrowUp') + + const item2 = page.getByTestId('item-2') + await expect(item2).toHaveAttribute('data-active') + }) + + test('ArrowDown wraps from last to first item', async ({ mount, page }) => { + await setupPortal(mount, page) + + // Navigate to last item + await page.keyboard.press('ArrowDown') + await page.keyboard.press('ArrowDown') + + const item3 = page.getByTestId('item-3') + await expect(item3).toHaveAttribute('data-active') + + // Press ArrowDown again - should wrap to first + await page.keyboard.press('ArrowDown') + + const item1 = page.getByTestId('item-1') + await expect(item1).toHaveAttribute('data-active') + }) + + test('ArrowUp wraps from first to last item', async ({ mount, page }) => { + await setupPortal(mount, page) + + // First item is active by default + const item1 = page.getByTestId('item-1') + await expect(item1).toHaveAttribute('data-active') + + // Press ArrowUp - should wrap to last + await page.keyboard.press('ArrowUp') + + const item3 = page.getByTestId('item-3') + await expect(item3).toHaveAttribute('data-active') + }) + + test('Enter selects the active item', async ({ mount, page }) => { + await setupPortal(mount, page) + + // Navigate to second item + await page.keyboard.press('ArrowDown') + + // Press Enter to select + await page.keyboard.press('Enter') + + // Check that the item was selected + const selected = page.getByTestId('selected') + await expect(selected).toHaveText('Banana') + + // Check that the raw value was updated + const rawValue = page.getByTestId('raw-value') + await expect(rawValue).toHaveText('hello @2 ') + }) + + test('Mouse hover changes active item', async ({ mount, page }) => { + await setupPortal(mount, page) + + const item1 = page.getByTestId('item-1') + const item3 = page.getByTestId('item-3') + + // First item is active by default + await expect(item1).toHaveAttribute('data-active') + + // Hover over third item + await item3.hover() + + // Third item should now be active + await expect(item3).toHaveAttribute('data-active') + await expect(item1).not.toHaveAttribute('data-active') + }) + + test('Click selects the item', async ({ mount, page }) => { + await setupPortal(mount, page) + + // Click on third item + const item3 = page.getByTestId('item-3') + await item3.click() + + // Check that the item was selected + const selected = page.getByTestId('selected') + await expect(selected).toHaveText('Cherry') + + // Check that the raw value was updated + const rawValue = page.getByTestId('raw-value') + await expect(rawValue).toHaveText('hello @3 ') + }) + + test('Focus stays in editor after navigation', async ({ mount, page }) => { + const { editor } = await setupPortal(mount, page) + + // Navigate with arrow keys + await page.keyboard.press('ArrowDown') + await page.keyboard.press('ArrowDown') + + // Focus should still be in editor + await expect(editor).toBeFocused() + }) +}) diff --git a/src/inlay/__ct__/inlay.structured-actions.spec.tsx b/src/inlay/__ct__/inlay.structured-actions.spec.tsx new file mode 100644 index 0000000..0d2d648 --- /dev/null +++ b/src/inlay/__ct__/inlay.structured-actions.spec.tsx @@ -0,0 +1,126 @@ +import { test, expect } from '@playwright/experimental-ct-react' +import { StructuredActionsInlay } from './fixtures/structured-actions-inlay' + +test.describe('StructuredInlay replace/update actions (CT)', () => { + test('update changes rendered label without changing raw value', async ({ + mount, + page + }) => { + await mount() + + const ed = page.getByTestId('editor') + await expect(ed).toBeVisible() + + // Wait for token to be weaved (scope to editor to avoid hidden first-pass element) + const tokenRender = ed.getByTestId('token-render') + await expect(tokenRender).toBeVisible() + await expect(tokenRender).toHaveText('@alice') + + // Click on the token to activate portal + await tokenRender.click() + + // Wait for portal to appear + const portal = page.getByTestId('portal') + await expect(portal).toBeVisible() + + // Click the update button + await page.getByTestId('btn-update').click() + + // Verify rendered label changed + await expect(tokenRender).toHaveText('UpdatedLabel') + + // Verify raw value is unchanged + const rawValue = page.getByTestId('raw-value') + await expect(rawValue).toHaveText('hello @alice world') + }) + + test('replace changes raw value', async ({ mount, page }) => { + await mount() + + const ed = page.getByTestId('editor') + await expect(ed).toBeVisible() + + // Wait for token to be weaved (scope to editor) + const tokenRender = ed.getByTestId('token-render') + await expect(tokenRender).toBeVisible() + + // Click on the token to activate portal + await tokenRender.click() + + // Wait for portal to appear + const portal = page.getByTestId('portal') + await expect(portal).toBeVisible() + + // Click the replace button (replaces @alice with @replaced) + await page.getByTestId('btn-replace').click() + + // Verify raw value changed + const rawValue = page.getByTestId('raw-value') + await expect(rawValue).toHaveText('hello @replaced world') + + // Verify the new token is rendered + await expect(tokenRender).toHaveText('@replaced') + }) + + test('replace positions caret at end of new text', async ({ + mount, + page + }) => { + await mount() + + const ed = page.getByTestId('editor') + await expect(ed).toBeVisible() + + // Wait for token (scope to editor) + const tokenRender = ed.getByTestId('token-render') + await expect(tokenRender).toBeVisible() + await expect(tokenRender).toHaveText('@a') + + // Click on token to activate portal + await tokenRender.click() + await expect(page.getByTestId('portal')).toBeVisible() + + // Replace @a with @replaced + // The portal has onMouseDown preventDefault, so focus stays in editor + await page.getByTestId('btn-replace').click() + + // Verify the value updated correctly + const rawValue = page.getByTestId('raw-value') + await expect(rawValue).toHaveText('hi @replaced bye') + + // Type to verify caret position (focus should still be in editor) + await page.keyboard.type('X') + + // Caret should be after @replaced + await expect(rawValue).toHaveText('hi @replacedX bye') + }) + + test('replace with longer text updates raw value correctly', async ({ + mount, + page + }) => { + await mount() + + const ed = page.getByTestId('editor') + await expect(ed).toBeVisible() + + // Wait for token (scope to editor) + const tokenRender = ed.getByTestId('token-render') + await expect(tokenRender).toBeVisible() + await expect(tokenRender).toHaveText('@a') + + // Click on token to activate portal + await tokenRender.click() + await expect(page.getByTestId('portal')).toBeVisible() + + // Replace @a with @replaced + await page.getByTestId('btn-replace').click() + + // Verify the value updated correctly + const rawValue = page.getByTestId('raw-value') + await expect(rawValue).toHaveText('hi @replaced bye') + + // Verify the token is re-rendered with new value + await expect(tokenRender).toHaveText('@replaced') + }) +}) diff --git a/src/inlay/__ct__/inlay.tokens.spec.tsx b/src/inlay/__ct__/inlay.tokens.spec.tsx new file mode 100644 index 0000000..3920f3e --- /dev/null +++ b/src/inlay/__ct__/inlay.tokens.spec.tsx @@ -0,0 +1,53 @@ +import { test, expect } from '@playwright/experimental-ct-react' +import { ControlledTokenInlay } from './fixtures/controlled-token-inlay' + +test.describe('Token-aware deletions (CT)', () => { + test('Backspace at end of token deletes last raw char', async ({ + mount, + page + }) => { + await mount() + + const ed = page.getByRole('textbox') + await ed.click() + // Ensure token has been weaved into the DOM and content is present + await expect(ed.locator('[data-token-text="@x"]').first()).toBeVisible() + await expect(ed).toHaveText('A@xB') + // overshoot to the right + for (let i = 0; i < 10; i++) await page.keyboard.press('ArrowRight') + await page.keyboard.press('ArrowLeft') + await page.keyboard.press('Backspace') + await expect(ed).toHaveText('A@B') + await expect(ed.locator('[data-token-text]')).toHaveCount(0) + }) + + test('Range delete across token removes the token completely', async ({ + mount, + page + }) => { + await mount() + + const ed = page.getByRole('textbox') + await ed.click() + // Ensure token has been weaved into the DOM and content is present + const token = ed.locator('[data-token-text="@x"]').first() + await expect(token).toBeVisible() + await expect(ed).toHaveText('A@xB') + const edBox = await ed.boundingBox() + const tokBox = await token.boundingBox() + if (!edBox || !tokBox) throw new Error('no boxes') + const y = edBox.y + edBox.height / 2 + const startX = Math.max(edBox.x + 2, tokBox.x - 4) + const endX = Math.min( + edBox.x + edBox.width - 2, + tokBox.x + tokBox.width + 2 + ) + await page.mouse.move(startX, y) + await page.mouse.down() + await page.mouse.move(endX, y, { steps: 5 }) + await page.mouse.up() + await page.keyboard.press('Backspace') + await expect(ed).toHaveText('AB') + await expect(ed.locator('[data-token-text]')).toHaveCount(0) + }) +}) diff --git a/src/inlay/__tests__/inlay-editor-behavior.test.tsx b/src/inlay/__tests__/inlay-editor-behavior.test.tsx new file mode 100644 index 0000000..870fde1 --- /dev/null +++ b/src/inlay/__tests__/inlay-editor-behavior.test.tsx @@ -0,0 +1,695 @@ +import React from 'react' +import { describe, it, expect } from 'vitest' +import { render, fireEvent, act, waitFor } from '@testing-library/react' +import * as Inlay from '../inlay' +import { getAbsoluteOffset, setDomSelection } from '../internal/dom-utils' + +function flush() { + return new Promise((r) => setTimeout(r, 0)) +} + +// Helper to dispatch native beforeinput event for backspace +// This is needed because our handler uses native event listeners +function fireBackspace(element: HTMLElement) { + const sel = window.getSelection() + // Range is captured for potential future use in getTargetRanges simulation + const _range = sel?.rangeCount ? sel.getRangeAt(0) : null + void _range // Acknowledge intentionally unused + + const event = new InputEvent('beforeinput', { + bubbles: true, + cancelable: true, + inputType: 'deleteContentBackward', + data: null + }) + + // jsdom doesn't support getTargetRanges, so we need to ensure selection is set + element.dispatchEvent(event) +} + +// Helper to dispatch native beforeinput for delete forward +function fireDelete(element: HTMLElement) { + const event = new InputEvent('beforeinput', { + bubbles: true, + cancelable: true, + inputType: 'deleteContentForward', + data: null + }) + element.dispatchEvent(event) +} + +// Empty state rendering +describe('Inlay empty state', () => { + it('renders zero-width space for consistent caret height', async () => { + const { getByTestId } = render( + + + + ) + const ed = getByTestId('editor') as HTMLElement + + await act(async () => { + await flush() + }) + + // Should contain a zero-width space (U+200B) to maintain caret baseline + expect(ed.textContent).toBe('\u200B') + }) +}) + +// Editing basics (Space/Enter) — Backspace covered in its own block below +describe('Inlay editing (Space/Enter)', () => { + it('Space and Enter modify value appropriately', async () => { + function Test() { + const [value, setValue] = React.useState('ab') + return ( + + + + ) + } + + const { getByTestId } = render() + const ed = getByTestId('editor') as HTMLElement + + await act(async () => { + ;(ed as HTMLElement).focus() + setDomSelection(ed as HTMLElement, 2) + await flush() + }) + + await act(async () => { + fireEvent.keyDown(ed, { key: ' ' }) + await flush() + }) + expect((ed as HTMLElement).textContent).toBe('ab ') + + await act(async () => { + fireEvent.keyDown(ed, { key: 'Enter' }) + await flush() + }) + expect((ed as HTMLElement).textContent).toBe('ab \n') + + // Backspace semantics are covered separately + }) +}) + +// Backspace semantics +describe('Inlay Backspace semantics', () => { + it('does nothing at start of content (collapsed at 0)', async () => { + function Test() { + const [value, setValue] = React.useState('ab') + return ( + + + + ) + } + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + await act(async () => { + ed.focus() + setDomSelection(ed, 0) + await flush() + }) + + await act(async () => { + fireBackspace(ed) + await flush() + }) + + expect(ed.textContent).toBe('ab') + const sel = window.getSelection()! + const caret = getAbsoluteOffset(ed, sel.focusNode!, sel.focusOffset) + expect(caret).toBe(0) + }) + + it('deletes previous char when collapsed (not at start)', async () => { + function Test() { + const [value, setValue] = React.useState('ab') + return ( + + + + ) + } + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + await act(async () => { + ed.focus() + setDomSelection(ed, 2) + await flush() + }) + + await act(async () => { + fireBackspace(ed) + await flush() + }) + + expect(ed.textContent).toBe('a') + const sel = window.getSelection()! + const caret = getAbsoluteOffset(ed, sel.focusNode!, sel.focusOffset) + expect(caret).toBe(1) + }) + + it('deletes selected range (within plain text)', async () => { + function Test() { + const [value, setValue] = React.useState('abcd') + return ( + + + + ) + } + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + await act(async () => { + ed.focus() + setDomSelection(ed, 1, 3) // select 'bc' + await flush() + }) + + await act(async () => { + fireBackspace(ed) + await flush() + }) + + expect(ed.textContent).toBe('ad') + const sel = window.getSelection()! + const caret = getAbsoluteOffset(ed, sel.focusNode!, sel.focusOffset) + expect(caret).toBe(1) + }) + + it('range delete across a token removes the token raw span', async () => { + function Test() { + const [value, setValue] = React.useState('A@xB') + return ( + + {/* Register token for '@x' so weaving recognizes it */} + + @x + + + ) + } + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + await waitFor(() => { + expect(ed.querySelector('[data-token-text="@x"]')).toBeTruthy() + }) + + await act(async () => { + ed.focus() + setDomSelection(ed, 1, 3) + await flush() + }) + + await act(async () => { + fireBackspace(ed) + await flush() + }) + + expect(ed.querySelector('[data-token-text]')).toBeFalsy() + }) + + it('collapsed backspace at end of token deletes last raw char of token', async () => { + function Test() { + const [value, setValue] = React.useState('A@xB') + return ( + + + @x + + + {({ value }) => } + + + ) + } + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + await waitFor(() => { + expect(ed.querySelector('[data-token-text]')).toBeTruthy() + }) + + await act(async () => { + ed.focus() + setDomSelection(ed, 3) // right after '@x' + await flush() + }) + + await act(async () => { + fireBackspace(ed) + await flush() + }) + + // Deletes 'x' -> raw becomes 'A@B'; token no longer matches + const ctxVal = getByTestId('ctx-val') + expect(ctxVal.getAttribute('data-value')).toBe('A@B') + expect(ed.querySelector('[data-token-text]')).toBeFalsy() + }) +}) + +// onKeyDown interception +describe('Inlay onKeyDown interception', () => { + it('returns true to prevent built-in handling (Space, Enter)', async () => { + const ref = React.createRef() + + function Test() { + const [value, setValue] = React.useState('ab') + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === ' ' || e.key === 'Enter') return true + return false + } + return ( + + + + ) + } + + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + await act(async () => { + ref.current!.setSelection(2) + await flush() + }) + + await act(async () => { + fireEvent.keyDown(ed, { key: ' ' }) + await flush() + }) + expect(ed.textContent).toBe('ab') + + await act(async () => { + fireEvent.keyDown(ed, { key: 'Enter' }) + await flush() + }) + expect(ed.textContent).toBe('ab') + }) +}) + +// Newline rendering +describe('Inlay trailing newline rendering', () => { + it('adds a when value ends with \n (when at least one token is present)', () => { + function Test({ value }: { value: string }) { + return ( + {}} data-testid="root"> + + X + + + ) + } + + const { getByTestId, rerender } = render() + const editor = getByTestId('root') as HTMLElement + expect(editor.querySelector('br')).toBeTruthy() + + rerender() + expect(editor.querySelector('br')).toBeFalsy() + }) + + it('first Enter on empty editor creates a visible newline (trailing )', async () => { + function Test() { + const [value, setValue] = React.useState('') + return ( + + + + ) + } + + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + await act(async () => { + ed.focus() + fireEvent.keyDown(ed, { key: 'Enter' }) + await flush() + }) + + // Expect a trailing to appear immediately after first Enter + expect(ed.querySelector('br')).toBeTruthy() + }) +}) + +// Grapheme cluster behavior +describe('Inlay Backspace with grapheme clusters', () => { + it('deletes full grapheme cluster (emoji + skin tone) when collapsed', async () => { + function Test() { + const [value, setValue] = React.useState('👍🏼') + return ( + + + + ) + } + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + await act(async () => { + ed.focus() + setDomSelection(ed, '👍🏼'.length) // caret after the cluster + await flush() + }) + + await act(async () => { + fireBackspace(ed) + await flush() + }) + + expect(ed.textContent).toBe('\u200B') + const sel = window.getSelection()! + const caret = getAbsoluteOffset(ed, sel.focusNode!, sel.focusOffset) + expect(caret).toBe(0) + }) + + it('deletes an entire flag grapheme (regional indicators) before caret', async () => { + const flag = '🇺🇸' + function Test() { + const [value, setValue] = React.useState(`a${flag}b`) + return ( + + + + ) + } + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + const value = `a${flag}b` + const posAfterFlag = value.indexOf(flag) + flag.length + + await act(async () => { + ed.focus() + setDomSelection(ed, posAfterFlag) + await flush() + }) + + await act(async () => { + fireBackspace(ed) + await flush() + }) + + expect(ed.textContent).toBe('ab') + }) + + it('deletes composed character with combining mark as a single grapheme', async () => { + const composed = 'e\u0301' // e + combining acute + function Test() { + const [value, setValue] = React.useState(composed) + return ( + + + + ) + } + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + await act(async () => { + ed.focus() + setDomSelection(ed, composed.length) + await flush() + }) + + await act(async () => { + fireBackspace(ed) + await flush() + }) + + expect(ed.textContent).toBe('\u200B') + }) +}) + +describe('Inlay selection deletion is grapheme-aware', () => { + it('Backspace with selection slicing through a grapheme deletes the whole grapheme', async () => { + const cluster = '👍🏼' + const text = `a${cluster}b` + function Test() { + const [value, setValue] = React.useState(text) + return ( + + + + ) + } + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + const start = 1 + 1 // into the grapheme + const end = 1 + cluster.length - 1 // still inside the grapheme + + await act(async () => { + ed.focus() + setDomSelection(ed, start, end) + await flush() + }) + + await act(async () => { + fireBackspace(ed) + await flush() + }) + + // Expect entire grapheme removed, leaving 'ab' + expect(ed.textContent).toBe('ab') + const sel = window.getSelection()! + const caret = getAbsoluteOffset(ed, sel.focusNode!, sel.focusOffset) + expect(caret).toBe(1) + }) + + it('Delete with selection slicing through a grapheme deletes the whole grapheme', async () => { + const cluster = '🇺🇸' + const text = `x${cluster}y` + function Test() { + const [value, setValue] = React.useState(text) + return ( + + + + ) + } + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + const start = 1 // start at grapheme start + const end = 1 + 1 // end in the middle of grapheme + + await act(async () => { + ed.focus() + setDomSelection(ed, start, end) + await flush() + }) + + await act(async () => { + fireDelete(ed) + await flush() + }) + + expect(ed.textContent).toBe('xy') + const sel = window.getSelection()! + const caret = getAbsoluteOffset(ed, sel.focusNode!, sel.focusOffset) + expect(caret).toBe(1) + }) +}) + +// ZWJ grapheme sequences and selection snapping +describe('Inlay grapheme advanced cases', () => { + it('Backspace/Delete remove whole ZWJ grapheme (family emoji)', async () => { + const family = '👨👩👧👦' + function Test() { + const [value, setValue] = React.useState(family) + return ( + + + + ) + } + const { getByTestId, rerender } = render() + const ed = getByTestId('ed') as HTMLElement + + await act(async () => { + ed.focus() + setDomSelection(ed, family.length) + await flush() + }) + await act(async () => { + fireBackspace(ed) + await flush() + }) + expect(ed.textContent).toBe('\u200B') + + // Reset and test Delete + rerender( + {}} data-testid="ed"> + + + ) + await act(async () => { + ed.focus() + setDomSelection(ed, 0) + await flush() + }) + await act(async () => { + fireDelete(ed) + await flush() + }) + expect(ed.textContent).toBe('\u200B') + }) + + it('setSelection snaps to grapheme boundaries', async () => { + const cluster = '👍🏼' + const text = `a${cluster}b` + const ref = React.createRef() + function Test() { + const [value, setValue] = React.useState(text) + return ( + + + {({ selection, getSelectionRange }) => { + const range = getSelectionRange() + let rawStart = -1 + let rawEnd = -1 + const root = ref.current?.root + if (root && range) { + rawStart = getAbsoluteOffset( + root, + range.startContainer, + range.startOffset + ) + rawEnd = getAbsoluteOffset( + root, + range.endContainer, + range.endOffset + ) + } + return ( + + ) + }} + + + + ) + } + const { getByTestId } = render() + + const mid = 1 + Math.floor(cluster.length / 2) + await act(async () => { + ref.current!.setSelection(mid) + await flush() + }) + + const ctx = getByTestId('ctx') + // Expect snapped to start of grapheme (offset 1) + expect(ctx.getAttribute('data-start')).toBe('1') + expect(ctx.getAttribute('data-end')).toBe('1') + expect(ctx.getAttribute('data-raw-start')).toBe('1') + expect(ctx.getAttribute('data-raw-end')).toBe('1') + }) +}) + +// Placeholder +describe('Inlay placeholder', () => { + it('shows placeholder only when value is empty', () => { + const placeholder = 'Type here...' + const { container, rerender } = render( + {}} placeholder={placeholder}> + + + ) + + expect(container.textContent).toContain(placeholder) + + rerender( + {}} placeholder={placeholder}> + + + ) + + expect(container.textContent).not.toContain(placeholder) + }) +}) + +// Multiline behavior +describe('Inlay multiline prop', () => { + it('multiline=false blocks Enter and Shift+Enter on empty editor', async () => { + function Test() { + const [value, setValue] = React.useState('') + return ( + + + + ) + } + + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + await act(async () => { + ed.focus() + fireEvent.keyDown(ed, { key: 'Enter' }) + await flush() + }) + expect(ed.querySelector('br')).toBeFalsy() + expect(ed.textContent).toBe('\u200B') + + await act(async () => { + fireEvent.keyDown(ed, { key: 'Enter', shiftKey: true }) + await flush() + }) + expect(ed.querySelector('br')).toBeFalsy() + expect(ed.textContent).toBe('\u200B') + }) + + it('multiline=false does not render trailing even if value ends with \n', async () => { + function Test() { + const [value, setValue] = React.useState('A\n') + return ( + + + + ) + } + + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + expect(ed.querySelector('br')).toBeFalsy() + }) +}) + +// (IME composition tests removed; to be covered in Playwright later) diff --git a/src/inlay/__tests__/inlay-selection-and-active-token.test.tsx b/src/inlay/__tests__/inlay-selection-and-active-token.test.tsx new file mode 100644 index 0000000..fd100a9 --- /dev/null +++ b/src/inlay/__tests__/inlay-selection-and-active-token.test.tsx @@ -0,0 +1,202 @@ +import React from 'react' +import { describe, it, expect } from 'vitest' +import { render, act, waitFor } from '@testing-library/react' +import * as Inlay from '../inlay' +import { getAbsoluteOffset } from '../internal/dom-utils' + +function flush() { + return new Promise((r) => setTimeout(r, 0)) +} + +describe('Inlay public selection API', () => { + it('setSelection updates public context and getSelectionRange round-trips to same raw offsets', async () => { + const ref = React.createRef() + + const Test = () => ( + {}} + data-testid="ed" + > + + {({ selection, getSelectionRange }) => { + const range = getSelectionRange() + let rawStart = -1 + let rawEnd = -1 + const root = ref.current?.root + if (root && range) { + rawStart = getAbsoluteOffset( + root, + range.startContainer, + range.startOffset + ) + rawEnd = getAbsoluteOffset( + root, + range.endContainer, + range.endOffset + ) + } + return ( + + ) + }} + + + ) + + const { getByTestId } = render() + + await act(async () => { + ref.current!.setSelection(1, 3) + await flush() + }) + + await waitFor(() => { + const ctx = getByTestId('ctx') + expect(ctx.getAttribute('data-start')).toBe('1') + expect(ctx.getAttribute('data-end')).toBe('3') + expect(ctx.getAttribute('data-range')).toBe('1') + expect(ctx.getAttribute('data-raw-start')).toBe('1') + expect(ctx.getAttribute('data-raw-end')).toBe('3') + }) + }) +}) + +describe('Inlay active token and state', () => { + it('middle/end for non-diverged token; start boundary is spacer', async () => { + const ref = React.createRef() + const { getByTestId } = render( + {}} data-testid="ed"> + + @x + + + {({ activeToken, activeTokenState }) => ( + + )} + + + ) + + const ed = getByTestId('ed') as HTMLElement + + await waitFor(() => { + expect(ed.querySelector('[data-token-text]')).toBeTruthy() + }) + + // Start boundary selects spacer [0,1] + await act(async () => { + ref.current!.setSelection(1) + await flush() + }) + await waitFor(() => { + const ctx = document.querySelector('[data-testid="ctx"]') as HTMLElement + expect(ctx.getAttribute('data-start')).toBe('0') + expect(ctx.getAttribute('data-end')).toBe('1') + expect(ctx.getAttribute('data-collapsed')).toBe('1') + expect(ctx.getAttribute('data-atstart')).toBe('0') + expect(ctx.getAttribute('data-atend')).toBe('1') + }) + + // Middle of token (offset 2) + await act(async () => { + ref.current!.setSelection(2) + await flush() + }) + await waitFor(() => { + const ctx = document.querySelector('[data-testid="ctx"]') as HTMLElement + expect(ctx.getAttribute('data-start')).toBe('1') + expect(ctx.getAttribute('data-end')).toBe('3') + expect(ctx.getAttribute('data-collapsed')).toBe('1') + expect(ctx.getAttribute('data-atstart')).toBe('0') + expect(ctx.getAttribute('data-atend')).toBe('0') + }) + + // End of token (offset 3) + await act(async () => { + ref.current!.setSelection(3) + await flush() + }) + await waitFor(() => { + const ctx = document.querySelector('[data-testid="ctx"]') as HTMLElement + expect(ctx.getAttribute('data-start')).toBe('1') + expect(ctx.getAttribute('data-end')).toBe('3') + expect(ctx.getAttribute('data-collapsed')).toBe('1') + expect(ctx.getAttribute('data-atstart')).toBe('0') + expect(ctx.getAttribute('data-atend')).toBe('1') + }) + }) + + it('diverged: start boundary is spacer; end-of-token reports atEnd', async () => { + const ref = React.createRef() + const { getByTestId } = render( + {}} + data-testid="ed" + > + + Alex + + + {({ activeToken, activeTokenState }) => ( + + )} + + + ) + + const ed = getByTestId('ed') as HTMLElement + + await waitFor(() => { + expect(ed.querySelector('[data-token-text]')).toBeTruthy() + }) + + // Start boundary is spacer [0,1] + await act(async () => { + ref.current!.setSelection(1) + await flush() + }) + await waitFor(() => { + const ctx = document.querySelector('[data-testid="ctx"]') as HTMLElement + expect(ctx.getAttribute('data-start')).toBe('0') + expect(ctx.getAttribute('data-end')).toBe('1') + expect(ctx.getAttribute('data-atstart')).toBe('0') + expect(ctx.getAttribute('data-atend')).toBe('1') + }) + + // End of token (offset 6) + await act(async () => { + ref.current!.setSelection(6) + await flush() + }) + await waitFor(() => { + const ctx = document.querySelector('[data-testid="ctx"]') as HTMLElement + expect(ctx.getAttribute('data-atstart')).toBe('0') + expect(ctx.getAttribute('data-atend')).toBe('1') + }) + }) +}) diff --git a/src/inlay/__tests__/inlay-tokens-and-registry.test.tsx b/src/inlay/__tests__/inlay-tokens-and-registry.test.tsx new file mode 100644 index 0000000..79b7164 --- /dev/null +++ b/src/inlay/__tests__/inlay-tokens-and-registry.test.tsx @@ -0,0 +1,264 @@ +import React from 'react' +import { describe, it, expect } from 'vitest' +import { render, waitFor, act } from '@testing-library/react' +import * as Inlay from '../inlay' + +function flush() { + return new Promise((r) => setTimeout(r, 0)) +} + +// Ancestor registration +describe('Inlay ancestor registration', () => { + it('wraps a token with its ancestor element in the visible weaved output', async () => { + const { getByTestId } = render( + {}} data-testid="ed"> + + + X + + + + ) + + const editor = getByTestId('ed') as HTMLElement + + await waitFor(() => { + const token = editor.querySelector('[data-token-text="X"]') + expect(token).toBeTruthy() + }) + + const wrapped = editor.querySelector( + '[data-anc="outer"] [data-token-text="X"]' + ) + expect(wrapped).toBeTruthy() + }) + + it('uses the top-most ancestor, not inner ones, as the captured node (outer wrapper present)', async () => { + const { getByTestId } = render( + {}} data-testid="ed"> + + + + X + + + + + ) + + const editor = getByTestId('ed') as HTMLElement + + await waitFor(() => { + const token = editor.querySelector('[data-token-text="X"]') + expect(token).toBeTruthy() + }) + + const outerWrapped = editor.querySelector( + '[data-anc="outer"] [data-token-text="X"]' + ) + expect(outerWrapped).toBeTruthy() + }) + + it('when no ancestor wrapper is present, token renders directly without ancestor', async () => { + const { getByTestId } = render( + {}} data-testid="ed"> + + X + + + ) + + const editor = getByTestId('ed') as HTMLElement + + await waitFor(() => { + const token = editor.querySelector('[data-token-text="X"]') + expect(token).toBeTruthy() + }) + + expect(editor.querySelector('[data-anc]')).toBeFalsy() + }) +}) + +// Adjacent tokens +describe('Inlay adjacent tokens (weaving)', () => { + it('renders back-to-back tokens without a spacer', () => { + const { getByTestId } = render( + {}} data-testid="ed"> + + @x + + + @x + + + ) + + const editor = getByTestId('ed') as HTMLElement + const tokens = editor.querySelectorAll('[data-token-text="@x"]') + expect(tokens.length).toBe(2) + }) +}) + +// Registry staleness +describe('Inlay token registry staleness', () => { + it('when value changes and token child is removed, no stale token renders; plain text is shown', async () => { + const Test = ({ + value, + withToken + }: { + value: string + withToken: boolean + }) => ( + {}} data-testid="ed"> + {withToken ? ( + + X + + ) : ( + + )} + + ) + + const { getByTestId, rerender } = render( + + ) + const ed = getByTestId('ed') as HTMLElement + + await waitFor(() => { + expect(ed.querySelector('[data-token-text="X"]')).toBeTruthy() + }) + + rerender() + + await waitFor(() => { + expect(ed.querySelector('[data-token-text]')).toBeFalsy() + expect(ed.textContent).toBe('Y') + }) + }) + + it('when value changes but a stale token child remains, it should not appear in the visible editor', async () => { + const { getByTestId, rerender } = render( + {}} data-testid="ed"> + + X + + + ) + const ed = getByTestId('ed') as HTMLElement + + await waitFor(() => { + expect(ed.querySelector('[data-token-text="X"]')).toBeTruthy() + }) + + rerender( + {}} data-testid="ed"> + + X + + + ) + + await waitFor(() => { + expect(ed.querySelector('[data-token-text]')).toBeFalsy() + }) + }) +}) + +// External controlled updates +describe('Inlay external controlled updates', () => { + it('re-weaves tokens when parent changes value (appears/disappears)', async () => { + function Test() { + const [value, setValue] = React.useState('A') + ;(window as any).__setVal = setValue + return ( + + + @x + + + ) + } + + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + await waitFor(() => { + expect(ed.querySelector('[data-token-text]')).toBeFalsy() + }) + + await act(async () => { + ;(window as any).__setVal('@x') + await flush() + }) + + await waitFor(() => { + expect(ed.querySelector('[data-token-text="@x"]')).toBeTruthy() + }) + + await act(async () => { + ;(window as any).__setVal('B') + await flush() + }) + + await waitFor(() => { + expect(ed.querySelector('[data-token-text]')).toBeFalsy() + }) + }) + + it('context remains usable after external value change (can set selection and get active token)', async () => { + const ref = React.createRef() + function Test() { + const [value, setValue] = React.useState('@x') + ;(window as any).__setVal2 = setValue + return ( + + + @x + + + {({ activeToken }) => ( + + )} + + + ) + } + + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + await waitFor(() => { + expect(ed.querySelector('[data-token-text]')).toBeTruthy() + }) + + await act(async () => { + ;(window as any).__setVal2('A@xB') + await flush() + }) + + await waitFor(() => { + expect(ed.querySelector('[data-token-text]')).toBeTruthy() + }) + + await act(async () => { + ref.current!.setSelection(2) + await flush() + }) + + await waitFor(() => { + const ctx = getByTestId('ctx') + expect(ctx.getAttribute('data-start')).toBe('1') + expect(ctx.getAttribute('data-end')).toBe('3') + }) + }) +}) diff --git a/src/inlay/hooks/use-clipboard.ts b/src/inlay/hooks/use-clipboard.ts new file mode 100644 index 0000000..c2b70d4 --- /dev/null +++ b/src/inlay/hooks/use-clipboard.ts @@ -0,0 +1,182 @@ +import { useCallback, useRef } from 'react' +import { flushSync } from 'react-dom' +import { + getAbsoluteOffset, + setDomSelection, + getClosestTokenEl, + getTokenRawRange +} from '../internal/dom-utils' + +export type ClipboardConfig = { + editorRef: React.RefObject + getValue: () => string + setValue: React.Dispatch> + pushUndoSnapshot?: () => void + isComposingRef: React.MutableRefObject +} + +function getSelectionFromDom( + root: HTMLElement +): { start: number; end: number } | null { + const domSelection = window.getSelection() + if (!domSelection || !domSelection.rangeCount) return null + + const range = domSelection.getRangeAt(0) + if (!root.contains(range.startContainer)) return null + + let start = getAbsoluteOffset(root, range.startContainer, range.startOffset) + let end = getAbsoluteOffset(root, range.endContainer, range.endOffset) + + // Handle snapped offsets when DOM selection exists but offsets collapsed + if (start === end && !range.collapsed) { + const startToken = getClosestTokenEl(range.startContainer) + const endToken = getClosestTokenEl(range.endContainer) + + if (startToken && startToken === endToken) { + const tokenRange = getTokenRawRange(root, startToken) + if (tokenRange) return tokenRange + } else if (startToken) { + const tokenRange = getTokenRawRange(root, startToken) + if (tokenRange) { + start = tokenRange.start + end = tokenRange.end + } + } else if (endToken) { + const tokenRange = getTokenRawRange(root, endToken) + if (tokenRange) { + start = tokenRange.start + end = tokenRange.end + } + } + } + + // Expand partial token selections to full token boundaries + const startToken = getClosestTokenEl(range.startContainer) + if (startToken) { + const tokenRange = getTokenRawRange(root, startToken) + if (tokenRange && start > tokenRange.start && start < tokenRange.end) { + start = tokenRange.start + } + } + + const endToken = getClosestTokenEl(range.endContainer) + if (endToken) { + const tokenRange = getTokenRawRange(root, endToken) + if (tokenRange && end > tokenRange.start && end < tokenRange.end) { + end = tokenRange.end + } + } + + return { start, end } +} + +export function useClipboard(cfg: ClipboardConfig) { + // Track pending selection for rapid operations where DOM hasn't caught up yet + const pendingSelectionRef = useRef<{ start: number; end: number } | null>( + null + ) + const pendingClearTimeoutRef = useRef(null) + + const onCopy = useCallback( + (event: React.ClipboardEvent) => { + event.preventDefault() + + const root = cfg.editorRef.current + if (!root) return + + const sel = getSelectionFromDom(root) + if (!sel || sel.start === sel.end) return + + const rawText = cfg.getValue().slice(sel.start, sel.end) + event.clipboardData.setData('text/plain', rawText) + }, + [cfg] + ) + + const onCut = useCallback( + (event: React.ClipboardEvent) => { + event.preventDefault() + + const root = cfg.editorRef.current + if (!root) return + + const sel = getSelectionFromDom(root) + if (!sel || sel.start === sel.end) return + + const rawText = cfg.getValue().slice(sel.start, sel.end) + event.clipboardData.setData('text/plain', rawText) + + cfg.pushUndoSnapshot?.() + + cfg.setValue((currentValue) => { + const before = currentValue.slice(0, sel.start) + const after = currentValue.slice(sel.end) + + requestAnimationFrame(() => { + const root = cfg.editorRef.current + if (root?.isConnected) setDomSelection(root, sel.start) + }) + + return before + after + }) + }, + [cfg] + ) + + const onPaste = useCallback( + (event: React.ClipboardEvent) => { + if (cfg.isComposingRef.current) return + event.preventDefault() + + const pastedText = event.clipboardData.getData('text/plain') + if (!pastedText) return + + const root = cfg.editorRef.current + if (!root) return + + // Use pending selection if available (from rapid paste), otherwise read from DOM + const sel = pendingSelectionRef.current ?? getSelectionFromDom(root) + if (!sel) return + + cfg.pushUndoSnapshot?.() + + const len = cfg.getValue().length + const safeStart = Math.max(0, Math.min(sel.start, len)) + const newSelection = safeStart + pastedText.length + + // Update pending selection synchronously BEFORE state update + // so subsequent rapid pastes use the correct position + pendingSelectionRef.current = { start: newSelection, end: newSelection } + + // Use flushSync to ensure DOM is updated synchronously + flushSync(() => { + cfg.setValue((currentValue) => { + const len = currentValue.length + const safeStart = Math.max(0, Math.min(sel.start, len)) + const safeEnd = Math.max(0, Math.min(sel.end, len)) + const before = currentValue.slice(0, safeStart) + const after = currentValue.slice(safeEnd) + return before + pastedText + after + }) + }) + + // Set selection immediately after flushSync (DOM is now updated) + if (root.isConnected) { + setDomSelection(root, newSelection) + } + + // Clear pending selection after a short delay, giving time for + // rapid paste events to complete before falling back to DOM + if (pendingClearTimeoutRef.current !== null) { + clearTimeout(pendingClearTimeoutRef.current) + } + pendingClearTimeoutRef.current = window.setTimeout(() => { + pendingClearTimeoutRef.current = null + pendingSelectionRef.current = null + }, 100) + }, + [cfg] + ) + + return { onCopy, onCut, onPaste } +} diff --git a/src/inlay/hooks/use-composition.ts b/src/inlay/hooks/use-composition.ts new file mode 100644 index 0000000..309f6f1 --- /dev/null +++ b/src/inlay/hooks/use-composition.ts @@ -0,0 +1,164 @@ +import { useCallback, useRef, useState } from 'react' +import { getAbsoluteOffset, setDomSelection } from '../internal/dom-utils' + +// Platform detection for iOS-specific handling +const isIOS = + typeof navigator !== 'undefined' && + (/iPad|iPhone|iPod/.test(navigator.userAgent) || + (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)) + +export function useComposition( + editorRef: React.RefObject, + serializeRawFromDom: () => string, + handleSelectionChange: () => void, + setValue: (updater: (prev: string) => string) => void, + getCurrentValue: () => string +) { + const [isComposing, setIsComposing] = useState(false) + const [contentKey, setContentKey] = useState(0) + const isComposingRef = useRef(false) + const compositionStartSelectionRef = useRef<{ + start: number + end: number + } | null>(null) + const compositionInitialValueRef = useRef(null) + const suppressNextBeforeInputRef = useRef(false) + const suppressNextKeydownCommitRef = useRef(null) + const compositionCommitKeyRef = useRef<'enter' | 'space' | null>(null) + const compositionJustEndedAtRef = useRef(0) + // Track last composition data for iOS workaround + const lastCompositionDataRef = useRef('') + + const onCompositionStart = useCallback( + (event: React.CompositionEvent) => { + console.log('[compositionstart]', { + data: event.data + }) + if (!editorRef.current) return + isComposingRef.current = true + setIsComposing(true) + const sel = window.getSelection() + if (sel && sel.rangeCount > 0) { + const r = sel.getRangeAt(0) + const start = getAbsoluteOffset( + editorRef.current, + r.startContainer, + r.startOffset + ) + const end = getAbsoluteOffset( + editorRef.current, + r.endContainer, + r.endOffset + ) + compositionStartSelectionRef.current = { start, end } + } + compositionInitialValueRef.current = getCurrentValue() + }, + [editorRef, getCurrentValue] + ) + + const onCompositionUpdate = useCallback( + (event: React.CompositionEvent) => { + // iOS Safari sometimes doesn't include data in compositionend, + // so we track the last composition data during updates + const data = (event as unknown as { data?: string }).data + if (data) { + lastCompositionDataRef.current = data + } + }, + [] + ) + + const onCompositionEnd = useCallback( + (event: React.CompositionEvent) => { + const eventData = (event as unknown as { data?: string }).data + console.log('[compositionend]', { + data: eventData, + lastCompositionData: lastCompositionDataRef.current + }) + + const root = editorRef.current + if (!root) { + isComposingRef.current = false + setIsComposing(false) + return + } + suppressNextBeforeInputRef.current = true + console.log('[compositionend] setting suppressNextBeforeInput = true') + + // Build committed value + let committed = (event as unknown as { data?: string }).data || '' + + // iOS Safari workaround: compositionend may not include data, + // fall back to last tracked composition data from updates + if (!committed && isIOS && lastCompositionDataRef.current) { + committed = lastCompositionDataRef.current + } + + const baseValue = compositionInitialValueRef.current ?? getCurrentValue() + const range = compositionStartSelectionRef.current ?? { start: 0, end: 0 } + const len = baseValue.length + const safeStart = Math.max(0, Math.min(range.start, len)) + const safeEnd = Math.max(0, Math.min(range.end, len)) + const before = baseValue.slice(0, safeStart) + const after = baseValue.slice(safeEnd) + + if (!committed) { + const domText = serializeRawFromDom() + const replacedLen = safeEnd - safeStart + const insertedLen = Math.max( + 0, + domText.length - (baseValue.length - replacedLen) + ) + if (insertedLen > 0 && safeStart + insertedLen <= domText.length) { + committed = domText.slice(safeStart, safeStart + insertedLen) + } + } + + setValue(() => before + committed + after) + + // Force a remount to purge any transient IME DOM artifacts left behind + setContentKey((k) => k + 1) + + // One-shot suppress the immediate commit keydown regardless of engine + // Prefer the key recorded during composition; default to 'enter' + suppressNextKeydownCommitRef.current = + compositionCommitKeyRef.current ?? 'enter' + compositionJustEndedAtRef.current = Date.now() + + requestAnimationFrame(() => { + const r = editorRef.current + if (!r) return + setDomSelection(r, safeStart + committed.length) + handleSelectionChange() + }) + + isComposingRef.current = false + setIsComposing(false) + compositionInitialValueRef.current = null + compositionStartSelectionRef.current = null + compositionCommitKeyRef.current = null + lastCompositionDataRef.current = '' + }, + [ + editorRef, + getCurrentValue, + handleSelectionChange, + serializeRawFromDom, + setValue + ] + ) + + return { + isComposing, + isComposingRef, + suppressNextBeforeInputRef, + suppressNextKeydownCommitRef, + compositionCommitKeyRef, + compositionJustEndedAtRef, + contentKey, + onCompositionStart, + onCompositionUpdate, + onCompositionEnd + } +} diff --git a/src/inlay/hooks/use-history.ts b/src/inlay/hooks/use-history.ts new file mode 100644 index 0000000..74961bb --- /dev/null +++ b/src/inlay/hooks/use-history.ts @@ -0,0 +1,98 @@ +import { useCallback, useRef } from 'react' + +export type Snapshot = { + value: string + selection: { start: number; end: number } +} + +export function useHistory( + getCurrentSnapshot: () => Snapshot, + applySnapshot: (snap: Snapshot) => void, + maxHistory: number = 200 +) { + const undoStackRef = useRef([]) + const redoStackRef = useRef([]) + + const pushUndoSnapshot = useCallback(() => { + const snap = getCurrentSnapshot() + const stack = undoStackRef.current + if (stack.length >= maxHistory) stack.shift() + stack.push(snap) + // New edits invalidate redo history + redoStackRef.current = [] + }, [getCurrentSnapshot, maxHistory]) + + const editSessionRef = useRef<{ + type: 'insert' | 'delete' | null + timer: number | null + }>({ + type: null, + timer: null + }) + + const endEditSession = useCallback(() => { + const s = editSessionRef.current + if (s.timer != null) { + clearTimeout(s.timer) + } + editSessionRef.current = { type: null, timer: null } + }, []) + + const beginEditSession = useCallback( + (type: 'insert' | 'delete') => { + const s = editSessionRef.current + if (s.type !== type) { + // Different kind resets session + endEditSession() + } + if (editSessionRef.current.type === null) { + // Start of a new coalesced chunk: push snapshot + pushUndoSnapshot() + } + // Refresh session + const timer = window.setTimeout(() => { + endEditSession() + }, 800) + editSessionRef.current = { type, timer } + }, + [endEditSession, pushUndoSnapshot] + ) + + const undo = useCallback(() => { + const stack = undoStackRef.current + if (stack.length > 0) { + const current = getCurrentSnapshot() + const last = stack.pop()! + const redoStack = redoStackRef.current + if (redoStack.length >= maxHistory) redoStack.shift() + redoStack.push(current) + applySnapshot(last) + return true + } + return false + }, [applySnapshot, getCurrentSnapshot, maxHistory]) + + const redo = useCallback(() => { + const redoStack = redoStackRef.current + if (redoStack.length > 0) { + const current = getCurrentSnapshot() + const next = redoStack.pop()! + const undoStack = undoStackRef.current + if (undoStack.length >= maxHistory) undoStack.shift() + undoStack.push(current) + applySnapshot(next) + return true + } + return false + }, [applySnapshot, getCurrentSnapshot, maxHistory]) + + return { + undoStackRef, + redoStackRef, + pushUndoSnapshot, + beginEditSession, + endEditSession, + undo, + redo + } +} diff --git a/src/inlay/hooks/use-key-handlers.ts b/src/inlay/hooks/use-key-handlers.ts new file mode 100644 index 0000000..8b489dc --- /dev/null +++ b/src/inlay/hooks/use-key-handlers.ts @@ -0,0 +1,1013 @@ +import React, { useCallback, useEffect, useRef } from 'react' +import { + getAbsoluteOffset, + getClosestTokenEl, + setDomSelection, + serializeRawFromDom +} from '../internal/dom-utils' +import { + nextGraphemeEnd, + prevGraphemeStart, + snapGraphemeEnd, + snapGraphemeStart +} from '../internal/string-utils' + +const isJsdom = + typeof navigator !== 'undefined' && /jsdom/i.test(navigator.userAgent || '') + +// Platform detection for iOS-specific handling +const isIOS = + typeof navigator !== 'undefined' && + (/iPad|iPhone|iPod/.test(navigator.userAgent) || + (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)) + +function scheduleSelection(cb: () => void) { + if (isJsdom) { + setTimeout(cb, 0) + } else if (typeof requestAnimationFrame === 'function') { + requestAnimationFrame(cb) + } else { + setTimeout(cb, 0) + } +} + +// Timeout for pending selection validity (ms) +const PENDING_SELECTION_TIMEOUT = 100 + +// Timeout for iOS swipe-text word deletion detection (ms) +// When backspace is pressed at the end of a recent multi-char insert, delete the whole chunk. +// iOS has no timeout - we use a generous value to avoid false negatives while still +// clearing stale tracking eventually. The main protection is position matching. +const SWIPE_TEXT_DELETE_TIMEOUT = 30000 // 30 seconds + +export type KeyHandlersConfig = { + editorRef: React.RefObject + contentKey: number // Changes when editor element is recreated (e.g., after IME composition) + multiline: boolean + onKeyDownProp?: (event: React.KeyboardEvent) => boolean | void + beginEditSession: (type: 'insert' | 'delete') => void + endEditSession: () => void + pushUndoSnapshot: () => void + undo: () => boolean + redo: () => boolean + isComposingRef: React.MutableRefObject + compositionCommitKeyRef: React.MutableRefObject<'enter' | 'space' | null> + suppressNextBeforeInputRef: React.MutableRefObject + suppressNextKeydownCommitRef: React.MutableRefObject + compositionJustEndedAtRef: React.MutableRefObject + setValue: React.Dispatch> + valueRef: React.MutableRefObject // Current value for sync checks + getActiveToken: () => { start: number; end: number } | null +} + +function selectionIntersectsToken(editor: HTMLElement): boolean { + const sel = window.getSelection() + if (!sel || sel.rangeCount === 0) return false + const rng = sel.getRangeAt(0) + const tokens = editor.querySelectorAll('[data-token-text]') + for (let i = 0; i < tokens.length; i++) { + const el = tokens[i] + const rangeIntersects = rng.intersectsNode + if (typeof rangeIntersects === 'function') { + if (rangeIntersects.call(rng, el)) return true + } else { + const tr = document.createRange() + tr.selectNode(el) + const overlap = + rng.compareBoundaryPoints(Range.END_TO_START, tr) === 1 && + rng.compareBoundaryPoints(Range.START_TO_END, tr) === -1 + if (overlap) return true + } + } + return false +} + +export function useKeyHandlers(cfg: KeyHandlersConfig) { + // Track pending selection to avoid stale window.getSelection() during rapid input + const pendingSelectionRef = useRef<{ pos: number; time: number } | null>(null) + + // Track last multi-char insert for iOS swipe-text word deletion detection + // When user swipe-types a word and presses backspace, iOS only sends one + // deleteContentBackward event for the last char, but expects the whole word deleted + const lastMultiCharInsertRef = useRef<{ + start: number + end: number + data: string // Track the actual inserted text for space preservation + time: number + } | null>(null) + + // Track when we actually prevented a beforeinput event (for MutationObserver to know) + const lastPreventedTimeRef = useRef(0) + + // Handle beforeinput via native event listener (React's synthetic event is unreliable) + const handleBeforeInput = useCallback( + (event: InputEvent) => { + const { editorRef } = cfg + if (!editorRef.current) return + + // Helper to mark that we're handling this event (for input handler to know) + const preventAndMark = () => { + event.preventDefault() + lastPreventedTimeRef.current = Date.now() + } + + const data: string | null | undefined = event.data + const inputType: string | undefined = event.inputType + + if (cfg.suppressNextBeforeInputRef.current) { + cfg.suppressNextBeforeInputRef.current = false + preventAndMark() + return + } + + // Immediately after compositionend, WebKit may fire an extra beforeinput inserting a newline. + // Block it within a short window regardless of current composition state. + if ( + cfg.compositionJustEndedAtRef.current && + Date.now() - cfg.compositionJustEndedAtRef.current < 50 && + (inputType === 'insertParagraph' || inputType === 'insertLineBreak') + ) { + preventAndMark() + return + } + + if (cfg.isComposingRef.current) { + if ( + inputType === 'insertParagraph' || + inputType === 'insertLineBreak' + ) { + preventAndMark() + } + return + } + + // Handle text insertions (insertText and insertReplacementText) + if (inputType === 'insertText' || inputType === 'insertReplacementText') { + // iOS handling: Let iOS modify DOM directly (prevents multi-word suggestions) + // EXCEPT when: has newlines (React crash) or in token context (stale DOM attributes) + const hasNewlines = cfg.valueRef.current.includes('\n') + const domSelection = window.getSelection() + const range = domSelection?.rangeCount + ? domSelection.getRangeAt(0) + : null + const isInTokenContext = range + ? !!getClosestTokenEl(range.startContainer) + : false + + const insertData = + inputType === 'insertReplacementText' + ? (data ?? event.dataTransfer?.getData('text/plain')) + : data + + if (isIOS && !hasNewlines && !isInTokenContext) { + return + } + + if (isIOS && hasNewlines && !insertData) { + // iOS with newlines but no data available - can't handle safely + // This shouldn't normally happen, but if it does, prevent and skip + preventAndMark() + return + } + + preventAndMark() + + // insertData already obtained above + if (!insertData) return + + // For insertReplacementText, use target ranges + let start: number + let end: number + + if (inputType === 'insertReplacementText') { + const targetRanges = event.getTargetRanges?.() + if (!targetRanges || targetRanges.length === 0) return + const targetRange = targetRanges[0] + start = getAbsoluteOffset( + editorRef.current, + targetRange.startContainer, + targetRange.startOffset + ) + end = getAbsoluteOffset( + editorRef.current, + targetRange.endContainer, + targetRange.endOffset + ) + } else { + // insertText: use current selection + const pending = pendingSelectionRef.current + if ( + pending && + Date.now() - pending.time < PENDING_SELECTION_TIMEOUT + ) { + start = pending.pos + end = pending.pos + } else { + const domSelection = window.getSelection() + if (!domSelection || !domSelection.rangeCount) return + const range = domSelection.getRangeAt(0) + start = getAbsoluteOffset( + editorRef.current, + range.startContainer, + range.startOffset + ) + end = getAbsoluteOffset( + editorRef.current, + range.endContainer, + range.endOffset + ) + } + } + + cfg.beginEditSession('insert') + cfg.setValue((currentValue) => { + const len = currentValue.length + const safeStart = Math.max(0, Math.min(start, len)) + const safeEnd = Math.max(0, Math.min(end, len)) + const before = currentValue.slice(0, safeStart) + const after = currentValue.slice(safeEnd) + + // Strip leading space when: + // 1. Inserting after a space (prevent double-space) + // 2. Inserting at start of line (after newline) - iOS swipe often adds leading space + // 3. Inserting at start of content (empty before) + // + // iOS sometimes sends the space as a SEPARATE event before the word, + // so we need to handle both single-space and space-prefixed insertions. + let finalInsertData = insertData + const shouldStripSpace = + insertData.startsWith(' ') && + (before.endsWith(' ') || + before.endsWith('\n') || + before.length === 0) + + if (shouldStripSpace) { + if (insertData === ' ') { + // Single space at start of line - skip entirely + return currentValue + } + // Multi-char with leading space - strip the space + finalInsertData = insertData.slice(1) + } + + const newValue = before + finalInsertData + after + const newSelection = safeStart + finalInsertData.length + + // Track as multi-char insert for swipe-text backspace detection + // ONLY track insertText (swipe-typing), NOT insertReplacementText (autocomplete) + // When user types "hel" and taps "hello" suggestion, backspace should delete char-by-char + const isSwipeText = inputType === 'insertText' + + if (isSwipeText && finalInsertData.length > 1) { + lastMultiCharInsertRef.current = { + start: safeStart, + end: newSelection, + data: finalInsertData, + time: Date.now() + } + } else if ( + isSwipeText && + finalInsertData === ' ' && + lastMultiCharInsertRef.current + ) { + // Extend tracking if adding trailing space to multi-char insert + const lastInsert = lastMultiCharInsertRef.current + if (safeStart === lastInsert.end) { + lastMultiCharInsertRef.current = { + start: lastInsert.start, + end: newSelection, + data: lastInsert.data + ' ', + time: lastInsert.time + } + } else { + lastMultiCharInsertRef.current = null + } + } else { + // Autocomplete (insertReplacementText) or single char - clear swipe tracking + lastMultiCharInsertRef.current = null + } + + pendingSelectionRef.current = { pos: newSelection, time: Date.now() } + + scheduleSelection(() => { + const root = editorRef.current + if (!root || !root.isConnected) return + setDomSelection(root, newSelection) + }) + return newValue + }) + return + } + + // Handle various delete input types (including mobile-specific ones) + if ( + inputType === 'deleteContentBackward' || + inputType === 'deleteContentForward' || + inputType === 'deleteWordBackward' || + inputType === 'deleteWordForward' || + inputType === 'deleteSoftLineBackward' || + inputType === 'deleteSoftLineForward' + ) { + preventAndMark() + + // iOS swipe-text fix: Use targetRanges when available AND the nodes are still connected. + // After rapid beforeinput events (e.g., iOS word deletion), React re-renders replace + // text nodes, making subsequent targetRanges point to orphaned nodes. + // In that case, use pending selection which tracks the expected cursor position. + const targetRanges = event.getTargetRanges?.() + + let start: number + let end: number + + // Check if targetRanges point to nodes still in the DOM + const targetRange = targetRanges?.[0] + const targetNodesConnected = + targetRange && + editorRef.current?.contains(targetRange.startContainer) && + editorRef.current?.contains(targetRange.endContainer) + + if (targetRange && targetNodesConnected) { + start = getAbsoluteOffset( + editorRef.current, + targetRange.startContainer, + targetRange.startOffset + ) + end = getAbsoluteOffset( + editorRef.current, + targetRange.endContainer, + targetRange.endOffset + ) + } else { + // Fallback: Use pending selection (for rapid deletes) or window.getSelection() + const pending = pendingSelectionRef.current + if ( + pending && + Date.now() - pending.time < PENDING_SELECTION_TIMEOUT + ) { + // Use pending selection for rapid iOS word deletion + start = pending.pos + end = pending.pos + } else { + const domSelection = window.getSelection() + if (!domSelection || !domSelection.rangeCount) return + const range = domSelection.getRangeAt(0) + start = getAbsoluteOffset( + editorRef.current, + range.startContainer, + range.startOffset + ) + end = getAbsoluteOffset( + editorRef.current, + range.endContainer, + range.endOffset + ) + } + } + + // iOS swipe-text word deletion detection: + // When user swipe-types a word and presses backspace, iOS sends ONE + // deleteContentBackward event with targetRange for just the last char. + // But the user expects the whole word to be deleted. + // Detect this by checking if we're deleting at the end of a recent multi-char insert. + const lastInsert = lastMultiCharInsertRef.current + const timeSinceInsert = lastInsert ? Date.now() - lastInsert.time : null + const matchesEnd = lastInsert ? end === lastInsert.end : false + const withinTimeout = + timeSinceInsert !== null && + timeSinceInsert < SWIPE_TEXT_DELETE_TIMEOUT + + if ( + inputType === 'deleteContentBackward' && + lastInsert && + withinTimeout && + matchesEnd + ) { + // This looks like iOS swipe-text deletion - delete the whole chunk + // But if the insert started with a space (iOS auto-inserts space before swipe words), + // preserve that space - only delete the word part + // EXCEPT: if the character before the insert is also a space (double-space scenario), + // delete the leading space too to avoid leaving a double space + const startsWithSpace = lastInsert.data.startsWith(' ') + + cfg.setValue((currentValue) => { + const charBefore = + lastInsert.start > 0 + ? currentValue.charAt(lastInsert.start - 1) + : '' + const prevIsSpace = charBefore === ' ' + + // If starts with space AND prev char is NOT a space, preserve the space + // Otherwise, delete from the start (including the leading space) + const preserveLeadingSpace = startsWithSpace && !prevIsSpace + const deleteStart = preserveLeadingSpace + ? lastInsert.start + 1 + : lastInsert.start + + // Actually perform the deletion here since we need currentValue + const before = currentValue.slice(0, deleteStart) + const after = currentValue.slice(end) + const newValue = before + after + + pendingSelectionRef.current = { + pos: deleteStart, + time: Date.now() + } + + scheduleSelection(() => { + const root = editorRef.current + if (!root || !root.isConnected) return + setDomSelection(root, deleteStart) + }) + + return newValue + }) + + lastMultiCharInsertRef.current = null // Clear tracking + return // Early return since we handled the deletion + } + + cfg.beginEditSession('delete') + cfg.setValue((currentValue) => { + const len = currentValue.length + const safeStart = Math.max(0, Math.min(start, len)) + const safeEnd = Math.max(0, Math.min(end, len)) + let newSelection = safeStart + let before = '' + let after = '' + + // For deleteContentBackward: + // - If there's a range selection (safeStart !== safeEnd), delete the entire selection + // - If it's a collapsed cursor, delete one grapheme backward + if (inputType === 'deleteContentBackward') { + // Handle range selection (delete entire selected range) + if (safeStart !== safeEnd) { + if (selectionIntersectsToken(editorRef.current!)) { + before = currentValue.slice(0, safeStart) + after = currentValue.slice(safeEnd) + newSelection = safeStart + } else { + const adjStart = snapGraphemeStart(currentValue, safeStart) + const adjEnd = snapGraphemeEnd(currentValue, safeEnd) + before = currentValue.slice(0, adjStart) + after = currentValue.slice(adjEnd) + newSelection = adjStart + } + } else { + // Collapsed cursor - delete one char/grapheme backward + const cursorPos = safeEnd + if (cursorPos === 0) return currentValue + const active = cfg.getActiveToken() + const insideToken = !!( + active && + cursorPos > active.start && + cursorPos <= active.end + ) + // Clear swipe-text tracking when near a token + if (active) lastMultiCharInsertRef.current = null + if (insideToken) { + const delStart = cursorPos - 1 + before = currentValue.slice(0, delStart) + after = currentValue.slice(cursorPos) + newSelection = delStart + } else { + const clusterStart = prevGraphemeStart(currentValue, cursorPos) + before = currentValue.slice(0, clusterStart) + after = currentValue.slice(cursorPos) + newSelection = clusterStart + } + } + } else if (inputType === 'deleteContentForward') { + const cursorPos = safeStart + if (cursorPos === len) return currentValue + const active = cfg.getActiveToken() + const insideToken = !!( + active && + cursorPos >= active.start && + cursorPos < active.end + ) + if (insideToken) { + const delEnd = cursorPos + 1 + before = currentValue.slice(0, cursorPos) + after = currentValue.slice(delEnd) + newSelection = cursorPos + } else { + const clusterEnd = nextGraphemeEnd(currentValue, cursorPos) + before = currentValue.slice(0, cursorPos) + after = currentValue.slice(clusterEnd) + newSelection = cursorPos + } + } else { + // Other delete types (word, line) - use the provided range + if (selectionIntersectsToken(editorRef.current!)) { + before = currentValue.slice(0, safeStart) + after = currentValue.slice(safeEnd) + newSelection = safeStart + } else { + const adjStart = snapGraphemeStart(currentValue, safeStart) + const adjEnd = snapGraphemeEnd(currentValue, safeEnd) + before = currentValue.slice(0, adjStart) + after = currentValue.slice(adjEnd) + newSelection = adjStart + } + } + // Store pending selection for rapid input handling + pendingSelectionRef.current = { pos: newSelection, time: Date.now() } + + scheduleSelection(() => { + const root = editorRef.current + if (!root || !root.isConnected) return + setDomSelection(root, newSelection) + }) + return before + after + }) + return + } + }, + [cfg] + ) + + // Attach native beforeinput listener (React's synthetic event is unreliable for beforeinput) + // Re-attach when contentKey changes (editor element is recreated after IME composition) + useEffect(() => { + const editor = cfg.editorRef.current + if (!editor) return + + const beforeInputListener = (e: Event) => handleBeforeInput(e as InputEvent) + editor.addEventListener('beforeinput', beforeInputListener) + + // iOS DOM → React state sync: + // When we don't preventDefault on iOS text insertions, iOS modifies the DOM directly. + // We use the input event to sync the DOM content back to React state. + // This uses serializeRawFromDom to correctly handle token elements. + const inputListener = (e: Event) => { + if (!isIOS) return // Only needed for iOS + + const ie = e as InputEvent + const inputType = ie.inputType + + // Handle text insertions (we didn't preventDefault, iOS modified DOM) + if ( + inputType === 'insertText' || + inputType === 'insertReplacementText' || + inputType === 'insertFromPaste' || + inputType === 'insertFromDrop' + ) { + // Skip if we just prevented a beforeinput event (handled it ourselves) + const now = Date.now() + if (now - lastPreventedTimeRef.current < 50) { + return + } + + // Get the raw DOM content (before stripping invisible chars) + const rawDomContent = editor.innerText || '' + + // Serialize DOM to get the correct text value (respecting token data-attributes) + // This strips zero-width spaces and other invisible characters + const newValue = serializeRawFromDom(editor) + + // Get current cursor position from DOM BEFORE React re-renders + const domSelection = window.getSelection() + let cursorPos = newValue.length + if (domSelection && domSelection.rangeCount > 0) { + const range = domSelection.getRangeAt(0) + const rawCursorPos = getAbsoluteOffset( + editor, + range.startContainer, + range.startOffset + ) + + // Adjust cursor position for any invisible characters that were stripped + // Count how many invisible chars exist before the cursor in raw content + const beforeCursor = rawDomContent.slice(0, rawCursorPos) + const invisibleCharsBeforeCursor = ( + beforeCursor.match(/[\u200B\uFEFF]/g) || [] + ).length + cursorPos = Math.max( + 0, + Math.min(rawCursorPos - invisibleCharsBeforeCursor, newValue.length) + ) + } + + // Track multi-char inserts for iOS swipe-text word deletion + // We need to distinguish between: + // - Swipe-typing: User swipes across keyboard to type a whole word at once + // → Backspace should delete the whole word + // - Autocomplete/suggestion: User types partial word, taps suggestion + // → Backspace should delete char-by-char + // + // iOS may send insertText for both! So we can't rely on inputType alone. + // Better heuristic: swipe-typing inserts AFTER a space or at start. + // Autocomplete typically replaces text mid-word. + const isSwipeText = inputType === 'insertText' + + // Check if cursor is in a token - skip swipe-text tracking if so + const range = domSelection?.rangeCount + ? domSelection.getRangeAt(0) + : null + const isInTokenContext = range + ? !!getClosestTokenEl(range.startContainer) + : false + + // We need to track if we added a space so we can adjust cursor position + let addedSpace = false + + cfg.setValue((oldValue) => { + let finalValue = newValue + let adjustedCursorPos = cursorPos + const insertedLength = newValue.length - oldValue.length + const insertStart = cursorPos - insertedLength + + // iOS often adds a leading space to swipe-typed words. + // Strip it when inserting after a newline or at the start of content. + if (insertedLength > 1 && insertStart >= 0) { + const charBeforeInsert = + insertStart > 0 ? oldValue.charAt(insertStart - 1) : '' + const insertedData = newValue.slice(insertStart, cursorPos) + + // Strip leading space if: + // 1. The inserted text starts with a space + // 2. AND we're at start of content OR after a newline OR after an existing space + if ( + insertedData.startsWith(' ') && + (insertStart === 0 || + charBeforeInsert === '\n' || + charBeforeInsert === ' ') + ) { + // Remove the leading space from the inserted text + finalValue = + oldValue.slice(0, insertStart) + + insertedData.slice(1) + + oldValue.slice(insertStart) + adjustedCursorPos = cursorPos - 1 + } + } + + // Recalculate for the adjusted value + const adjustedInsertedLength = finalValue.length - oldValue.length + const adjustedInsertStart = adjustedCursorPos - adjustedInsertedLength + + // Swipe-text vs autocomplete detection: + // - Swipe-typing: after space/start → track for whole-word deletion + // - Autocomplete: mid-word extension → char-by-char deletion + // - Token context: always char-by-char deletion + if (isInTokenContext) { + lastMultiCharInsertRef.current = null + } else if ( + isSwipeText && + adjustedInsertedLength > 1 && + adjustedInsertStart >= 0 + ) { + const lastCharOfOld = + oldValue.length > 0 ? oldValue.charAt(oldValue.length - 1) : '' + const endsWithWordChar = + lastCharOfOld && lastCharOfOld !== ' ' && lastCharOfOld !== '\n' + + // If oldValue ends mid-word, this is likely autocomplete + // (user typed "hel" and tapped "hello" to extend it) + if (endsWithWordChar) { + // Autocomplete - don't track for whole-word deletion + lastMultiCharInsertRef.current = null + } else { + // Swipe-typing (oldValue ends with space, newline, or is empty) + const charBeforeAdjustedInsert = + adjustedInsertStart > 0 + ? oldValue.charAt(adjustedInsertStart - 1) + : '' + const adjustedInsertedData = finalValue.slice( + adjustedInsertStart, + adjustedCursorPos + ) + + if ( + adjustedInsertStart > 0 && + charBeforeAdjustedInsert && + charBeforeAdjustedInsert !== ' ' && + charBeforeAdjustedInsert !== '\n' && + !adjustedInsertedData.startsWith(' ') + ) { + // Need to add a space before the inserted word + finalValue = + oldValue.slice(0, adjustedInsertStart) + + ' ' + + adjustedInsertedData + + oldValue.slice(adjustedInsertStart) + addedSpace = true + + // Track with the added space + lastMultiCharInsertRef.current = { + start: adjustedInsertStart, + end: adjustedCursorPos + 1, + data: ' ' + adjustedInsertedData, + time: Date.now() + } + } else { + // Normal swipe - track for whole-word deletion + lastMultiCharInsertRef.current = { + start: adjustedInsertStart, + end: adjustedCursorPos, + data: adjustedInsertedData, + time: Date.now() + } + } + } + } else if (isSwipeText && adjustedInsertedLength === 1) { + // Single char from swipe - check if it's a trailing space after a swipe + const insertedChar = finalValue.charAt(adjustedCursorPos - 1) + const lastInsert = lastMultiCharInsertRef.current + if ( + insertedChar === ' ' && + lastInsert && + adjustedInsertStart === lastInsert.end + ) { + // Extend the tracking to include the trailing space + lastMultiCharInsertRef.current = { + start: lastInsert.start, + end: adjustedCursorPos, + data: lastInsert.data + ' ', + time: lastInsert.time + } + } else { + // Regular single char, clear tracking + lastMultiCharInsertRef.current = null + } + } else if (!isSwipeText) { + // Autocomplete/suggestion (insertReplacementText) - clear swipe tracking + // User should be able to backspace char-by-char + lastMultiCharInsertRef.current = null + } + + // Update pending selection for rapid input handling + const finalCursorPos = addedSpace + ? adjustedCursorPos + 1 + : adjustedCursorPos + pendingSelectionRef.current = { + pos: finalCursorPos, + time: Date.now() + } + + return finalValue + }) + + // Restore caret position after React re-renders + // Use pendingSelectionRef since the actual position is calculated inside setValue + scheduleSelection(() => { + const root = cfg.editorRef.current + if (!root || !root.isConnected) return + const pending = pendingSelectionRef.current + const pos = pending ? pending.pos : cursorPos + setDomSelection(root, pos) + }) + } + + // After deletions, sync DOM → React to ensure they stay in sync + // This catches any cases where our deletion logic didn't perfectly match iOS's DOM state + if ( + inputType === 'deleteContentBackward' || + inputType === 'deleteContentForward' || + inputType === 'deleteWordBackward' || + inputType === 'deleteWordForward' || + inputType === 'deleteSoftLineBackward' || + inputType === 'deleteSoftLineForward' + ) { + // Small delay to let our beforeinput handler complete first + setTimeout(() => { + const root = cfg.editorRef.current + if (!root || !root.isConnected) return + + const domValue = serializeRawFromDom(root) + cfg.setValue((currentValue) => { + // Only sync if they're different (our handler might have already set it correctly) + if (currentValue !== domValue) { + return domValue + } + return currentValue + }) + }, 0) + } + } + editor.addEventListener('input', inputListener) + + return () => { + editor.removeEventListener('beforeinput', beforeInputListener) + editor.removeEventListener('input', inputListener) + } + }, [cfg.editorRef, cfg.contentKey, handleBeforeInput, cfg.setValue]) + + // Keep onBeforeInput as a no-op for backward compatibility (actual handling is via native listener) + const onBeforeInput = useCallback(() => { + // No-op: beforeinput is handled via native event listener + }, []) + + const onKeyDown = useCallback( + (event: React.KeyboardEvent) => { + // One-shot suppression for the immediate keydown fired after compositionend (WebKit bug) + if (cfg.suppressNextKeydownCommitRef.current) { + const sup = cfg.suppressNextKeydownCommitRef.current + const isEnter = event.key === 'Enter' || event.key === 'Return' + const isSpace = event.key === ' ' + if ((sup === 'enter' && isEnter) || (sup === 'space' && isSpace)) { + event.preventDefault() + event.stopPropagation() + cfg.suppressNextKeydownCommitRef.current = null + return + } + // Clear suppression if next key is different + cfg.suppressNextKeydownCommitRef.current = null + } + + if (cfg.onKeyDownProp?.(event)) return + + if (!cfg.multiline && event.key === 'Enter') { + event.preventDefault() + return + } + + if ( + event.key.startsWith('Arrow') || + event.metaKey || + event.ctrlKey || + event.altKey + ) { + if (!(event.key === 'Shift')) cfg.endEditSession() + } + + if ((event.metaKey || event.ctrlKey) && !event.altKey) { + const isUndo = event.key.toLowerCase() === 'z' && !event.shiftKey + const isRedo = + (event.key.toLowerCase() === 'z' && event.shiftKey) || + event.key.toLowerCase() === 'y' + if (isUndo) { + if (cfg.undo()) { + event.preventDefault() + return + } + } else if (isRedo) { + if (cfg.redo()) { + event.preventDefault() + return + } + } + } + + const { editorRef } = cfg + if (!editorRef.current) return + const domSelection = window.getSelection() + if (!domSelection || !domSelection.rangeCount) return + + const range = domSelection.getRangeAt(0) + const start = getAbsoluteOffset( + editorRef.current, + range.startContainer, + range.startOffset + ) + + if (cfg.isComposingRef.current) { + if (event.key === 'Enter' || event.key === 'Return') { + event.preventDefault() + event.stopPropagation() + cfg.compositionCommitKeyRef.current = 'enter' + return + } + if (event.key === ' ') { + event.preventDefault() + event.stopPropagation() + cfg.compositionCommitKeyRef.current = 'space' + return + } + return + } + + if (event.key === 'Enter') { + event.preventDefault() + const end = getAbsoluteOffset( + editorRef.current, + range.endContainer, + range.endOffset + ) + cfg.pushUndoSnapshot() + cfg.endEditSession() + cfg.setValue((currentValue) => { + const len = currentValue.length + const safeStart = Math.max(0, Math.min(start, len)) + const safeEnd = Math.max(0, Math.min(end, len)) + const before = currentValue.slice(0, safeStart) + const after = currentValue.slice(safeEnd) + const newValue = before + '\n' + after + const newSelection = safeStart + 1 + scheduleSelection(() => { + const root = editorRef.current + if (!root || !root.isConnected) return + setDomSelection(root, newSelection) + + // iOS fix: Reset autocomplete state after newline. + // iOS sometimes doesn't see as a word boundary and suggests + // merged words like "helloworld" instead of treating them separately. + // Toggle autocomplete attribute to reset iOS's autocomplete context + // without dismissing the keyboard (unlike blur/focus). + if (isIOS) { + const currentAutocomplete = root.getAttribute('autocomplete') + root.setAttribute('autocomplete', 'off') + requestAnimationFrame(() => { + if (root.isConnected) { + if (currentAutocomplete) { + root.setAttribute('autocomplete', currentAutocomplete) + } else { + root.removeAttribute('autocomplete') + } + } + }) + } + }) + return newValue + }) + } + + // On iOS, don't intercept space - let it flow to beforeinput/input + // so that iOS multi-word keyboard suggestions can work. + // On desktop, handle space directly for consistent behavior. + if (event.key === ' ' && !isIOS) { + event.preventDefault() + cfg.beginEditSession('insert') + cfg.setValue((currentValue) => { + const len = currentValue.length + const safeStart = Math.max(0, Math.min(start, len)) + const safeEnd = Math.max(0, Math.min(start, len)) + const before = currentValue.slice(0, safeStart) + const after = currentValue.slice(safeEnd) + const newValue = before + ' ' + after + const newSelection = safeStart + 1 + scheduleSelection(() => { + const root = editorRef.current + if (!root || !root.isConnected) return + setDomSelection(root, newSelection) + }) + return newValue + }) + } + + // Backspace is handled by beforeinput (deleteContentBackward) to support + // iOS swipe-text word deletion which fires multiple beforeinput events. + // Not calling preventDefault here allows beforeinput to fire. + + if (event.key === 'Delete') { + event.preventDefault() + cfg.beginEditSession('delete') + cfg.setValue((currentValue) => { + if (!currentValue) return '' + const len = currentValue.length + const safeStart = Math.max(0, Math.min(start, len)) + let newSelection = safeStart + let before: string + let after: string + if (range.collapsed) { + if (safeStart === len) return currentValue + const active = cfg.getActiveToken() + const insideToken = !!( + active && + safeStart >= active.start && + safeStart < active.end + ) + if (insideToken) { + const delEnd = safeStart + 1 + before = currentValue.slice(0, safeStart) + after = currentValue.slice(delEnd) + newSelection = safeStart + } else { + const clusterEnd = nextGraphemeEnd(currentValue, safeStart) + before = currentValue.slice(0, safeStart) + after = currentValue.slice(clusterEnd) + newSelection = safeStart + } + } else { + const end = getAbsoluteOffset( + editorRef.current!, + range.endContainer, + range.endOffset + ) + const safeEnd = Math.max(0, Math.min(end, len)) + if (selectionIntersectsToken(editorRef.current!)) { + before = currentValue.slice(0, safeStart) + after = currentValue.slice(safeEnd) + newSelection = safeStart + } else { + const adjStart = snapGraphemeStart(currentValue, safeStart) + const adjEnd = snapGraphemeEnd(currentValue, safeEnd) + before = currentValue.slice(0, adjStart) + after = currentValue.slice(adjEnd) + newSelection = adjStart + } + } + scheduleSelection(() => { + const root = editorRef.current + if (!root || !root.isConnected) return + setDomSelection(root, newSelection) + }) + return before + after + }) + } + }, + [cfg] + ) + + return { onBeforeInput, onKeyDown } +} diff --git a/src/inlay/hooks/use-placeholder-sync.ts b/src/inlay/hooks/use-placeholder-sync.ts new file mode 100644 index 0000000..480c5e2 --- /dev/null +++ b/src/inlay/hooks/use-placeholder-sync.ts @@ -0,0 +1,38 @@ +import { useLayoutEffect } from 'react' + +export function usePlaceholderSync( + editorRef: React.RefObject, + placeholderRef: React.RefObject, + deps: unknown[] +) { + useLayoutEffect(() => { + if (editorRef.current && placeholderRef.current) { + const editorStyles = window.getComputedStyle(editorRef.current) + const stylesToCopy: (keyof CSSStyleDeclaration)[] = [ + 'paddingTop', + 'paddingRight', + 'paddingBottom', + 'paddingLeft', + 'borderTopWidth', + 'borderRightWidth', + 'borderBottomWidth', + 'borderLeftWidth', + 'fontFamily', + 'fontSize', + 'lineHeight', + 'letterSpacing', + 'textAlign' + ] + + stylesToCopy.forEach((styleName) => { + const v = editorStyles[styleName] + if (v !== null) { + // @ts-expect-error - Style name is a valid CSSStyleDeclaration property + placeholderRef.current!.style[styleName] = v as string + } + }) + placeholderRef.current!.style.borderStyle = editorStyles.borderStyle + placeholderRef.current!.style.borderColor = 'transparent' + } + }, deps) +} diff --git a/src/inlay/hooks/use-selection-snap.ts b/src/inlay/hooks/use-selection-snap.ts new file mode 100644 index 0000000..5c5661d --- /dev/null +++ b/src/inlay/hooks/use-selection-snap.ts @@ -0,0 +1,175 @@ +import { useCallback } from 'react' +import { getAbsoluteOffset, setDomSelection } from '../internal/dom-utils' + +export type SelectionSnapConfig = { + editorRef: React.RefObject + setSelection: (sel: { start: number; end: number }) => void + lastAnchorRectRef: React.MutableRefObject + suppressNextSelectionAdjustRef: React.MutableRefObject + lastArrowDirectionRef: React.MutableRefObject< + 'left' | 'right' | 'up' | 'down' | null + > + lastShiftRef: React.MutableRefObject + isComposingRef: React.MutableRefObject +} + +export function useSelectionSnap(cfg: SelectionSnapConfig) { + const onSelect = useCallback(() => { + const root = cfg.editorRef.current + if (!root) return + + if (cfg.suppressNextSelectionAdjustRef.current) { + cfg.suppressNextSelectionAdjustRef.current = false + } + + const domSelection = window.getSelection() + if (!domSelection || !domSelection.rangeCount) return + + const range = domSelection.getRangeAt(0) + const clientRect = + range.getClientRects()[0] || range.getBoundingClientRect() + if (!(clientRect.x === 0 && clientRect.y === 0)) { + cfg.lastAnchorRectRef.current = new DOMRect( + clientRect.x, + clientRect.y, + clientRect.width, + clientRect.height + ) + } + + if (!root.contains(range.startContainer)) return + + const start = getAbsoluteOffset( + root, + range.startContainer, + range.startOffset + ) + const end = getAbsoluteOffset(root, range.endContainer, range.endOffset) + cfg.setSelection({ start, end }) + + if (cfg.isComposingRef.current) { + cfg.lastArrowDirectionRef.current = null + cfg.lastShiftRef.current = false + return + } + + const direction = cfg.lastArrowDirectionRef.current + const isShift = cfg.lastShiftRef.current + if (direction) { + requestAnimationFrame(() => { + const root = cfg.editorRef.current + if (!root) return + const sel = window.getSelection() + if (!sel || sel.rangeCount === 0) return + const rng = sel.getRangeAt(0) + + const getClosestTokenEl = (n: Node | null): HTMLElement | null => { + let curr: Node | null = n + while (curr) { + if (curr.nodeType === Node.ELEMENT_NODE) { + const asEl = curr as HTMLElement + if (asEl.hasAttribute('data-token-text')) return asEl + } + curr = curr.parentNode || null + } + return null + } + + const renderedLen = (el: Element): number => { + const walker = document.createTreeWalker( + el, + NodeFilter.SHOW_TEXT, + null + ) + let total = 0 + let n: Node | null + while ((n = walker.nextNode())) total += (n.textContent || '').length + return total + } + const rawLen = (el: Element): number => + (el.getAttribute('data-token-text') || '').length + + const findFirstTextNode = (el: Element): ChildNode | null => { + const walker = document.createTreeWalker( + el, + NodeFilter.SHOW_TEXT, + null + ) + return walker.nextNode() as ChildNode | null + } + const findLastTextNode = (el: Element): ChildNode | null => { + const walker = document.createTreeWalker( + el, + NodeFilter.SHOW_TEXT, + null + ) + let last: Node | null = null + let n: Node | null + while ((n = walker.nextNode())) last = n + return last as ChildNode | null + } + + const snapEdgeForToken = ( + tokenEl: Element, + prefer: 'start' | 'end' + ): number | null => { + if (!root) return null + if (prefer === 'start') { + const first = findFirstTextNode(tokenEl) + if (first) return getAbsoluteOffset(root, first, 0) + return null + } else { + const last = findLastTextNode(tokenEl) + if (last) { + const len = (last.textContent || '').length + return getAbsoluteOffset(root, last, len) + } + return null + } + } + + const arrowToEdge = (dir: typeof direction): 'start' | 'end' => + dir === 'left' || dir === 'up' ? 'start' : 'end' + + const tokenEl = getClosestTokenEl(rng.startContainer) + if (!tokenEl) { + cfg.lastArrowDirectionRef.current = null + cfg.lastShiftRef.current = false + return + } + + const isDiverged = renderedLen(tokenEl) !== rawLen(tokenEl) + if (!isDiverged) { + cfg.lastArrowDirectionRef.current = null + cfg.lastShiftRef.current = false + return + } + + if (!isShift) { + if (!rng.collapsed) return + const edge = arrowToEdge(direction) + const target = snapEdgeForToken(tokenEl, edge) + if (target == null) return + cfg.suppressNextSelectionAdjustRef.current = true + setDomSelection(root, target) + } else { + const anchorNode = sel.anchorNode + const anchorOffset = sel.anchorOffset + const edge = arrowToEdge(direction) + const focusRaw = snapEdgeForToken(tokenEl, edge) + if (focusRaw == null || !anchorNode) return + const anchorRaw = getAbsoluteOffset(root, anchorNode, anchorOffset) + const startRaw = Math.min(anchorRaw, focusRaw) + const endRaw = Math.max(anchorRaw, focusRaw) + cfg.suppressNextSelectionAdjustRef.current = true + setDomSelection(root, startRaw, endRaw) + } + + cfg.lastArrowDirectionRef.current = null + cfg.lastShiftRef.current = false + }) + } + }, [cfg]) + + return { onSelect } +} diff --git a/src/inlay/hooks/use-selection.ts b/src/inlay/hooks/use-selection.ts new file mode 100644 index 0000000..ff0690b --- /dev/null +++ b/src/inlay/hooks/use-selection.ts @@ -0,0 +1,175 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { getAbsoluteOffset, setDomSelection } from '../internal/dom-utils' +import { snapGraphemeEnd, snapGraphemeStart } from '../internal/string-utils' + +export type SelectionState = { start: number; end: number } + +// Detect iOS Safari - includes modern iPads that report as "MacIntel" with touch +const isIOS = + typeof navigator !== 'undefined' && + (/iPad|iPhone|iPod/.test(navigator.userAgent) || + (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)) + +export function useSelection( + editorRef: React.RefObject, + value: string +) { + const [selection, setSelection] = useState({ + start: 0, + end: 0 + }) + const lastAnchorRectRef = useRef(new DOMRect(0, 0, 0, 0)) + const virtualAnchorRef = useRef({ + getBoundingClientRect: () => lastAnchorRectRef.current + }) + const suppressNextSelectionAdjustRef = useRef(false) + + // Update just the anchor rect from current selection + const updateAnchorRect = useCallback(() => { + const domSelection = window.getSelection() + if (!domSelection || !domSelection.rangeCount) return + + const range = domSelection.getRangeAt(0) + const clientRect = + range.getClientRects()[0] || range.getBoundingClientRect() + if (!(clientRect.x === 0 && clientRect.y === 0)) { + lastAnchorRectRef.current = new DOMRect( + clientRect.x, + clientRect.y, + clientRect.width, + clientRect.height + ) + } + }, []) + + const handleSelectionChange = useCallback(() => { + if (!editorRef.current) return + + // Avoid feedback loop after we programmatically set selection + if (suppressNextSelectionAdjustRef.current) { + suppressNextSelectionAdjustRef.current = false + } + + const domSelection = window.getSelection() + if (!domSelection || !domSelection.rangeCount) return + + const range = domSelection.getRangeAt(0) + + // Update anchor rect + updateAnchorRect() + + if (!editorRef.current.contains(range.startContainer)) { + return + } + + const start = getAbsoluteOffset( + editorRef.current, + range.startContainer, + range.startOffset + ) + const end = getAbsoluteOffset( + editorRef.current, + range.endContainer, + range.endOffset + ) + setSelection({ start, end }) + }, [editorRef, updateAnchorRect]) + + const setSelectionImperative = useCallback( + (start: number, end?: number) => { + const root = editorRef.current + if (!root) return + const s = value + const rawStart = Math.max(0, Math.min(start, s.length)) + const rawEnd = + end != null ? Math.max(0, Math.min(end, s.length)) : rawStart + const a = Math.min(rawStart, rawEnd) + const b = Math.max(rawStart, rawEnd) + const snappedStart = snapGraphemeStart(s, a) + const snappedEnd = end != null ? snapGraphemeEnd(s, b) : snappedStart + suppressNextSelectionAdjustRef.current = true + setDomSelection(root, snappedStart, snappedEnd) + handleSelectionChange() + }, + [editorRef, handleSelectionChange, value] + ) + + // iOS Safari fires selectionchange on document, not the element. + // This listener ensures we capture selection changes on mobile Safari. + useEffect(() => { + const handleDocumentSelectionChange = () => { + const root = editorRef.current + if (!root) return + + // Only process if our editor is focused or contains the selection + const domSelection = window.getSelection() + if (!domSelection || !domSelection.rangeCount) return + + const range = domSelection.getRangeAt(0) + if (!root.contains(range.startContainer)) return + + // Delegate to our existing handler + handleSelectionChange() + } + + document.addEventListener('selectionchange', handleDocumentSelectionChange) + return () => { + document.removeEventListener( + 'selectionchange', + handleDocumentSelectionChange + ) + } + }, [editorRef, handleSelectionChange]) + + // iOS: Update anchor rect after input/viewport changes (caret rect can be stale) + useEffect(() => { + if (!isIOS) return + + let rafId: number | null = null + const deferredUpdate = () => { + if (rafId !== null) cancelAnimationFrame(rafId) + rafId = requestAnimationFrame(() => { + rafId = null + updateAnchorRect() + }) + } + + const handleInput = (e: Event) => { + const root = editorRef.current + if (root?.contains(e.target as Node)) deferredUpdate() + } + + const handleViewportChange = () => { + const root = editorRef.current + if ( + root && + (document.activeElement === root || + root.contains(document.activeElement)) + ) { + deferredUpdate() + } + } + + document.addEventListener('input', handleInput, true) + const vv = window.visualViewport + vv?.addEventListener('resize', handleViewportChange) + vv?.addEventListener('scroll', handleViewportChange) + + return () => { + if (rafId !== null) cancelAnimationFrame(rafId) + document.removeEventListener('input', handleInput, true) + vv?.removeEventListener('resize', handleViewportChange) + vv?.removeEventListener('scroll', handleViewportChange) + } + }, [editorRef, updateAnchorRect]) + + return { + selection, + setSelection, + setSelectionImperative, + handleSelectionChange, + lastAnchorRectRef, + virtualAnchorRef, + suppressNextSelectionAdjustRef + } +} diff --git a/src/inlay/hooks/use-token-weaver.tsx b/src/inlay/hooks/use-token-weaver.tsx new file mode 100644 index 0000000..cb9ad52 --- /dev/null +++ b/src/inlay/hooks/use-token-weaver.tsx @@ -0,0 +1,187 @@ +import React, { + useCallback, + useLayoutEffect, + useMemo, + useRef, + useState +} from 'react' + +export type TokenState = { + isCollapsed: boolean + isAtStartOfToken: boolean + isAtEndOfToken: boolean +} + +export type TokenInfo = { + text: string + node: React.ReactElement + start: number + end: number +} + +export function useTokenWeaver( + value: string, + selection: { start: number; end: number }, + multiline: boolean, + children: React.ReactNode +) { + const [isRegistered, setIsRegistered] = useState(false) + const tokenRegistry = useRef<{ text: string; node: React.ReactElement }[]>([]) + const activeTokenRef = useRef(null) + + if (!isRegistered) tokenRegistry.current = [] + + const registerToken = useCallback( + (token: { text: string; node: React.ReactElement }) => { + tokenRegistry.current.push(token) + }, + [] + ) + + useLayoutEffect(() => { + setIsRegistered(false) + }, [children]) + + useLayoutEffect(() => { + if (!isRegistered) setIsRegistered(true) + }, [isRegistered]) + + const { weavedChildren, activeToken, activeTokenState } = useMemo(() => { + if (!isRegistered) { + return { + weavedChildren: null as React.ReactNode, + activeToken: activeTokenRef.current, + activeTokenState: null as TokenState | null + } + } + + const isStale = tokenRegistry.current.some( + (token) => value.indexOf(token.text) === -1 + ) + if (isStale) { + return { + weavedChildren: null as React.ReactNode, + activeToken: activeTokenRef.current, + activeTokenState: null as TokenState | null + } + } + + // Empty state: render zero-width space to maintain consistent caret height + if (value.length === 0) { + return { + weavedChildren: {'\u200B'}, + activeToken: null, + activeTokenState: null as TokenState | null + } + } + + // Robust sorting to handle duplicate tokens + const sortedTokens: { text: string; node: React.ReactElement }[] = [] + const tokenPool = [...tokenRegistry.current] + let searchIndex = 0 + while (searchIndex < value.length && tokenPool.length > 0) { + let foundToken = false + for (let i = 0; i < tokenPool.length; i++) { + const token = tokenPool[i] + if (value.startsWith(token.text, searchIndex)) { + sortedTokens.push(token) + tokenPool.splice(i, 1) + searchIndex += token.text.length + foundToken = true + break + } + } + if (!foundToken) searchIndex++ + } + + const result: React.ReactNode[] = [] + const map: TokenInfo[] = [] + let currentIndex = 0 + if (sortedTokens.length === 0) { + const nodes: React.ReactNode[] = [{value}] + if (multiline && value.endsWith('\n')) + nodes.push() + const tokenMap = [ + { text: value, node: , start: 0, end: value.length } + ] + const active = + tokenMap.find( + (t) => selection.start >= t.start && selection.end <= t.end + ) || null + return { + weavedChildren: nodes, + activeToken: active, + activeTokenState: null as TokenState | null + } + } + + for (const token of sortedTokens) { + const tokenStartIndex = value.indexOf(token.text, currentIndex) + if (tokenStartIndex === -1) continue + if (tokenStartIndex > currentIndex) { + const spacerText = value.slice(currentIndex, tokenStartIndex) + result.push({spacerText}) + map.push({ + text: spacerText, + node: , + start: currentIndex, + end: tokenStartIndex + }) + } + const tokenWithKey = React.cloneElement(token.node, { + key: `token-${currentIndex}` + }) + result.push(tokenWithKey) + map.push({ + text: token.text, + node: token.node, + start: tokenStartIndex, + end: tokenStartIndex + token.text.length + }) + currentIndex = tokenStartIndex + token.text.length + } + + if (currentIndex < value.length) { + const trailingSpacer = value.slice(currentIndex) + result.push({trailingSpacer}) + map.push({ + text: trailingSpacer, + node: , + start: currentIndex, + end: value.length + }) + } + + if (multiline && value.endsWith('\n')) { + result.push() + } + + const active = + map.find((t) => selection.start >= t.start && selection.end <= t.end) || + null + + let activeTokenState: TokenState | null = null + if (active) { + const isCollapsed = selection.start === selection.end + activeTokenState = { + isCollapsed, + isAtStartOfToken: isCollapsed && selection.start === active.start, + isAtEndOfToken: isCollapsed && selection.end === active.end + } + } + + activeTokenRef.current = active + + return { weavedChildren: result, activeToken: active, activeTokenState } + }, [value, isRegistered, selection, multiline]) + + return { + isRegistered, + setIsRegistered, + registerToken, + weavedChildren, + activeToken, + activeTokenRef, + activeTokenState + } +} diff --git a/src/inlay/hooks/use-touch-selection.ts b/src/inlay/hooks/use-touch-selection.ts new file mode 100644 index 0000000..eb294d8 --- /dev/null +++ b/src/inlay/hooks/use-touch-selection.ts @@ -0,0 +1,144 @@ +import { useCallback, useRef } from 'react' +import { getClosestTokenEl } from '../internal/dom-utils' + +export type TouchSelectionConfig = { + editorRef: React.RefObject + handleSelectionChange: () => void + isComposingRef: React.MutableRefObject +} + +/** + * Handles touch-based selection interactions on mobile devices. + * - Debounces rapid touch events + * - Snaps selection to token boundaries when touching inside tokens + * - Handles long-press detection for native selection mode + */ +export function useTouchSelection(cfg: TouchSelectionConfig) { + const touchStartPosRef = useRef<{ x: number; y: number } | null>(null) + const longPressTimeoutRef = useRef(null) + const isTouchActiveRef = useRef(false) + + const clearLongPressTimeout = useCallback(() => { + if (longPressTimeoutRef.current !== null) { + clearTimeout(longPressTimeoutRef.current) + longPressTimeoutRef.current = null + } + }, []) + + const onTouchStart = useCallback( + (event: React.TouchEvent) => { + if (cfg.isComposingRef.current) return + + const touch = event.touches[0] + if (!touch) return + + touchStartPosRef.current = { x: touch.clientX, y: touch.clientY } + isTouchActiveRef.current = true + + // Long-press detection (500ms) - triggers native selection mode + // We don't prevent this; we just use it to know user wants selection + clearLongPressTimeout() + longPressTimeoutRef.current = window.setTimeout(() => { + // After long press, let native selection handle it + // We'll sync state in touchend or selectionchange + longPressTimeoutRef.current = null + }, 500) + }, + [cfg.isComposingRef, clearLongPressTimeout] + ) + + const onTouchMove = useCallback( + (event: React.TouchEvent) => { + if (!isTouchActiveRef.current) return + + const touch = event.touches[0] + if (!touch || !touchStartPosRef.current) return + + // If user moves finger significantly, cancel long-press + const dx = Math.abs(touch.clientX - touchStartPosRef.current.x) + const dy = Math.abs(touch.clientY - touchStartPosRef.current.y) + if (dx > 10 || dy > 10) { + clearLongPressTimeout() + } + }, + [clearLongPressTimeout] + ) + + const onTouchEnd = useCallback( + (event: React.TouchEvent) => { + clearLongPressTimeout() + isTouchActiveRef.current = false + touchStartPosRef.current = null + + if (cfg.isComposingRef.current) return + + const root = cfg.editorRef.current + if (!root) return + + // Check if touch ended inside a token + const changedTouch = event.changedTouches[0] + if (!changedTouch) return + + const elementAtPoint = document.elementFromPoint( + changedTouch.clientX, + changedTouch.clientY + ) + + if (elementAtPoint && root.contains(elementAtPoint)) { + const tokenEl = getClosestTokenEl(elementAtPoint) + + if (tokenEl) { + // If inside a token, snap selection to token boundary + // Let the native selection happen first, then sync + requestAnimationFrame(() => { + cfg.handleSelectionChange() + }) + } else { + // Normal text area - just sync selection + requestAnimationFrame(() => { + cfg.handleSelectionChange() + }) + } + } + }, + [cfg, clearLongPressTimeout] + ) + + return { + onTouchStart, + onTouchMove, + onTouchEnd + } +} + +/** + * Utility to detect if the current device supports touch + */ +export function isTouchDevice(): boolean { + if (typeof window === 'undefined') return false + return ( + 'ontouchstart' in window || + navigator.maxTouchPoints > 0 || + // @ts-expect-error - msMaxTouchPoints is IE-specific + navigator.msMaxTouchPoints > 0 + ) +} + +/** + * Utility to detect iOS devices + */ +export function isIOS(): boolean { + if (typeof navigator === 'undefined') return false + return ( + /iPad|iPhone|iPod/.test(navigator.userAgent) || + (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1) + ) +} + +/** + * Utility to detect Android devices + */ +export function isAndroid(): boolean { + if (typeof navigator === 'undefined') return false + return /Android/.test(navigator.userAgent) +} diff --git a/src/inlay/hooks/use-virtual-keyboard.ts b/src/inlay/hooks/use-virtual-keyboard.ts new file mode 100644 index 0000000..8c5bb29 --- /dev/null +++ b/src/inlay/hooks/use-virtual-keyboard.ts @@ -0,0 +1,67 @@ +import { useEffect, useRef, useCallback } from 'react' + +export type VirtualKeyboardConfig = { + editorRef: React.RefObject + onVirtualKeyboardChange?: (open: boolean) => void +} + +/** + * Detects virtual keyboard visibility changes using the visualViewport API. + * Scrolls the editor into view when the keyboard opens. + */ +export function useVirtualKeyboard(cfg: VirtualKeyboardConfig) { + const isKeyboardOpenRef = useRef(false) + const initialViewportHeightRef = useRef(null) + + const handleViewportResize = useCallback(() => { + const vv = window.visualViewport + if (!vv) return + + // Store initial viewport height on first call + if (initialViewportHeightRef.current === null) { + initialViewportHeightRef.current = vv.height + } + + // Detect keyboard by comparing current viewport height to initial + // A significant reduction (>25%) typically indicates keyboard is open + const threshold = initialViewportHeightRef.current * 0.75 + const keyboardOpen = vv.height < threshold + + // Only fire callback on state change + if (keyboardOpen !== isKeyboardOpenRef.current) { + isKeyboardOpenRef.current = keyboardOpen + cfg.onVirtualKeyboardChange?.(keyboardOpen) + + // When keyboard opens, scroll editor into view + if (keyboardOpen && cfg.editorRef.current) { + // Use a small delay to let the viewport settle + requestAnimationFrame(() => { + cfg.editorRef.current?.scrollIntoView({ + behavior: 'smooth', + block: 'center' + }) + }) + } + } + }, [cfg]) + + useEffect(() => { + const vv = window.visualViewport + if (!vv) return + + // Initialize with current height + initialViewportHeightRef.current = vv.height + + vv.addEventListener('resize', handleViewportResize) + vv.addEventListener('scroll', handleViewportResize) + + return () => { + vv.removeEventListener('resize', handleViewportResize) + vv.removeEventListener('scroll', handleViewportResize) + } + }, [handleViewportResize]) + + return { + isKeyboardOpen: isKeyboardOpenRef.current + } +} diff --git a/src/inlay/index.ts b/src/inlay/index.ts new file mode 100644 index 0000000..9586135 --- /dev/null +++ b/src/inlay/index.ts @@ -0,0 +1,15 @@ +import { Root, Token, Portal } from './inlay' +import { StructuredInlay } from './structured/structured-inlay' + +// Re-export types that consumers may need +export type { InlayProps, InlayRef, TokenState } from './inlay' +export type { Plugin } from './structured/plugins/plugin' +export type { Matcher, Match } from './internal/string-utils' + +// Compound component export — use as Inlay.Root, Inlay.Token, Inlay.Portal, etc. +export const Inlay = { + Root, + Token, + Portal, + StructuredInlay +} diff --git a/src/inlay/inlay.tsx b/src/inlay/inlay.tsx new file mode 100644 index 0000000..75af325 --- /dev/null +++ b/src/inlay/inlay.tsx @@ -0,0 +1,653 @@ +import type { Scope } from '@radix-ui/react-context' +import { createContextScope } from '@radix-ui/react-context' +import * as Popover from '@radix-ui/react-popover' +import { useControllableState } from '@radix-ui/react-use-controllable-state' +import React, { + createContext, + useCallback, + useContext, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState +} from 'react' +import { + getAbsoluteOffset, + setDomSelection, + serializeRawFromDom as serializeFromDom +} from './internal/dom-utils' +import { useHistory } from './hooks/use-history' +import { useSelection } from './hooks/use-selection' +import { useTokenWeaver } from './hooks/use-token-weaver' +import { useComposition } from './hooks/use-composition' +import { useKeyHandlers } from './hooks/use-key-handlers' +import { usePlaceholderSync } from './hooks/use-placeholder-sync' +import { useSelectionSnap } from './hooks/use-selection-snap' +import { + PortalList, + PortalItem, + type PortalKeyboardHandler +} from './portal-list' +import { useClipboard } from './hooks/use-clipboard' +import { useVirtualKeyboard } from './hooks/use-virtual-keyboard' +import { useTouchSelection } from './hooks/use-touch-selection' + +export const COMPONENT_NAME = 'Inlay' +export const TEXT_COMPONENT_NAME = 'Inlay.Text' + +export type ScopedProps = P & { __scope?: Scope } +const [createInlayContext] = createContextScope(COMPONENT_NAME) + +const AncestorContext = createContext(null) +const PopoverControlContext = createContext<{ + setOpen: (open: boolean) => void +} | null>(null) + +// Context for portal keyboard navigation +type PortalKeyboardContextValue = { + setHandler: (handler: PortalKeyboardHandler | null) => void +} +const PortalKeyboardContext = createContext( + null +) +export { PortalKeyboardContext } + +// Context for anchor rect - allows Portal to position itself based on caret position +type AnchorRectContextValue = { + getRect: () => DOMRect +} +const AnchorRectContext = createContext(null) + +function annotateWithAncestor( + node: React.ReactNode, + currentAncestor: React.ReactElement | null +): React.ReactNode { + if (!React.isValidElement(node)) { + return node + } + + const element = node as React.ReactElement<{ children?: React.ReactNode }> + const nextAncestor = currentAncestor ?? element + + const children = element.props.children + const wrappedChildren = React.Children.map( + children, + (child: React.ReactNode) => annotateWithAncestor(child, nextAncestor) + ) + + return ( + + {React.cloneElement(element, undefined, wrappedChildren)} + + ) +} + +// --- Internal Context for token registration --- +type InternalInlayContextValue = { + registerToken: (token: { text: string; node: React.ReactElement }) => void +} +const [InternalInlayProvider, useInternalInlayContext] = + createInlayContext(COMPONENT_NAME) + +// --- Public Context for consumer state --- +type TokenInfo = { + text: string + node: React.ReactElement + start: number + end: number +} + +export type TokenState = { + isCollapsed: boolean + isAtStartOfToken: boolean + isAtEndOfToken: boolean +} + +type PublicInlayContextValue = { + value: string + selection: { start: number; end: number } + activeToken: TokenInfo | null + activeTokenState: TokenState | null + getSelectionRange: () => Range | null +} + +const [PublicInlayProvider, usePublicInlayContext] = + createInlayContext(COMPONENT_NAME) + +export type InlayProps = ScopedProps< + { + children: React.ReactNode + value?: string + defaultValue?: string + onChange?: (value: string) => void + placeholder?: React.ReactNode + multiline?: boolean + // Mobile input attributes + inputMode?: + | 'text' + | 'search' + | 'email' + | 'tel' + | 'url' + | 'numeric' + | 'decimal' + | 'none' + autoCapitalize?: + | 'off' + | 'none' + | 'on' + | 'sentences' + | 'words' + | 'characters' + autoCorrect?: 'on' | 'off' + enterKeyHint?: + | 'enter' + | 'done' + | 'go' + | 'next' + | 'previous' + | 'search' + | 'send' + onVirtualKeyboardChange?: (open: boolean) => void + } & Omit< + React.HTMLAttributes, + 'onChange' | 'defaultValue' | 'onKeyDown' + > & { + onKeyDown?: (event: React.KeyboardEvent) => boolean // Return true to stop propagation + } & { + getPopoverAnchorRect?: (root: HTMLDivElement | null) => DOMRect + } +> + +export type InlayRef = { + root: HTMLDivElement | null + setSelection: (start: number, end?: number) => void +} + +const Inlay = React.forwardRef((props, forwardedRef) => { + const { + __scope, + children: allChildren, + value: valueProp, + defaultValue, + onChange, + onKeyDown: onKeyDownProp, + placeholder, + multiline = true, + getPopoverAnchorRect, + // Mobile input attributes + inputMode = 'text', + autoCapitalize = 'sentences', + autoCorrect, // Omit by default - let iOS use native behavior (single-word suggestions) + enterKeyHint, + onVirtualKeyboardChange, + ...inlayProps + } = props + + const popoverPortal = React.useMemo( + () => + React.Children.toArray(allChildren).find( + (child) => React.isValidElement(child) && child.type === Portal + ), + [allChildren] + ) + + const children = React.useMemo( + () => + React.Children.toArray(allChildren).filter( + (child) => !React.isValidElement(child) || child.type !== Portal + ), + [allChildren] + ) + + const [isPopoverOpen, setIsPopoverOpen] = useState(false) + + // Portal keyboard handler - allows Portal.List to intercept keyboard events + const portalKeyboardHandlerRef = useRef(null) + const portalKeyboardContext = useMemo( + () => ({ + setHandler: (handler: PortalKeyboardHandler | null) => { + portalKeyboardHandlerRef.current = handler + } + }), + [] + ) + const popoverControl = useMemo(() => ({ setOpen: setIsPopoverOpen }), []) + const [value, setValue] = useControllableState({ + prop: valueProp, + defaultProp: defaultValue || '', + onChange + }) + // Keep a ref to the current value for synchronous access in event handlers + const valueRef = useRef(value) + valueRef.current = value + const editorRef = useRef(null) + const placeholderRef = useRef(null) + const { + selection, + setSelection, + setSelectionImperative, + handleSelectionChange, + lastAnchorRectRef, + suppressNextSelectionAdjustRef + } = useSelection(editorRef, value) + const virtualAnchorRef = useRef({ + getBoundingClientRect: () => lastAnchorRectRef.current + }) + const customAnchorRef = useRef({ + getBoundingClientRect: () => new DOMRect(0, 0, 0, 0) + }) + useLayoutEffect(() => { + if (!getPopoverAnchorRect) return + customAnchorRef.current.getBoundingClientRect = () => { + try { + const root = editorRef.current + const rect = getPopoverAnchorRect(root) + return rect ?? new DOMRect(0, 0, 0, 0) + } catch { + return new DOMRect(0, 0, 0, 0) + } + } + }, [getPopoverAnchorRect]) + const chosenAnchorRef = getPopoverAnchorRect + ? customAnchorRef + : virtualAnchorRef + const { + isRegistered, + registerToken, + weavedChildren, + activeToken, + activeTokenRef, + activeTokenState + } = useTokenWeaver(value, selection, multiline, children) + + const lastArrowDirectionRef = useRef<'left' | 'right' | 'up' | 'down' | null>( + null + ) + const lastShiftRef = useRef(false) + + const getCurrentSnapshot = useCallback(() => { + const root = editorRef.current + if (root) { + const sel = window.getSelection() + if (sel && sel.rangeCount > 0) { + const r = sel.getRangeAt(0) + const start = getAbsoluteOffset(root, r.startContainer, r.startOffset) + const end = getAbsoluteOffset(root, r.endContainer, r.endOffset) + return { value, selection: { start, end } } + } + } + return { value, selection } + }, [value, selection]) + const applySnapshot = useCallback( + (snap: { value: string; selection: { start: number; end: number } }) => { + setValue(() => snap.value) + requestAnimationFrame(() => { + const root = editorRef.current + if (root) { + suppressNextSelectionAdjustRef.current = true + setDomSelection(root, snap.selection.start, snap.selection.end) + } + }) + }, + [setValue] + ) + const { pushUndoSnapshot, beginEditSession, endEditSession, undo, redo } = + useHistory(getCurrentSnapshot, applySnapshot, 200) + + const serializeRawFromDom = useCallback((): string => { + const root = editorRef.current + if (!root) return value + return serializeFromDom(root) + }, [value]) + + const { + isComposing, + isComposingRef, + suppressNextBeforeInputRef, + suppressNextKeydownCommitRef, + compositionCommitKeyRef, + compositionJustEndedAtRef, + contentKey, + onCompositionStart, + onCompositionUpdate, + onCompositionEnd + } = useComposition( + editorRef, + serializeRawFromDom, + handleSelectionChange, + setValue, + () => value + ) + + usePlaceholderSync(editorRef, placeholderRef, [value, placeholder]) + + // weaving moved + const getSelectionRange = useCallback(() => { + const domSelection = window.getSelection() + if (domSelection && domSelection.rangeCount > 0) { + return domSelection.getRangeAt(0) + } + return null + }, []) + const { onBeforeInput, onKeyDown } = useKeyHandlers({ + editorRef, + contentKey, + multiline, + onKeyDownProp, + beginEditSession, + endEditSession, + pushUndoSnapshot, + undo, + redo, + isComposingRef, + compositionCommitKeyRef, + suppressNextBeforeInputRef, + suppressNextKeydownCommitRef, + compositionJustEndedAtRef, + setValue, + valueRef, + getActiveToken: () => + activeTokenRef.current + ? { + start: activeTokenRef.current.start, + end: activeTokenRef.current.end + } + : null + }) + const { onSelect } = useSelectionSnap({ + editorRef, + setSelection, + lastAnchorRectRef, + suppressNextSelectionAdjustRef, + lastArrowDirectionRef, + lastShiftRef, + isComposingRef + }) + const { onCopy, onCut, onPaste } = useClipboard({ + editorRef, + getValue: () => value, + setValue, + pushUndoSnapshot, + isComposingRef + }) + useVirtualKeyboard({ + editorRef, + onVirtualKeyboardChange + }) + const { onTouchStart, onTouchMove, onTouchEnd } = useTouchSelection({ + editorRef, + handleSelectionChange, + isComposingRef + }) + // Wrap onKeyDown to route through portal keyboard handler first + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + // If portal has a keyboard handler registered and it handles the event, stop + if ( + isPopoverOpen && + portalKeyboardHandlerRef.current && + portalKeyboardHandlerRef.current(event) + ) { + return + } + // Otherwise, use the default key handler + onKeyDown(event) + }, + [isPopoverOpen, onKeyDown] + ) + useImperativeHandle(forwardedRef, () => ({ + root: editorRef.current, + setSelection: (start: number, end?: number) => { + if (editorRef.current) { + setSelectionImperative(start, end) + } + } + })) + return ( + + + + + + {/* First pass: renders children to populate registry */} + + {React.Children.map(children, (child) => + annotateWithAncestor(child, null) + )} + + + {/* Second pass: renders weaved content */} + + + {isRegistered ? weavedChildren : children} + + {value.length === 0 && placeholder && !isComposing && ( + + {placeholder} + + )} + + ({ getRect: () => lastAnchorRectRef.current }), + [] + )} + > + + {popoverPortal} + + + + + + + ) +}) + +type PortalRenderProps = (context: PublicInlayContextValue) => React.ReactNode + +type PortalProps = ScopedProps< + Omit & { + children: PortalRenderProps + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: `data-${string}`]: any + } +> + +const Portal = (props: PortalProps) => { + const { + __scope, + children, + side = 'bottom', + sideOffset = 0, + alignOffset = 0, + ...contentProps + } = props + const context = usePublicInlayContext(COMPONENT_NAME, __scope) + const popoverControl = useContext(PopoverControlContext) + const keyboardContext = useContext(PortalKeyboardContext) + const anchorRectContext = useContext(AnchorRectContext) + const contentRef = useRef(null) + + const content = children(context) + const hasContent = !!content + + // Track last content to avoid flashing during timing gaps between render and effects + const lastContentRef = useRef(null) + const closeTimeoutRef = useRef(null) + + if (hasContent) { + lastContentRef.current = content + if (closeTimeoutRef.current !== null) { + clearTimeout(closeTimeoutRef.current) + closeTimeoutRef.current = null + } + } + + useLayoutEffect(() => { + if (hasContent) { + popoverControl?.setOpen(true) + } else { + // Defer close to allow effects to catch up + if (closeTimeoutRef.current !== null) + clearTimeout(closeTimeoutRef.current) + closeTimeoutRef.current = window.setTimeout(() => { + closeTimeoutRef.current = null + popoverControl?.setOpen(false) + lastContentRef.current = null + }, 50) + } + return () => { + if (closeTimeoutRef.current !== null) { + clearTimeout(closeTimeoutRef.current) + closeTimeoutRef.current = null + } + } + }, [hasContent, popoverControl]) + + // Position manually via DOM to follow caret on iOS and avoid re-render loops + useLayoutEffect(() => { + const el = contentRef.current + if (!el || !anchorRectContext) return + + const rect = anchorRectContext.getRect() + if (rect.x === 0 && rect.y === 0 && rect.width === 0 && rect.height === 0) + return + + let top: number, left: number + if (side === 'bottom') { + top = rect.bottom + sideOffset + left = rect.left + alignOffset + } else if (side === 'top') { + top = rect.top - el.offsetHeight - sideOffset + left = rect.left + alignOffset + } else if (side === 'left') { + top = rect.top + alignOffset + left = rect.left - el.offsetWidth - sideOffset + } else { + top = rect.top + alignOffset + left = rect.right + sideOffset + } + + el.style.top = `${top}px` + el.style.left = `${left}px` + el.style.visibility = 'visible' + }) + + const displayContent = content || lastContentRef.current + + if (!displayContent) return null + + return ( + + e.preventDefault()} + {...contentProps} + style={{ + ...contentProps.style, + position: 'fixed', + top: 0, + left: 0, + zIndex: 50, + visibility: 'hidden' // Made visible by useLayoutEffect after positioning + }} + > + + {displayContent} + + + + ) +} +Portal.displayName = 'Inlay.Portal' + +// Attach List and Item to Portal for compound component API +const PortalWithList = Object.assign(Portal, { + List: PortalList, + Item: PortalItem +}) + +type TokenProps = ScopedProps<{ + value: string + children: React.ReactNode +}> & + React.HTMLAttributes + +const Token = React.forwardRef((props, ref) => { + const { __scope, value, children, ...textProps } = props + const internalContext = useInternalInlayContext(TEXT_COMPONENT_NAME, __scope) + const ancestor = useContext(AncestorContext) + + const nodeToRegister = ( + + {children} + + ) + + // Register the token with its text value and the React node itself. + // If an ancestor is found in context, register that instead of the immediate span. + internalContext.registerToken({ + text: value, + node: ancestor || nodeToRegister + }) + + return nodeToRegister +}) + +Token.displayName = TEXT_COMPONENT_NAME + +export { Inlay as Root, Token, PortalWithList as Portal } diff --git a/src/inlay/internal/dom-utils.test.ts b/src/inlay/internal/dom-utils.test.ts new file mode 100644 index 0000000..38dcedc --- /dev/null +++ b/src/inlay/internal/dom-utils.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'vitest' +import { getAbsoluteOffset, getTextNodeAtOffset } from './dom-utils' + +// Helper to build a minimal editor DOM tree +function createEditor(html: string): HTMLElement { + const root = document.createElement('div') + root.innerHTML = html + return root +} + +describe('dom-utils', () => { + it('getAbsoluteOffset on plain text accumulates correctly', () => { + const root = createEditor('hello world') + const firstText = root.querySelector('span')!.firstChild as Text + const secondText = root.querySelectorAll('span')[1].firstChild as Text + + expect(getAbsoluteOffset(root, firstText, 0)).toBe(0) // h| + expect(getAbsoluteOffset(root, firstText, 5)).toBe(5) // hello| + expect(getAbsoluteOffset(root, secondText, 1)).toBe(6) // hello␠| + }) + + it('getTextNodeAtOffset on plain text maps back to nodes/offsets', () => { + const root = createEditor('hello world') + const [n0, o0] = getTextNodeAtOffset(root, 0) + expect((n0 as Text).data.slice(0, o0)).toBe('') + + const [n5, o5] = getTextNodeAtOffset(root, 5) + expect((n5 as Text).data.slice(0, o5)).toBe('hello') + + const [n7, o7] = getTextNodeAtOffset(root, 7) + expect((n7 as Text).data.slice(0, o7)).toBe(' w') + }) + + it('diverged token: absolute offset snaps to token edges when inside token', () => { + const root = createEditor( + 'hi ' + + 'Alex' + + '!' + ) + const token = root.querySelector('[data-token-text]') as HTMLElement + const tokenInner = token.querySelector('span')!.firstChild as Text // "Alex" + + // Inside first char of rendered token should snap to start of raw token (offset at start of token = 3) + expect(getAbsoluteOffset(root, tokenInner, 1)).toBe(3) + + // Inside last char of rendered token should snap to end of raw token (raw len = 5, base start = 3, so end = 8) + expect(getAbsoluteOffset(root, tokenInner, 4)).toBe(8) + }) + + it('round-trip sweep over all offsets in mixed content (snap inside diverged token)', () => { + const root = createEditor( + 'X' + + 'Alex' + + 'Y' + ) + // raw: "X@alexY" -> token starts at 1, rawLen=5, tokenEnd=6, total len=7 + const tokenStart = 1 + const tokenRawLen = 5 + const tokenEnd = tokenStart + tokenRawLen + for (let i = 0; i <= 7; i++) { + const [n, o] = getTextNodeAtOffset(root, i) + const roundTrip = getAbsoluteOffset(root, n as Node, o) + const expected = + i < tokenStart || i > tokenEnd + ? i + : i - tokenStart <= tokenRawLen / 2 + ? tokenStart + : tokenEnd + expect(roundTrip).toBe(expected) + } + }) + + it('fallbacks: offsets past end clamp to last text node end', () => { + const root = createEditor('abc') + const [n, o] = getTextNodeAtOffset(root, 999) + expect((n as Text).data.length).toBe(3) + expect(o).toBe(3) + }) +}) diff --git a/src/inlay/internal/dom-utils.ts b/src/inlay/internal/dom-utils.ts new file mode 100644 index 0000000..8146bde --- /dev/null +++ b/src/inlay/internal/dom-utils.ts @@ -0,0 +1,381 @@ +const isTokenElement = (el: Element): boolean => + el.hasAttribute('data-token-text') + +const getTokenRawLength = (el: Element): number => + (el.getAttribute('data-token-text') || '').length + +const getRenderedTextLength = (el: Element): number => { + const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null) + let total = 0 + let n: Node | null + while ((n = walker.nextNode())) total += (n.textContent || '').length + return total +} + +const findFirstTextNode = (el: Element): ChildNode | null => { + const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null) + return walker.nextNode() as ChildNode | null +} + +const findLastTextNode = (el: Element): ChildNode | null => { + const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null) + let last: Node | null = null + let n: Node | null + while ((n = walker.nextNode())) last = n + return last as ChildNode | null +} + +export function getClosestTokenEl(node: Node | null): HTMLElement | null { + let curr: Node | null = node + while (curr) { + if (curr.nodeType === Node.ELEMENT_NODE) { + const el = curr as HTMLElement + if (el.hasAttribute('data-token-text')) return el + } + curr = curr.parentNode + } + return null +} + +export function getTokenRawRange( + root: HTMLElement, + tokenEl: HTMLElement +): { start: number; end: number } | null { + const rawText = tokenEl.getAttribute('data-token-text') + if (!rawText) return null + + const walker = document.createTreeWalker(tokenEl, NodeFilter.SHOW_TEXT, null) + const firstText = walker.nextNode() + if (!firstText) return null + + const start = getAbsoluteOffset(root, firstText, 0) + return { start, end: start + rawText.length } +} + +export const getTextNodeAtOffset = ( + root: HTMLElement, + offset: number +): [ChildNode | null, number] => { + const traverse = ( + container: Node, + remaining: { value: number } + ): [ChildNode | null, number] | null => { + const children = container.childNodes + for (let i = 0; i < children.length; i++) { + const child = children[i] + + if (child.nodeType === Node.ELEMENT_NODE) { + const el = child as Element + + // represents a newline character - count as 1 + // If we're at the position right after a , return the next text node + if (el.tagName === 'BR') { + if (remaining.value === 0) { + // Position is right at the - find next text node + // Look for the next sibling text node or continue traversal + for (let j = i + 1; j < children.length; j++) { + const next = children[j] + if (next.nodeType === Node.TEXT_NODE) { + return [next as ChildNode, 0] + } + if (next.nodeType === Node.ELEMENT_NODE) { + const nextEl = next as Element + const first = findFirstTextNode(nextEl) + if (first) return [first, 0] + } + } + // No next text node found - return null + return null + } + remaining.value -= 1 + continue + } + + if (isTokenElement(el)) { + const rawLen = getTokenRawLength(el) + const renderedLen = getRenderedTextLength(el) + const isDiverged = renderedLen !== rawLen + + if (isDiverged) { + if (remaining.value <= rawLen) { + const first = findFirstTextNode(el) + const last = findLastTextNode(el) + if (!first && !last) return null + + const snapToStart = remaining.value <= rawLen / 2 + if (snapToStart) { + if (first) return [first, 0] + } else { + if (last) return [last, (last.textContent || '').length] + } + if (first) return [first, 0] + if (last) return [last, (last.textContent || '').length] + return null + } + remaining.value -= rawLen + continue + } + + const found = traverse(el, remaining) + if (found) return found + continue + } + + const found = traverse(el, remaining) + if (found) return found + continue + } + + if (child.nodeType === Node.TEXT_NODE) { + const text = child.textContent || '' + if (remaining.value <= text.length) { + return [child as ChildNode, remaining.value] + } + remaining.value -= text.length + } + } + + return null + } + + const result = traverse(root, { value: Math.max(0, offset) }) + if (result) return result + + // Fallback: place at end of last text node + const allTokenTextNodes = Array.from( + root.querySelectorAll('[data-token-text]') + ) + .map((el) => findLastTextNode(el)) + .filter(Boolean) as ChildNode[] + + if (allTokenTextNodes.length > 0) { + const lastNode = allTokenTextNodes[allTokenTextNodes.length - 1] + return [lastNode, (lastNode.textContent || '').length] + } + + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null) + let last: Node | null = null + let n: Node | null + while ((n = walker.nextNode())) last = n + if (last) return [last as ChildNode, (last.textContent || '').length] + + return [null, 0] +} + +export const getAbsoluteOffset = ( + root: HTMLElement, + node: Node, + offset: number +): number => { + const getOffsetWithinElement = ( + el: Element, + target: Node, + targetOffset: number + ): number => { + const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null) + let total = 0 + let n: Node | null + while ((n = walker.nextNode())) { + if (n === target) { + total += Math.min(targetOffset, (n.textContent || '').length) + break + } + total += (n.textContent || '').length + } + return total + } + + // Handle element node containers (e.g. Firefox Ctrl+a sets selection on element, not text node) + if (node.nodeType === Node.ELEMENT_NODE) { + const el = node as Element + const children = el.childNodes + + let total = 0 + const measure = (n: Node): number => { + if (n.nodeType === Node.TEXT_NODE) { + return (n.textContent || '').length + } + if (n.nodeType === Node.ELEMENT_NODE) { + const e = n as Element + // represents a newline character + if (e.tagName === 'BR') return 1 + if (isTokenElement(e)) return getTokenRawLength(e) + let sum = 0 + for (let i = 0; i < e.childNodes.length; i++) { + sum += measure(e.childNodes[i]) + } + return sum + } + return 0 + } + + for (let i = 0; i < offset && i < children.length; i++) { + total += measure(children[i]) + } + return total + } + + const traverse = (container: Node, acc: { value: number }): number | null => { + const children = container.childNodes + for (let i = 0; i < children.length; i++) { + const child = children[i] + + if (child.nodeType === Node.ELEMENT_NODE) { + const el = child as Element + + if (isTokenElement(el)) { + const rawLen = getTokenRawLength(el) + const renderedLen = getRenderedTextLength(el) + const isDiverged = renderedLen !== rawLen + + if (isDiverged) { + if (el.contains(node)) { + const withinRendered = getOffsetWithinElement(el, node, offset) + const snapToStart = withinRendered <= renderedLen / 2 + return acc.value + (snapToStart ? 0 : rawLen) + } + acc.value += rawLen + continue + } + + if (el.contains(node)) { + const inner = traverse(el, acc) + if (inner != null) return inner + } else { + acc.value += renderedLen + } + continue + } + + // represents a newline character - count as 1 and continue + if (el.tagName === 'BR') { + acc.value += 1 + continue + } + + if (el.contains(node)) { + const inner = traverse(el, acc) + if (inner != null) return inner + } else { + const measureSubtree = (e: Element): number => { + // represents a newline character + if (e.tagName === 'BR') return 1 + let total = 0 + const cn = e.childNodes + for (let j = 0; j < cn.length; j++) { + const c = cn[j] + if (c.nodeType === Node.TEXT_NODE) { + total += (c.textContent || '').length + } else if (c.nodeType === Node.ELEMENT_NODE) { + const ce = c as Element + if (isTokenElement(ce)) { + const rl = getTokenRawLength(ce) + const rr = getRenderedTextLength(ce) + total += rr === rl ? rr : rl + } else { + total += measureSubtree(ce) + } + } + } + return total + } + acc.value += measureSubtree(el) + } + continue + } + + if (child.nodeType === Node.TEXT_NODE) { + if (child === node) { + return acc.value + Math.min(offset, (child.textContent || '').length) + } else { + acc.value += (child.textContent || '').length + } + } + } + return null + } + + const result = traverse(root, { value: 0 }) + if (result != null) return result + + // Fallback: total length + let total = 0 + const stack: Node[] = [root] + while (stack.length) { + const n = stack.pop()! + if (n.nodeType === Node.ELEMENT_NODE) { + const el = n as Element + if (isTokenElement(el)) { + const rl = getTokenRawLength(el) + const rr = getRenderedTextLength(el) + total += rr === rl ? rr : rl + continue + } + const cn = el.childNodes + for (let i = cn.length - 1; i >= 0; i--) stack.push(cn[i]) + } else if (n.nodeType === Node.TEXT_NODE) { + total += (n.textContent || '').length + } + } + return total +} + +export const setDomSelection = ( + root: HTMLElement, + start: number, + end?: number +) => { + const [startNode, startOffset] = getTextNodeAtOffset(root, start) + const [endNode, endOffset] = end + ? getTextNodeAtOffset(root, end) + : [startNode, startOffset] + + if (startNode && endNode) { + // Guard against NotFoundError on iOS: nodes may have been removed + // by React re-renders between scheduling and execution + if (!startNode.isConnected || !endNode.isConnected) { + return + } + + const range = document.createRange() + range.setStart(startNode, startOffset) + range.setEnd(endNode, endOffset) + const selection = window.getSelection() + if (selection) { + selection.removeAllRanges() + selection.addRange(range) + } + } +} + +export const serializeRawFromDom = (root: HTMLElement): string => { + const clone = root.cloneNode(true) as HTMLElement + const tokenEls = clone.querySelectorAll('[data-token-text]') + tokenEls.forEach((el) => { + const raw = el.getAttribute('data-token-text') || '' + const renderedLen = getRenderedTextLength(el) + if (renderedLen !== raw.length) { + ;(el as HTMLElement).textContent = raw + } + }) + let result = (clone as HTMLElement).innerText + // iOS contentEditable often has trailing newlines, zero-width spaces, or other + // invisible characters when "empty". Strip these from the end. + // Also handle the case where the entire content is just whitespace/invisible chars. + result = result.replace(/[\u200B\uFEFF]+/g, '') // Remove zero-width spaces throughout + + // Handle empty content (just newlines or whitespace) + if (result === '\n' || result.trim() === '') { + return '' + } + + // ContentEditable often adds ONE extra trailing newline. Remove it if present. + // But preserve intentional newlines in the content (e.g., "hello\nworld\n" → "hello\nworld") + // This is tricky: we can't know if the final newline is intentional or added by browser. + // Heuristic: if it ends with double newline, remove one. Single trailing newline stays. + if (result.endsWith('\n\n')) { + result = result.slice(0, -1) + } + + return result +} diff --git a/src/inlay/internal/string-utils.test.ts b/src/inlay/internal/string-utils.test.ts new file mode 100644 index 0000000..b323ae4 --- /dev/null +++ b/src/inlay/internal/string-utils.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect } from 'vitest' +import { + createRegexMatcher, + filterMatchesByMatcher, + groupMatchesByMatcher, + scan +} from './string-utils' + +describe('string-utils', () => { + it('createRegexMatcher + scan: finds and sorts matches with names', () => { + const mention = createRegexMatcher<{ mention: string }, 'mention'>( + 'mention', + { + regex: /@\w+/g, + transform: (m) => ({ mention: m[0] }) + } + ) + + const text = 'hi @alex and @bob' + const matches = scan(text, [mention]) + + expect(matches.map((m) => m.raw)).toEqual(['@alex', '@bob']) + expect(matches.map((m) => m.start)).toEqual([3, 13]) + expect(matches.every((m) => m.matcher === 'mention')).toBe(true) + }) + + it('createRegexMatcher resets lastIndex between scans', () => { + const word = createRegexMatcher<{ w: string }, 'word'>('word', { + regex: /\w+/g, + transform: (m) => ({ w: m[0] }) + }) + + const a = scan('alpha beta', [word]) + const b = scan('gamma', [word]) + + expect(a.map((m) => m.raw)).toEqual(['alpha', 'beta']) + expect(b.map((m) => m.raw)).toEqual(['gamma']) + }) + + it('filterMatchesByMatcher returns only requested matcher type', () => { + const mention = createRegexMatcher<{ mention: string }, 'mention'>( + 'mention', + { regex: /@\w+/g, transform: (m) => ({ mention: m[0] }) } + ) + const hash = createRegexMatcher<{ tag: string }, 'hashtag'>('hashtag', { + regex: /#\w+/g, + transform: (m) => ({ tag: m[0] }) + }) + + const matches = scan('say @alex and #music', [mention, hash]) + + const onlyMentions = filterMatchesByMatcher(matches, 'mention') + expect(onlyMentions.map((m) => m.raw)).toEqual(['@alex']) + }) + + it('groupMatchesByMatcher groups by matcher name', () => { + const m1 = createRegexMatcher<{ v: string }, 'a'>('a', { + regex: /a/g, + transform: (m) => ({ v: m[0] }) + }) + const m2 = createRegexMatcher<{ v: string }, 'b'>('b', { + regex: /b/g, + transform: (m) => ({ v: m[0] }) + }) + + const matches = scan('ababa', [m1, m2]) + const grouped = groupMatchesByMatcher(matches) + + expect(grouped.a?.map((x) => x.raw)).toEqual(['a', 'a', 'a']) + expect(grouped.b?.map((x) => x.raw)).toEqual(['b', 'b']) + }) + + it('createRegexMatcher throws when regex lacks global flag', () => { + expect(() => + // @ts-expect-error intentional invalid regex config for test + createRegexMatcher('bad', { regex: /@\w+/, transform: (m) => m[0] }) + ).toThrow() + }) + + it('createRegexMatcher throws when regex uses sticky flag', () => { + expect(() => + // @ts-expect-error intentional invalid regex config for test + createRegexMatcher('bad', { regex: /@\w+/gy, transform: (m) => m[0] }) + ).toThrow() + }) + + it('scan filters overlapping matches - longer match wins', () => { + const atWord = createRegexMatcher<{ v: string }, 'at'>('at', { + regex: /@\w+/g, + transform: (m) => ({ v: m[0] }) + }) + const word = createRegexMatcher<{ v: string }, 'word'>('word', { + regex: /\w+/g, + transform: (m) => ({ v: m[0] }) + }) + + // @alex (5 chars starting at 0) vs alex (4 chars starting at 1) + // They overlap, and @alex is longer, so only @alex should be returned + const matches = scan('@alex', [atWord, word]) + expect(matches.map((m) => `${m.matcher}:${m.raw}`)).toEqual(['at:@alex']) + }) + + it('scan: longest match wins when two matchers start at same position', () => { + const short = createRegexMatcher<{ v: string }, 'short'>('short', { + regex: /@alice/g, + transform: (m) => ({ v: m[0] }) + }) + const long = createRegexMatcher<{ v: string }, 'long'>('long', { + regex: /@alice_vip/g, + transform: (m) => ({ v: m[0] }) + }) + + // Both match starting at position 0, but long is 10 chars vs short's 6 + const matches = scan('@alice_vip', [short, long]) + expect(matches.map((m) => `${m.matcher}:${m.raw}`)).toEqual([ + 'long:@alice_vip' + ]) + }) + + it('scan: first matcher wins when matches are exact same range', () => { + const pluginA = createRegexMatcher<{ v: string }, 'a'>('a', { + regex: /@test/g, + transform: (m) => ({ v: m[0] }) + }) + const pluginB = createRegexMatcher<{ v: string }, 'b'>('b', { + regex: /@test/g, + transform: (m) => ({ v: m[0] }) + }) + + // Both match @test at exact same range - first matcher (a) should win + const matches = scan('@test', [pluginA, pluginB]) + expect(matches.map((m) => `${m.matcher}:${m.raw}`)).toEqual(['a:@test']) + }) + + it('scan preserves non-overlapping matches from multiple matchers', () => { + const mention = createRegexMatcher<{ v: string }, 'mention'>('mention', { + regex: /@\w+/g, + transform: (m) => ({ v: m[0] }) + }) + const hashtag = createRegexMatcher<{ v: string }, 'hashtag'>('hashtag', { + regex: /#\w+/g, + transform: (m) => ({ v: m[0] }) + }) + + // @bob and #music don't overlap, both should be kept + const matches = scan('@bob loves #music', [mention, hashtag]) + expect(matches.map((m) => `${m.matcher}:${m.raw}`)).toEqual([ + 'mention:@bob', + 'hashtag:#music' + ]) + }) +}) diff --git a/src/inlay/internal/string-utils.ts b/src/inlay/internal/string-utils.ts new file mode 100644 index 0000000..b5de702 --- /dev/null +++ b/src/inlay/internal/string-utils.ts @@ -0,0 +1,248 @@ +/** + * Represents a single matched token within a larger string. + * @template T The type of the structured data associated with the match. + * @template N The literal string type of the matcher's name. + */ +export type Match = { + /** The raw matched text span from the original string. */ + raw: string + /** The start index of the match in the full string. */ + start: number + /** The end index of the match in the full string. */ + end: number + /** A structured object with parsed values defined by the matcher. */ + data: T + /** The unique `name` of the matcher that produced this match. */ + matcher: N +} + +/** + * A utility type that constructs a `Match` type from a `Matcher` type. + * It infers the data type `T` and the literal name `N` from the matcher. + * This is key to creating a discriminated union for the `scan` function's return type. + */ +export type MatchFromMatcher = + M extends Matcher ? Match : never + +type MatchData = Omit, 'matcher'> + +/** + * Defines a configuration for finding a specific kind of token. + * It's a generic interface that can be implemented using various strategies (e.g., regex, prefix, custom logic). + * @template T The type of the structured data this matcher will produce. + * @template N The literal string type of the matcher's name. + */ +export type Matcher = { + /** A unique name for the matcher, used to identify which matcher found a token. */ + name: N + /** + * The core matching logic. It scans the input text and returns all found tokens. + * @param text The full string to scan. + * @returns An array of match data, without the `matcher` property. + */ + match: (text: string) => MatchData[] +} + +/** + * Scans a string for tokens using a set of configured matchers. + * + * This function is the core of the token matching utility. It orchestrates the matching process + * by running all provided matchers over the input text and consolidating the results. + * Each matcher can produce a different type of structured data, and the return type will + * be a discriminated union of all possible `Match` types, inferred from the provided matchers. + * + * @param text The full string to scan. + * @param matchers An array of `Matcher` objects to run on the string. The use of `` ensures + * that the literal types of matcher names are preserved for type inference. + * @returns An array of all `Match` objects found, sorted by their start index. + * The type of each element is a discriminated union of all possible `Match` types. + * Consumers can switch on `match.matcher` to safely access `match.data`. + */ +export function scan[]>( + text: string, + matchers: M +): MatchFromMatcher[] { + const allMatches = matchers.flatMap((matcher) => + matcher.match(text).map((m) => ({ ...m, matcher: matcher.name })) + ) as MatchFromMatcher[] + + // Sort by start position ascending, then by length descending (longest first at same position) + // This ensures the longest match at each position is considered first + allMatches.sort((a, b) => { + if (a.start !== b.start) return a.start - b.start + return b.end - b.start - (a.end - a.start) + }) + + // Greedy algorithm: accept non-overlapping matches, preferring longer ones + // When two matches overlap, the one starting earlier (or longer at same position) wins + const accepted: typeof allMatches = [] + let lastEnd = -1 + for (const match of allMatches) { + if (match.start >= lastEnd) { + accepted.push(match) + lastEnd = match.end + } + } + + return accepted +} + +/** + * Options for creating a regex-based matcher. + * @template T The type of the structured data the matcher will produce. + */ +type RegexMatcherOptions = { + /** The regular expression to match against. Must include the 'g' flag for global matching. */ + regex: RegExp + /** A function to transform the raw regex match array into the desired structured data. */ + transform: (match: RegExpExecArray) => T +} + +/** + * A factory function to create a `Matcher` that uses a regular expression. + * This simplifies the creation of matchers for patterns that can be described with regex. + * + * @template T The type of the structured data the created matcher will produce. + * @param name The unique name for this matcher. + * @param options The configuration for the regex matcher. + * @returns A new `Matcher` object. + */ +export function createRegexMatcher( + name: N, + options: RegexMatcherOptions +): Matcher { + if (!options.regex.global) { + throw new Error('createRegexMatcher requires a global regex (e.g. /.../g)') + } + if (options.regex.sticky) { + throw new Error( + `createRegexMatcher does not support the sticky 'y' flag, as it interferes with scanning.` + ) + } + + return { + name, + match: (text: string): MatchData[] => { + const matches: MatchData[] = [] + let regexMatch: RegExpExecArray | null + + // Important: Reset lastIndex on the regex before each new scan. + options.regex.lastIndex = 0 + + while ((regexMatch = options.regex.exec(text)) !== null) { + // This check is necessary to avoid infinite loops with zero-width matches. + if (regexMatch.index === options.regex.lastIndex) { + options.regex.lastIndex++ + } + + matches.push({ + raw: regexMatch[0], + start: regexMatch.index, + end: regexMatch.index + regexMatch[0].length, + data: options.transform(regexMatch) + }) + } + return matches + } + } +} + +/** + * Filters an array of matches to only include those from a specific matcher. + * This is a type-safe alternative to `matches.filter(m => m.matcher === '...')`. + * + * @param matches The array of `Match` objects to filter. + * @param matcherName The name of the matcher to filter by. + * @returns A new array containing only the matches from the specified matcher, + * with the `data` property correctly typed. + */ +export function filterMatchesByMatcher< + M extends Match, + N extends M['matcher'] +>(matches: readonly M[], matcherName: N): Extract[] { + return matches.filter( + (match): match is Extract => + match.matcher === matcherName + ) +} + +/** + * Groups an array of matches by their matcher name. + * + * @param matches An array of `Match` objects, typically from `scan`. + * @returns A record where keys are matcher names and values are arrays of matches + * from that matcher, with each array correctly typed. + */ +export function groupMatchesByMatcher>( + matches: readonly M[] +): Partial<{ [N in M['matcher']]: Extract[] }> { + // We use a less specific type for `grouped` internally to work around a TypeScript + // limitation where it cannot correlate the matcher name (key) with the match + // object's type (value) within the loop. The final cast is safe because the + // logic guarantees the structure of the returned object. + const grouped: Record = {} + + for (const match of matches) { + const key = match.matcher + if (!grouped[key]) { + grouped[key] = [] + } + grouped[key].push(match) + } + + return grouped as unknown as Partial<{ + [N in M['matcher']]: Extract[] + }> +} + +// --- Grapheme segmentation helpers --- +// These utilities provide consistent grapheme cluster boundaries across engines. +// They are intentionally here since they operate purely on strings. +import Graphemer from 'graphemer' + +const graphemeSplitter = new Graphemer() + +export function prevGraphemeStart(text: string, index: number): number { + if (index <= 0) return 0 + let pos = 0 + for (const cluster of graphemeSplitter.iterateGraphemes(text)) { + const next = pos + cluster.length + if (next >= index) return pos + pos = next + } + return pos +} + +export function nextGraphemeEnd(text: string, index: number): number { + if (index >= text.length) return text.length + let pos = 0 + for (const cluster of graphemeSplitter.iterateGraphemes(text)) { + const next = pos + cluster.length + if (index < next) return next + pos = next + } + return text.length +} + +export function snapGraphemeStart(text: string, index: number): number { + if (index <= 0) return 0 + let pos = 0 + for (const cluster of graphemeSplitter.iterateGraphemes(text)) { + const next = pos + cluster.length + if (index < next) return pos + pos = next + } + return pos +} + +export function snapGraphemeEnd(text: string, index: number): number { + if (index >= text.length) return text.length + let pos = 0 + for (const cluster of graphemeSplitter.iterateGraphemes(text)) { + const next = pos + cluster.length + if (index <= pos) return pos + if (index <= next) return next + pos = next + } + return pos +} diff --git a/src/inlay/portal-list.tsx b/src/inlay/portal-list.tsx new file mode 100644 index 0000000..b30d3db --- /dev/null +++ b/src/inlay/portal-list.tsx @@ -0,0 +1,348 @@ +import React, { + createContext, + useCallback, + useContext, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState +} from 'react' + +// --- Portal List Navigation Context --- + +type PortalListContextValue = { + activeIndex: number + setActiveIndex: (index: number) => void + registerItem: (value: T, disabled?: boolean) => number + unregisterItem: (index: number) => void + selectItem: (index: number) => void + loop: boolean +} + +const PortalListContext = createContext(null) + +function usePortalListContext() { + const context = useContext(PortalListContext) + if (!context) { + throw new Error('Portal.Item must be used within Portal.List') + } + return context +} + +// --- Keyboard Handler Context --- +// This context allows the parent Inlay.Root to intercept keyboard events + +export type PortalKeyboardHandler = ( + event: React.KeyboardEvent +) => boolean + +// Import the context from inlay.tsx to avoid circular dependency issues +// The context is created in inlay.tsx and exported +import { PortalKeyboardContext } from './inlay' + +export function usePortalKeyboardContext() { + return useContext(PortalKeyboardContext) +} + +// --- Portal.List Component --- + +export type PortalListProps = { + children: React.ReactNode + onSelect: (item: T, index: number) => void + onDismiss?: () => void + loop?: boolean +} & Omit, 'onSelect'> + +function PortalListInner( + props: PortalListProps, + ref: React.ForwardedRef +) { + const { children, onSelect, onDismiss, loop = true, ...divProps } = props + + const [activeIndex, setActiveIndex] = useState(0) + const itemsRef = useRef>([]) + const [, forceUpdate] = useState(0) + const keyboardContext = usePortalKeyboardContext() + + const registerItem = useCallback((value: T, disabled?: boolean) => { + const index = itemsRef.current.length + itemsRef.current.push({ value, disabled }) + forceUpdate((n) => n + 1) + return index + }, []) + + const unregisterItem = useCallback((index: number) => { + itemsRef.current.splice(index, 1) + forceUpdate((n) => n + 1) + }, []) + + const selectItem = useCallback( + (index: number) => { + const item = itemsRef.current[index] + if (item && !item.disabled) { + onSelect(item.value, index) + } + }, + [onSelect] + ) + + const navigateUp = useCallback(() => { + const items = itemsRef.current + if (items.length === 0) return + + setActiveIndex((current) => { + let nextIndex = current - 1 + if (nextIndex < 0) { + nextIndex = loop ? items.length - 1 : 0 + } + + // Skip disabled items + let attempts = 0 + while (items[nextIndex]?.disabled && attempts < items.length) { + nextIndex = nextIndex - 1 + if (nextIndex < 0) { + nextIndex = loop ? items.length - 1 : 0 + } + attempts++ + } + + return nextIndex + }) + }, [loop]) + + const navigateDown = useCallback(() => { + const items = itemsRef.current + if (items.length === 0) return + + setActiveIndex((current) => { + let nextIndex = current + 1 + if (nextIndex >= items.length) { + nextIndex = loop ? 0 : items.length - 1 + } + + // Skip disabled items + let attempts = 0 + while (items[nextIndex]?.disabled && attempts < items.length) { + nextIndex = nextIndex + 1 + if (nextIndex >= items.length) { + nextIndex = loop ? 0 : items.length - 1 + } + attempts++ + } + + return nextIndex + }) + }, [loop]) + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent): boolean => { + const items = itemsRef.current + + switch (event.key) { + case 'ArrowUp': + event.preventDefault() + navigateUp() + return true + case 'ArrowDown': + event.preventDefault() + navigateDown() + return true + case 'Enter': { + const item = items[activeIndex] + if (item && !item.disabled) { + event.preventDefault() + onSelect(item.value, activeIndex) + return true + } + return false + } + case 'Escape': + event.preventDefault() + onDismiss?.() + return true + default: + return false + } + }, + [activeIndex, navigateUp, navigateDown, onSelect, onDismiss] + ) + + // Register keyboard handler with parent context + useLayoutEffect(() => { + keyboardContext?.setHandler(handleKeyDown) + return () => { + keyboardContext?.setHandler(null) + } + }, [keyboardContext, handleKeyDown]) + + // Reset active index when items change + useEffect(() => { + const items = itemsRef.current + if (activeIndex >= items.length && items.length > 0) { + setActiveIndex(items.length - 1) + } + }, [activeIndex]) + + const contextValue = useMemo( + (): PortalListContextValue => ({ + activeIndex, + setActiveIndex, + registerItem, + unregisterItem, + selectItem, + loop + }), + [activeIndex, registerItem, unregisterItem, selectItem, loop] + ) + + return ( + + { + // Prevent focus loss from editor + e.preventDefault() + divProps.onMouseDown?.(e) + }} + onTouchStart={(e) => { + // Prevent focus loss from editor on touch devices + e.preventDefault() + divProps.onTouchStart?.(e) + }} + > + {children} + + + ) +} + +export const PortalList = React.forwardRef(PortalListInner) as ( + props: PortalListProps & { ref?: React.ForwardedRef } +) => React.ReactElement + +// --- Portal.Item Component --- + +export type PortalItemProps = { + value: T + disabled?: boolean + children: React.ReactNode +} & Omit, 'value'> + +function PortalItemInner( + props: PortalItemProps, + ref: React.ForwardedRef +) { + const { value, disabled = false, children, ...divProps } = props + const context = usePortalListContext() + const [index, setIndex] = useState(-1) + const touchStartPosRef = useRef<{ x: number; y: number } | null>(null) + + // Register this item on mount + useLayoutEffect(() => { + const idx = context.registerItem(value, disabled) + setIndex(idx) + return () => { + context.unregisterItem(idx) + } + }, []) + + const isActive = context.activeIndex === index + + const handleClick = useCallback( + (e: React.MouseEvent) => { + if (!disabled && index >= 0) { + context.selectItem(index) + } + divProps.onClick?.(e) + }, + [disabled, context, divProps, index] + ) + + const handleMouseEnter = useCallback( + (e: React.MouseEvent) => { + if (!disabled && index >= 0) { + context.setActiveIndex(index) + } + divProps.onMouseEnter?.(e) + }, + [disabled, context, divProps, index] + ) + + // Touch handlers for mobile - activate on touch, select on release + const handleTouchStart = useCallback( + (e: React.TouchEvent) => { + const touch = e.touches[0] + if (touch) { + touchStartPosRef.current = { x: touch.clientX, y: touch.clientY } + } + // Activate item on touch start (like hover on desktop) + if (!disabled && index >= 0) { + context.setActiveIndex(index) + } + divProps.onTouchStart?.(e) + }, + [disabled, context, divProps, index] + ) + + const handleTouchEnd = useCallback( + (e: React.TouchEvent) => { + const touch = e.changedTouches[0] + const startPos = touchStartPosRef.current + + // Only select if touch didn't move significantly (not a scroll gesture) + if (touch && startPos) { + const dx = Math.abs(touch.clientX - startPos.x) + const dy = Math.abs(touch.clientY - startPos.y) + + // If movement was minimal, treat as a tap and select + if (dx < 10 && dy < 10) { + if (!disabled && index >= 0) { + // Prevent the subsequent click event + e.preventDefault() + context.selectItem(index) + } + } + } + + touchStartPosRef.current = null + divProps.onTouchEnd?.(e) + }, + [disabled, context, divProps, index] + ) + + return ( + + {children} + + ) +} + +export const PortalItem = React.forwardRef(PortalItemInner) as ( + props: PortalItemProps
{command}
+ + $ {command} +
+ $ {command} +
{content}
- Headless components nobody asked for + const infoSection = ( +
+ {tagline}
+ {description} +
Wrote these so I could ship weird stuff faster. You can too.
+ {comp.tagline} +
- A flexible time range picker with built-in intelligence -
+ {comp.description} +
- Parse natural language expressions using{' '} - - chrono-node - {' '} - for intuitive input. -
+ {comp.documentation} +
- Edit day, month, year, hour, and minute segments - with intuitive keyboard shortcuts. -
- Description of the future component goes here -
- More components coming soon -
+ {children} +
+ Install +
+ + github.com/bizarre/ui + +
+ {subtitle} +
+ {text} +
{r.name}
+ {r.type} +
+ Focused building blocks for edge‑case UX. +
+ Two modules, designed for speed and clarity. +
{JSON.stringify(dateRange, null, 2)}
= P & { __scope?: Scope } -const [createTimeSliceContext] = createContextScope(COMPONENT_NAME) +const [createChronoContext] = createContextScope(COMPONENT_NAME) -type TimeSliceContextValue = { +type ChronoContextValue = { timeZone: TimeZone inputRef: React.RefObject open: boolean @@ -42,10 +42,10 @@ type TimeSliceContextValue = { portalId?: string } -const [TimeSliceProvider, useTimeSliceContext] = - createTimeSliceContext(COMPONENT_NAME) +const [ChronoProvider, useChronoContext] = + createChronoContext(COMPONENT_NAME) -type TimeSliceProps = ScopedProps<{ +type ChronoProps = ScopedProps<{ children: React.ReactNode timeZone?: TimeZone open?: boolean @@ -65,7 +65,7 @@ type TimeSliceProps = ScopedProps<{ onDateRangeConfirm?: (range: DateRange) => void }> -const TimeSlice: React.FC = ({ +const Chrono: React.FC = ({ children, __scope, formatInput: formatInputProp, @@ -80,7 +80,7 @@ const TimeSlice: React.FC = ({ setOpen, dateRange, setDateRange: setDateRangeInternal - } = useTimeSliceState(stateProps) + } = useChronoState(stateProps) const calculateIsRelative = useCallback((range: DateRange): boolean => { let shouldBeRelative = false @@ -213,7 +213,7 @@ const TimeSlice: React.FC = ({ return ( - = ({ portalId={portalId} > {children} - + ) } -type TimeSliceTriggerProps = ScopedProps<{ +type ChronoTriggerProps = ScopedProps<{ children: React.ReactNode asChild?: boolean }> & Omit, 'onClick'> -const TimeSliceTrigger = React.forwardRef< - HTMLDivElement, - TimeSliceTriggerProps ->(({ asChild, children, __scope, ...props }, forwardedRef) => { - const { inputRef } = useTimeSliceContext(COMPONENT_NAME, __scope) +const ChronoTrigger = React.forwardRef( + ({ asChild, children, __scope, ...props }, forwardedRef) => { + const { inputRef } = useChronoContext(COMPONENT_NAME, __scope) - const onClick = useCallback(() => { - if (inputRef.current) { - inputRef.current.focus() - } - }, [inputRef]) + const onClick = useCallback(() => { + if (inputRef.current) { + inputRef.current.focus() + } + }, [inputRef]) - const Comp = asChild ? Slot : 'div' + const Comp = asChild ? Slot : 'div' - return ( - - {children} - - ) -}) + return ( + + {children} + + ) + } +) -type TimeSliceInputProps = ScopedProps<{ +type ChronoInputProps = ScopedProps<{ children?: React.ReactNode asChild?: boolean }> & @@ -271,9 +270,9 @@ type TimeSliceInputProps = ScopedProps<{ 'onChange' | 'onKeyDown' | 'onFocus' | 'onClick' > -const TimeSliceInput = React.forwardRef( +const ChronoInput = React.forwardRef( ({ asChild, children, __scope, ...props }, forwardedRef) => { - const context = useTimeSliceContext(COMPONENT_NAME, __scope) + const context = useChronoContext(COMPONENT_NAME, __scope) const internalInputRef = React.useRef(null) const composedInputRef = composeRefs( forwardedRef, @@ -394,16 +393,16 @@ const TimeSliceInput = React.forwardRef( } ) -type TimeSlicePortalProps = ScopedProps<{ +type ChronoPortalProps = ScopedProps<{ children: React.ReactNode asChild?: boolean ariaLabel?: string }> & React.HTMLAttributes -const TimeSlicePortal = React.forwardRef( +const ChronoPortal = React.forwardRef( ({ asChild, children, __scope, ariaLabel, ...props }, forwardedRef) => { - const context = useTimeSliceContext(COMPONENT_NAME, __scope) + const context = useChronoContext(COMPONENT_NAME, __scope) const handleKeyDownInPortal = useCallback( (e: React.KeyboardEvent) => { @@ -488,7 +487,7 @@ const TimeSlicePortal = React.forwardRef( } ) -type TimeSliceShortcutProps = ScopedProps<{ +type ChronoShortcutProps = ScopedProps<{ children: React.ReactNode duration: Partial<{ years: number @@ -502,73 +501,72 @@ type TimeSliceShortcutProps = ScopedProps<{ }> & Omit, 'onClick'> -const TimeSliceShortcut = React.forwardRef< - HTMLDivElement, - TimeSliceShortcutProps ->(({ asChild, children, __scope, duration, ...props }, forwardedRef) => { - const { setDateRange, setInternalIsRelative, setOpen, inputRef } = - useTimeSliceContext(COMPONENT_NAME, __scope) - - const handleClick = useCallback( - (e: React.MouseEvent) => { - e.preventDefault() - const now = new Date() - let finalStartDate: Date - let finalEndDate: Date - - const isFutureIntent = Object.values(duration).some( - (val) => val !== undefined && val < 0 - ) - - const normalizedDuration: Duration = {} - ;(Object.keys(duration) as Array).forEach( - (key) => { - const value = duration[key] - if (value !== undefined) { - normalizedDuration[key] = Math.abs(value) +const ChronoShortcut = React.forwardRef( + ({ asChild, children, __scope, duration, ...props }, forwardedRef) => { + const { setDateRange, setInternalIsRelative, setOpen, inputRef } = + useChronoContext(COMPONENT_NAME, __scope) + + const handleClick = useCallback( + (e: React.MouseEvent) => { + e.preventDefault() + const now = new Date() + let finalStartDate: Date + let finalEndDate: Date + + const isFutureIntent = Object.values(duration).some( + (val) => val !== undefined && val < 0 + ) + + const normalizedDuration: Duration = {} + ;(Object.keys(duration) as Array).forEach( + (key) => { + const value = duration[key] + if (value !== undefined) { + normalizedDuration[key] = Math.abs(value) + } } + ) + + if (isFutureIntent) { + finalStartDate = now + finalEndDate = add(now, normalizedDuration) + } else { + finalStartDate = sub(now, normalizedDuration) + finalEndDate = now } - ) - - if (isFutureIntent) { - finalStartDate = now - finalEndDate = add(now, normalizedDuration) - } else { - finalStartDate = sub(now, normalizedDuration) - finalEndDate = now - } - setInternalIsRelative(true) - setDateRange({ startDate: finalStartDate, endDate: finalEndDate }) - setOpen(false) - if (inputRef.current) { - inputRef.current.blur() - } - }, - [setDateRange, setInternalIsRelative, setOpen, duration, inputRef] - ) + setInternalIsRelative(true) + setDateRange({ startDate: finalStartDate, endDate: finalEndDate }) + setOpen(false) + if (inputRef.current) { + inputRef.current.blur() + } + }, + [setDateRange, setInternalIsRelative, setOpen, duration, inputRef] + ) - const optionPropsAria = { - ...props, - role: 'option', - 'aria-selected': false, - ref: forwardedRef, - onClick: handleClick, - tabIndex: -1, - 'data-shortcut-item': 'true', - style: { cursor: 'pointer', ...props.style } - } + const optionPropsAria = { + ...props, + role: 'option', + 'aria-selected': false, + ref: forwardedRef, + onClick: handleClick, + tabIndex: -1, + 'data-shortcut-item': 'true', + style: { cursor: 'pointer', ...props.style } + } - const Comp = asChild ? Slot : 'div' + const Comp = asChild ? Slot : 'div' - return {children} -}) + return {children} + } +) -const Root = TimeSlice -const Trigger = TimeSliceTrigger -const Input = TimeSliceInput -const Portal = TimeSlicePortal -const Shortcut = TimeSliceShortcut +const Root = Chrono +const Trigger = ChronoTrigger +const Input = ChronoInput +const Portal = ChronoPortal +const Shortcut = ChronoShortcut export { Root, @@ -576,9 +574,9 @@ export { Input, Portal, Shortcut, - type TimeSliceProps, - type TimeSliceInputProps, - type TimeSlicePortalProps, - type TimeSliceShortcutProps, + type ChronoProps, + type ChronoInputProps, + type ChronoPortalProps, + type ChronoShortcutProps, type DateRange } diff --git a/src/timeslice/hooks/use-time-slice-state.ts b/src/chrono/hooks/use-chrono-state.ts similarity index 92% rename from src/timeslice/hooks/use-time-slice-state.ts rename to src/chrono/hooks/use-chrono-state.ts index 537b0a8..405f1ad 100644 --- a/src/timeslice/hooks/use-time-slice-state.ts +++ b/src/chrono/hooks/use-chrono-state.ts @@ -6,7 +6,7 @@ export type DateRange = { endDate?: Date } -type UseTimeSliceStateProps = { +type UseChronoStateProps = { open?: boolean defaultOpen?: boolean onOpenChange?: (open: boolean) => void @@ -15,10 +15,10 @@ type UseTimeSliceStateProps = { onDateRangeChange?: (range: DateRange) => void } -export function useTimeSliceState({ +export function useChronoState({ onDateRangeChange, ...props -}: UseTimeSliceStateProps) { +}: UseChronoStateProps) { const [open, setOpen] = useControllableState({ prop: props.open, defaultProp: props.defaultOpen ?? false, diff --git a/src/timeslice/hooks/use-segment-navigation/__tests__/date-adjustments.test.ts b/src/chrono/hooks/use-segment-navigation/__tests__/date-adjustments.test.ts similarity index 100% rename from src/timeslice/hooks/use-segment-navigation/__tests__/date-adjustments.test.ts rename to src/chrono/hooks/use-segment-navigation/__tests__/date-adjustments.test.ts diff --git a/src/timeslice/hooks/use-segment-navigation/__tests__/environment.test.ts b/src/chrono/hooks/use-segment-navigation/__tests__/environment.test.ts similarity index 100% rename from src/timeslice/hooks/use-segment-navigation/__tests__/environment.test.ts rename to src/chrono/hooks/use-segment-navigation/__tests__/environment.test.ts diff --git a/src/timeslice/hooks/use-segment-navigation/__tests__/initial-state.test.ts b/src/chrono/hooks/use-segment-navigation/__tests__/initial-state.test.ts similarity index 97% rename from src/timeslice/hooks/use-segment-navigation/__tests__/initial-state.test.ts rename to src/chrono/hooks/use-segment-navigation/__tests__/initial-state.test.ts index cc87c15..5816dda 100644 --- a/src/timeslice/hooks/use-segment-navigation/__tests__/initial-state.test.ts +++ b/src/chrono/hooks/use-segment-navigation/__tests__/initial-state.test.ts @@ -5,7 +5,7 @@ import { buildSegments // type Segment // Not used } from '../use-segment-navigation' -// import type { DateRange } from '../../use-time-slice-state' // Not used +// import type { DateRange } from '../../use-chrono-state' // Not used import { // createMockInputRef, getHook as getHookFromUtils diff --git a/src/timeslice/hooks/use-segment-navigation/__tests__/keyboard-navigation.test.ts b/src/chrono/hooks/use-segment-navigation/__tests__/keyboard-navigation.test.ts similarity index 100% rename from src/timeslice/hooks/use-segment-navigation/__tests__/keyboard-navigation.test.ts rename to src/chrono/hooks/use-segment-navigation/__tests__/keyboard-navigation.test.ts diff --git a/src/timeslice/hooks/use-segment-navigation/__tests__/keydown-interactions.test.ts b/src/chrono/hooks/use-segment-navigation/__tests__/keydown-interactions.test.ts similarity index 99% rename from src/timeslice/hooks/use-segment-navigation/__tests__/keydown-interactions.test.ts rename to src/chrono/hooks/use-segment-navigation/__tests__/keydown-interactions.test.ts index cbb9dde..e396111 100644 --- a/src/timeslice/hooks/use-segment-navigation/__tests__/keydown-interactions.test.ts +++ b/src/chrono/hooks/use-segment-navigation/__tests__/keydown-interactions.test.ts @@ -6,7 +6,7 @@ import { // type DateSegmentType, // Not used type Segment // Used in Tab suite helper } from '../use-segment-navigation' -// import type { DateRange } from '../../use-time-slice-state'; // Not used +// import type { DateRange } from '../../use-chrono-state'; // Not used import { createMockKeyboardEvent, getHook as getHookFromUtils diff --git a/src/timeslice/hooks/use-segment-navigation/__tests__/manual-input.test.ts b/src/chrono/hooks/use-segment-navigation/__tests__/manual-input.test.ts similarity index 100% rename from src/timeslice/hooks/use-segment-navigation/__tests__/manual-input.test.ts rename to src/chrono/hooks/use-segment-navigation/__tests__/manual-input.test.ts diff --git a/src/timeslice/hooks/use-segment-navigation/__tests__/segment-selection.test.ts b/src/chrono/hooks/use-segment-navigation/__tests__/segment-selection.test.ts similarity index 100% rename from src/timeslice/hooks/use-segment-navigation/__tests__/segment-selection.test.ts rename to src/chrono/hooks/use-segment-navigation/__tests__/segment-selection.test.ts diff --git a/src/timeslice/hooks/use-segment-navigation/__tests__/timezone-specific.test.ts b/src/chrono/hooks/use-segment-navigation/__tests__/timezone-specific.test.ts similarity index 100% rename from src/timeslice/hooks/use-segment-navigation/__tests__/timezone-specific.test.ts rename to src/chrono/hooks/use-segment-navigation/__tests__/timezone-specific.test.ts diff --git a/src/timeslice/hooks/use-segment-navigation/index.ts b/src/chrono/hooks/use-segment-navigation/index.ts similarity index 100% rename from src/timeslice/hooks/use-segment-navigation/index.ts rename to src/chrono/hooks/use-segment-navigation/index.ts diff --git a/src/timeslice/hooks/use-segment-navigation/test-utils.ts b/src/chrono/hooks/use-segment-navigation/test-utils.ts similarity index 99% rename from src/timeslice/hooks/use-segment-navigation/test-utils.ts rename to src/chrono/hooks/use-segment-navigation/test-utils.ts index e7415a7..7b8af28 100644 --- a/src/timeslice/hooks/use-segment-navigation/test-utils.ts +++ b/src/chrono/hooks/use-segment-navigation/test-utils.ts @@ -2,7 +2,7 @@ import { vi, type MockedFunction } from 'vitest' import type React from 'react' import { renderHook } from '@testing-library/react' import { useSegmentNavigation } from './use-segment-navigation' -import type { DateRange } from '../use-time-slice-state' +import type { DateRange } from '../use-chrono-state' import { act } from '@testing-library/react' import { buildSegments as buildSegmentsInternal, diff --git a/src/timeslice/hooks/use-segment-navigation/use-segment-navigation.ts b/src/chrono/hooks/use-segment-navigation/use-segment-navigation.ts similarity index 99% rename from src/timeslice/hooks/use-segment-navigation/use-segment-navigation.ts rename to src/chrono/hooks/use-segment-navigation/use-segment-navigation.ts index 748ba3b..2d7a22a 100644 --- a/src/timeslice/hooks/use-segment-navigation/use-segment-navigation.ts +++ b/src/chrono/hooks/use-segment-navigation/use-segment-navigation.ts @@ -1,5 +1,5 @@ import React, { useCallback, useMemo, useEffect, useState } from 'react' -import type { DateRange } from '../use-time-slice-state' +import type { DateRange } from '../use-chrono-state' import { addMonths, addDays, addHours, addMinutes, addYears } from 'date-fns' import '@formatjs/intl-datetimeformat/polyfill' import '@formatjs/intl-datetimeformat/locale-data/en' diff --git a/src/chrono/index.ts b/src/chrono/index.ts new file mode 100644 index 0000000..0d9927d --- /dev/null +++ b/src/chrono/index.ts @@ -0,0 +1,13 @@ +export { + Root, + Trigger, + Input, + Portal, + Shortcut, + type ChronoProps, + type ChronoInputProps, + type ChronoPortalProps, + type ChronoShortcutProps, + type DateRange, + type TimeZone +} from './chrono' diff --git a/src/timeslice/utils/date-parser.ts b/src/chrono/utils/date-parser.ts similarity index 96% rename from src/timeslice/utils/date-parser.ts rename to src/chrono/utils/date-parser.ts index d0a8928..365d8e7 100644 --- a/src/timeslice/utils/date-parser.ts +++ b/src/chrono/utils/date-parser.ts @@ -1,6 +1,6 @@ import * as chrono from 'chrono-node' import { fromUnixTime, isValid } from 'date-fns' -import type { DateRange } from '../hooks/use-time-slice-state' +import type { DateRange } from '../hooks/use-chrono-state' export function parseDateInput(value: string): DateRange { let parsed = chrono.parse(value, new Date()) diff --git a/src/timeslice/utils/time-range.ts b/src/chrono/utils/time-range.ts similarity index 100% rename from src/timeslice/utils/time-range.ts rename to src/chrono/utils/time-range.ts diff --git a/src/index.ts b/src/index.ts index 247e6ac..7e27701 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,10 @@ -export * as TimeSlice from './timeslice' +export * as Chrono from './chrono' +export { Inlay } from './inlay' +export type { + InlayProps, + InlayRef, + TokenState, + Plugin, + Matcher, + Match +} from './inlay' diff --git a/src/inlay/.gitignore b/src/inlay/.gitignore new file mode 100644 index 0000000..68c7b8c --- /dev/null +++ b/src/inlay/.gitignore @@ -0,0 +1,4 @@ +# Ignore local development notes +TODO.md +TODO.local.md +*.private.md \ No newline at end of file diff --git a/src/inlay/ARCHITECTURE.md b/src/inlay/ARCHITECTURE.md new file mode 100644 index 0000000..d9502c3 --- /dev/null +++ b/src/inlay/ARCHITECTURE.md @@ -0,0 +1,351 @@ +# Inlay Architecture + +Inlay is a React-based rich text editor primitive built on `contentEditable`. It provides controlled text input with support for embedded tokens—inline elements that represent structured data (mentions, tags, links) while maintaining a clean string-based value model. + +## Core Concept: Token Divergence + +The key architectural decision is **token divergence**: a token's visual representation can differ from its raw value. + +``` +Raw value: "Hello @alice_123, meet @bob_456" +Visual DOM: "Hello Alice, meet Bob" +``` + +This enables readable UI while preserving machine-readable identifiers in the underlying value. All cursor movement, selection, copy/paste, and deletion operations must account for this divergence. + +## Component Hierarchy + +``` +StructuredInlay (high-level, plugin-based) + └── Inlay.Root (core contentEditable wrapper) + ├── Inlay.Token (inline token markers) + └── Inlay.Portal (positioned overlays via Radix Popover) +``` + +### Exports + +All components are exported under the `Inlay` namespace: + +- `Inlay.Root` — Main editor component, wraps contentEditable +- `Inlay.Token` — Declares a token with `value` (raw) and `children` (visual) +- `Inlay.Portal` — Positioned popover anchored to selection or editor + - `Inlay.Portal.List` — Keyboard-navigable list container + - `Inlay.Portal.Item` — Selectable item within a Inlay.Portal.List +- `Inlay.StructuredInlay` — Higher-level component with plugin system + +Types are also exported: `InlayProps`, `InlayRef`, `TokenState`, `Plugin`, `Matcher`, `Match`. + +## Directory Structure + +``` +src/inlay/ +├── inlay.tsx # Core Root/Token/Portal components +├── portal-list.tsx # Portal.List/Item compound components +├── index.ts # Public exports +├── hooks/ +│ ├── use-clipboard.ts # Copy/cut/paste with token awareness +│ ├── use-composition.ts # IME composition handling (incl. iOS quirks) +│ ├── use-history.ts # Undo/redo stack +│ ├── use-key-handlers.ts# Keyboard input processing (incl. Android GBoard) +│ ├── use-placeholder-sync.ts +│ ├── use-selection.ts # Selection state tracking (incl. iOS selectionchange) +│ ├── use-selection-snap.ts # Cursor snapping to token boundaries +│ ├── use-token-weaver.tsx # Two-pass token rendering +│ ├── use-touch-selection.ts # Touch-based selection handling +│ └── use-virtual-keyboard.ts # Virtual keyboard detection (visualViewport) +├── internal/ +│ ├── dom-utils.ts # DOM traversal, offset calculation +│ └── string-utils.ts # Token matching/scanning +├── structured/ +│ ├── structured-inlay.tsx # Plugin-based wrapper +│ └── plugins/ +│ ├── plugin.ts # Plugin type definition +│ └── mentions.tsx # Example mentions plugin +├── __ct__/ # Playwright component tests +└── __tests__/ # Vitest unit tests +``` + +## Key Hooks + +### `useTokenWeaver` +Two-pass rendering system: +1. First pass: Children render invisibly to register tokens +2. Second pass: Tokens are "weaved" into the text at correct positions + +This solves the chicken-and-egg problem of needing to know token positions before rendering while also needing to render to know what tokens exist. + +**Empty state:** When the value is empty, a zero-width space (`\u200B`) is rendered to maintain consistent caret height. Without this, the caret position can shift vertically when transitioning between empty and non-empty states (especially with styled tokens that have padding). + +### `useKeyHandlers` +Intercepts all keyboard input via `onBeforeInput` and `onKeyDown`. Prevents default browser behavior and manually updates the controlled value. Handles: +- Text insertion (with multi-char insert tracking for iOS swipe-text) +- Backspace/Delete (with grapheme cluster awareness) +- iOS swipe-text word deletion (deletes entire swiped word, preserves auto-inserted spaces) +- Enter/Space +- Undo/Redo (Ctrl+Z, Ctrl+Y) + +**iOS DOM sync:** On iOS, text insertions bypass `preventDefault()` to avoid multi-word suggestion bugs. The `input` event handler syncs DOM content to React state using `serializeRawFromDom()`. A `valueRef` provides synchronous access to the current value for decisions that must be made before React re-renders (e.g., detecting newlines to avoid `` reconciliation crashes). + +### `useComposition` +Manages IME (Input Method Editor) composition for CJK languages. Tracks composition state to avoid interfering with in-progress input. Handles composition commit via Space/Enter. + +### `useClipboard` +Token-aware clipboard operations. When copying/cutting a token, extracts the raw value (not visual text). When pasting, inserts at correct raw offset position. + +### `useSelectionSnap` +Snaps cursor and selection to token boundaries. Prevents cursor from landing inside a token's visual representation—it either sits before or after the token in raw-value terms. + +### `useHistory` +Simple undo/redo with snapshot-based history. Coalesces rapid edits into single undo steps. + +### `useSelection` +Tracks current selection as raw offsets. Provides `activeToken` when cursor is adjacent to or within a token. + +## Internal Utilities + +### `dom-utils.ts` +Core DOM traversal functions that account for token divergence: + +- `getAbsoluteOffset(root, node, offset)` — Converts DOM selection position to raw string offset +- `getTextNodeAtOffset(root, offset)` — Converts raw offset to DOM position +- `setDomSelection(root, start, end?)` — Sets browser selection from raw offsets +- `getClosestTokenEl(node)` — Finds containing token element +- `getTokenRawRange(root, tokenEl)` — Gets raw offset range for a token + +### `string-utils.ts` +Token matching and scanning: + +- `Matcher` — Interface for token matchers (regex, prefix, custom) +- `scan(text, matchers)` — Finds all token matches in a string, with overlap resolution +- `Match` — Represents a found token with position and parsed data + +**Overlap Resolution:** When multiple matchers produce overlapping matches, `scan()` uses a longest-match-wins strategy: +1. Matches are sorted by start position, then by length (longest first) +2. A greedy algorithm accepts non-overlapping matches, preferring longer ones +3. When matches have the same range, the first matcher in the array wins + +This prevents duplicate tokens when plugins have overlapping patterns (e.g., `@alice` vs `@alice_vip`). + +## Portal Navigation + +Portal content often needs keyboard navigation (e.g., autocomplete lists). `Portal.List` and `Portal.Item` provide this with built-in keyboard handling. + +```tsx +portal: ({ replace }) => ( + replace(`@${user.id} `)}> + {users.map(user => ( + + {user.name} + + ))} + +) +``` + +**Keyboard behavior:** +- `ArrowUp/Down` — Navigate items (wraps around) +- `Enter` — Select active item +- `Escape` — Dismiss portal + +**Virtual focus:** The editor retains DOM focus while Portal.List tracks the "active" item via state. This avoids contentEditable focus issues. + +**Styling:** Use `data-active` attribute for highlighting: +```css +[data-portal-item][data-active] { background: var(--highlight); } +``` + +**Single-item pattern:** For confirmations or actions, use a single Inlay.Portal.Item: +```tsx + deleteToken()}> + Delete? Press Enter to confirm. + +``` + +**Positioning:** Portal uses manual DOM positioning instead of Radix's built-in anchor. This ensures the popover follows the caret on iOS Safari, where Radix's cached anchor position doesn't update correctly after text changes. The anchor rect is passed via `AnchorRectContext` and applied via `useLayoutEffect` on each render. + +## Plugin System (StructuredInlay) + +Plugins define token types with: + +```typescript +type Plugin = { + props: P // Plugin configuration + matcher: Matcher // How to find tokens in text + render: (ctx) => ReactNode // Token visual representation + portal: (ctx) => ReactNode // Optional popover content + onInsert: (value: T) => void + onKeyDown: (event) => boolean +} +``` + +Example: A mentions plugin matches `@username` patterns, renders styled chips, and shows a user card popover on focus. + +## Browser Compatibility + +- Handles Firefox's element-node selections (Ctrl+A sets selection on element, not text nodes) +- WebKit composition quirks (extra `beforeInput` events after `compositionend`) +- Cross-platform keyboard shortcuts via `ControlOrMeta` + +## Mobile Support + +Inlay provides full mobile device support with touch interactions, virtual keyboard handling, and platform-specific fixes. + +### Mobile Input Attributes + +The editor automatically sets mobile-friendly attributes: + +```tsx + +``` + +All attributes are configurable via props: + +```tsx +type InlayProps = { + inputMode?: 'text' | 'search' | 'email' | 'tel' | 'url' | 'numeric' | 'decimal' | 'none' + autoCapitalize?: 'off' | 'none' | 'on' | 'sentences' | 'words' | 'characters' + autoCorrect?: 'on' | 'off' + enterKeyHint?: 'enter' | 'done' | 'go' | 'next' | 'previous' | 'search' | 'send' + onVirtualKeyboardChange?: (open: boolean) => void +} +``` + +### Touch Event Handling + +The `useTouchSelection` hook handles touch-based interactions: + +- **Tap to focus:** Positions caret at touch location +- **Long press:** Triggers native selection mode +- **Token snapping:** Touch inside tokens snaps to token boundaries +- **Debouncing:** Prevents rapid touch event issues + +### Virtual Keyboard Detection + +The `useVirtualKeyboard` hook uses the `visualViewport` API to detect keyboard visibility: + +```tsx + { + console.log('Keyboard:', open ? 'open' : 'closed') + }} +/> +``` + +When the keyboard opens, the editor automatically scrolls into view. + +### Portal Touch Navigation + +`Portal.List` and `Portal.Item` support touch interactions: + +- **Touch start:** Activates item (like hover on desktop) +- **Touch end:** Selects item if touch didn't move (tap detection) +- **Scroll vs tap:** Movement >10px cancels selection + +```tsx +// Portal items work the same on touch and desktop + + + {item.label} + + +``` + +### iOS-Specific Handling + +- **Selection events:** iOS fires `selectionchange` on `document`, not the element. Added document-level listener in `useSelection`. +- **Anchor rect updates:** iOS can return stale caret rects after text changes. The `useSelection` hook listens for `input` and `visualViewport` events, using `requestAnimationFrame` to read the rect after layout stabilizes. This ensures popovers follow the caret correctly. +- **Composition data:** iOS Safari sometimes omits data in `compositionend`. Tracked via `compositionupdate` as fallback. +- **iPad detection:** Includes modern iPads that report as "MacIntel" with touch. +- **Multi-word suggestions prevention:** Calling `preventDefault()` on `beforeinput` for text insertions triggers iOS to show multi-word predictions (e.g., "I am going to the" as a single suggestion). However, iOS doesn't send usable event data for these predictions. By NOT calling `preventDefault()` on iOS for `insertText`, iOS shows only single-word suggestions which work correctly. The DOM is modified natively and synced to React state via the `input` event handler. +- **Token context exception:** When the cursor is inside a token, we use the controlled path (with `preventDefault`) even on iOS. This avoids issues where `data-token-text` attributes become stale after edits, causing `serializeRawFromDom` to return incorrect values. +- **Newline handling with React:** When content contains newlines, React renders `` elements. If iOS modifies the DOM directly around `` elements, React reconciliation fails with "NotFoundError". Solution: use a `valueRef` to detect newlines synchronously (before React re-renders) and call `preventDefault()` when newlines exist, handling the input via the controlled path. +- **Swipe-text after newlines:** iOS sends swipe data with a leading space even at the start of a line (after `\n`). This space is stripped. iOS may also send the space as a SEPARATE event before the word—single-space insertions at line start are skipped entirely. +- **Swipe-text word deletion:** When user swipe-types a word and presses backspace, iOS sends a single `deleteContentBackward` event with a targetRange covering only the last character. However, if we don't `preventDefault()`, iOS fires 5 rapid delete events and deletes the whole word natively. Since we need to `preventDefault()` to maintain controlled state, we track multi-char inserts and delete the entire chunk when backspace is pressed immediately after. +- **Swipe-text space preservation:** iOS auto-inserts a leading space when swipe-typing after existing text (e.g., "hello" + swipe "world" → "hello world"). When deleting, only the word is removed, preserving the auto-inserted space. +- **Autocomplete suggestions:** For `insertReplacementText` (autocomplete), iOS may not provide the replacement data when `preventDefault()` is called. On iOS, autocomplete is always handled via DOM sync regardless of newlines. +- **Autocomplete state reset:** After pressing Enter, the `autocomplete` attribute is briefly toggled to reset iOS's autocomplete context. This prevents iOS from suggesting merged words like "helloworld" when the actual text is "hello\nworld". + +### Android-Specific Handling + +- **GBoard predictions:** Handles `insertReplacementText` input type for word predictions. Replacement text is in `event.data`. +- **Delete variations:** Handles `deleteWordBackward`, `deleteWordForward`, `deleteSoftLineBackward`, `deleteSoftLineForward` input types. + +### iOS Safari Text Suggestions + +When a user taps a keyboard suggestion on iOS Safari: +1. iOS fires `insertReplacementText` with `data: null` and the replacement text in `event.dataTransfer.getData('text/plain')` +2. This differs from Android which puts the text in `event.data` +3. The handler checks both `data` and `dataTransfer` to support both platforms + +### Testing Mobile + +Mobile tests use Playwright with device emulation: + +```bash +# Run mobile-specific tests +bun run test:ct -- --project=mobile-chrome +bun run test:ct -- --project=mobile-safari +``` + +Test files in `__ct__/inlay.mobile.spec.tsx` cover: +- Touch-based caret positioning +- Mobile attribute presence +- Portal touch navigation +- Token interaction on touch + +## Accessibility + +Inlay provides baseline accessibility out of the box: + +- `role="textbox"` and `aria-multiline` are set automatically +- Default `aria-label="Text input"` — consumers should override with context-specific labels +- Placeholder is marked `aria-hidden="true"` to avoid duplicate announcements + +**Automated a11y testing:** Uses `@axe-core/playwright` to catch WCAG violations in CI. Tests cover empty state, with-tokens, and focused states. + +**Consumer responsibilities:** +- Provide meaningful `aria-label` or `aria-labelledby` for the editor context +- Ensure token visual styling meets contrast requirements +- Test with actual screen readers (VoiceOver, NVDA) for announcement quality + +## Testing + +- **`__ct__/`** — Playwright component tests (real browser, keyboard simulation) +- **`__tests__/`** — Vitest unit tests (JSDOM, faster iteration) + +Run with: +```bash +bun run test:ct -- src/inlay/__ct__/ # Playwright +bun run test -- src/inlay/ # Vitest +``` + +## Common Patterns + +### Adding a new keyboard shortcut +1. Add handler in `use-key-handlers.ts` `onKeyDown` +2. Check for modifier keys, prevent default, update value +3. Use `setDomSelection` to position cursor after state update + +### Adding clipboard behavior +1. Modify `use-clipboard.ts` +2. Use `getSelectionFromDom` for token-aware selection +3. Use `cfg.getValue()` to read raw value, `cfg.setValue()` to update + +### Creating a new token type +1. Define a `Matcher` in `string-utils.ts` format +2. Use with `Inlay.StructuredInlay` plugins or manually with `` + +## Known Limitations + +- Single-line by default (`multiline` prop enables multi-line) +- No rich formatting (bold, italic) — tokens only +- No nested tokens +- IME composition with tokens at boundaries can be tricky +- Mobile autocorrect is disabled by default (would interfere with tokens) +- Samsung keyboard may have composition quirks (test thoroughly) + diff --git a/src/inlay/ISSUE-ios-multiword-suggestions.md b/src/inlay/ISSUE-ios-multiword-suggestions.md new file mode 100644 index 0000000..d0a8823 --- /dev/null +++ b/src/inlay/ISSUE-ios-multiword-suggestions.md @@ -0,0 +1,184 @@ +# iOS Multi-Word Keyboard Suggestions Not Working + +## Problem Statement + +When using Inlay on iOS Safari, multi-word keyboard predictions (like "tell" → "tell them") don't work. Tapping a multi-word suggestion either does nothing or only inserts a space. + +Single-word suggestions (like "hel" → "hello") DO work correctly. + +## Root Cause Discovery + +After extensive debugging, we discovered: + +**Calling `preventDefault()` on `beforeinput` events triggers iOS to show multi-word predictions.** + +This is unexpected iOS behavior - when we prevent default, iOS interprets it as "this app wants special input handling" and switches to an advanced keyboard mode with multi-word predictions. + +### Test Results + +| ContentEditable Setup | Multi-Word Predictions? | +|----------------------|------------------------| +| Naked (no JS) | ❌ No | +| With `onInput` only | ❌ No | +| With `onBeforeInput` (no prevent) | ❌ No | +| With `onBeforeInput` + `preventDefault()` | ✅ Yes | +| Inlay.Root | ✅ Yes | +| StructuredInlay | ✅ Yes | + +### The Dilemma + +1. **We need `preventDefault()`** to control input, especially around structured tokens +2. **`preventDefault()` triggers multi-word predictions** on iOS +3. **iOS doesn't send usable events** for multi-word predictions - no `data`, no `dataTransfer`, nothing to intercept + +## What Works + +**Single-word suggestions** work correctly: +- iOS sends `inputType: "insertReplacementText"` with `data: null` +- The actual text is in `event.dataTransfer.getData('text/plain')` +- We handle this in `use-key-handlers.ts` around line 150 + +```typescript +// Get replacement text: try data first (Android), then dataTransfer (iOS Safari) +const replacementText = data ?? event.dataTransfer?.getData('text/plain') +``` + +## What Doesn't Work + +**Multi-word predictions** fail because: +1. When user taps a multi-word suggestion, iOS sends `textInput` with just `data: " "` (space) +2. No actual suggestion text is provided anywhere +3. We can't handle what we can't detect + +## Potential Solutions + +### 1. Allow + Resync on Input +Don't `preventDefault()`. Let iOS modify the DOM, then on `input`: +- Parse the DOM content +- Reconstruct token state +- Sync to React + +**Challenge**: Token integrity - if iOS types into a token span, we need to correctly parse it back. + +### 2. Invisible Input Overlay (iOS only) +Mount a real `` or `` on iOS that: +- Is invisible but captures all native input +- Gets all the proper iOS events +- Syncs to the visible contentEditable display + +**Advantage**: Clean separation - iOS talks to native input, we control display. + +### 3. Hybrid preventDefault +Only call `preventDefault()` when cursor is in/near tokens. Let iOS handle plain text areas normally. + +**Challenge**: Detecting "near tokens" reliably, edge cases. + +### 4. Parse-Based Reconciliation +After any DOM mutation: +- Diff the DOM against expected state +- Reconcile differences +- Update React state accordingly + +**Similar to #1** but more focused on diffing. + +## Relevant Code Locations + +- `src/inlay/hooks/use-key-handlers.ts` - Main input handling, `preventDefault()` calls +- `src/inlay/inlay.tsx` - ContentEditable element, attributes +- `src/inlay/stories/structured.stories.tsx` - Test stories including `NakedContentEditable` + +## Test Stories Created + +In `structured.stories.tsx`: + +### `MinimalInlay` +- Minimal Inlay.Root (no plugins) +- StructuredInlay (no plugins) +- Both show multi-word predictions (confirms it's core Inlay, not plugins) + +### `NakedContentEditable` +Key test cases to isolate the cause: + +1. **Naked (no JS)** - Single-word only ✅ +2. **With preventDefault on beforeinput** - Multi-word predictions ❌ +3. **With React controlled state** - Single-word only ✅ +4. **Handle on input instead of beforeinput** - Single-word only ✅ + +The 4th test case is the key insight: if you add a `beforeinput` handler but DON'T call `preventDefault()`, you still get single-word only. It's specifically the `preventDefault()` call that triggers multi-word. + +## Debug Logging (Currently Active) + +Extensive logging is currently in `use-key-handlers.ts` for iOS debugging. These are useful for testing on real devices: + +- `[beforeinput]` - Logs all beforeinput events with inputType, data, dataTransfer, cancelable, etc. +- `[insertText]` - Logs text insertions with position info +- `[insertReplacementText]` - Logs iOS/Android word predictions +- `[MutationObserver]` - Logs DOM changes not caught by events +- `[textInput]` - Logs native textInput events with DOM content +- `[input]` - Logs post-input events +- `[document textInput/input/beforeinput]` - Document-level listeners + +There are also document-level event listeners added in the `useEffect` for catching events iOS might send elsewhere. + +**Note**: These should be removed or made conditional before shipping to production. + +## Current State + +The code has: +1. ✅ **Single-word `insertReplacementText` fix** - Working! Checks `dataTransfer` when `data` is null +2. ✅ **Debug logging** - Active, useful for iOS testing on real devices +3. ⚠️ **Space-handling experiment** - Code at ~line 355 that doesn't `preventDefault()` for space insertions (was testing if iOS sends more events after space) +4. ⚠️ **MutationObserver with debouncing** - Syncs DOM to React state when we don't prevent default +5. ⚠️ **`lastPreventedTimeRef` tracking** - Tracks when we prevented vs didn't, so MutationObserver knows when to sync +6. ⚠️ **`preventAndMark()` helper** - Wrapper around `preventDefault()` that also updates the ref + +## Test Status + +Some tests in `inlay.ios-swipe-text.spec.tsx` are **currently failing** due to experimental changes: + +``` +3 failed: +- backspace after swipe + trailing space should delete swiped word +- backspace after multiple swipes should delete most recent word +- swipe after trailing space should not create double space +11 passed +``` + +The failures are because of the space-handling experiment (line ~355) that doesn't `preventDefault()` for spaces. This breaks the controlled input flow. + +**To fix**: Either revert the space experiment or update the tests. + +## Recommended Next Steps + +1. **Choose an approach** from the solutions above +2. **Prototype** the chosen approach +3. **Test on real iOS device** (critical - simulators may differ) +4. **Fix or update failing tests** +5. **Clean up debug logging** before shipping +6. **Update ARCHITECTURE.md** with final solution + +## iOS Keyboard Behavior Notes + +- `autocorrect="on"` vs omitted behaves the same (both show multi-word when preventDefault is used) +- `spellcheck`, `autocapitalize`, `role`, `inputMode` don't affect multi-word prediction appearance +- The trigger is specifically `preventDefault()` on `beforeinput` events +- iOS System Settings → Keyboard → Predictive controls this at OS level, but we can't control it from web + +## Code Changes Made + +### `inlay.tsx` +- Changed `autoCorrect` default from `'off'` to `undefined` (omit attribute, let iOS decide) + +### `use-key-handlers.ts` +- Added `lastPreventedTimeRef` to track when we prevent default +- Added `preventAndMark()` helper function +- Added extensive debug logging +- Added MutationObserver that syncs DOM to state when we don't prevent +- Added document-level event listeners for debugging +- Added space-handling experiment (line ~355) + +### `structured.stories.tsx` +- Removed `autoCorrect="on"` from main story (using default now) +- Added `MinimalInlay` story +- Added `NakedContentEditable` story with 4 test cases + diff --git a/src/inlay/__ct__/fixtures/auto-update-inlay.tsx b/src/inlay/__ct__/fixtures/auto-update-inlay.tsx new file mode 100644 index 0000000..fe2033b --- /dev/null +++ b/src/inlay/__ct__/fixtures/auto-update-inlay.tsx @@ -0,0 +1,78 @@ +import React from 'react' +import { StructuredInlay } from '../../structured/structured-inlay' +import { createRegexMatcher } from '../../internal/string-utils' +import type { Plugin } from '../../structured/plugins/plugin' + +type TokenData = { raw: string; name?: string } + +/** + * Token component that auto-updates after mount (like the storybook MentionToken). + * This creates divergence: raw "@user" → display "User Name" + */ +function MentionToken({ + token, + update +}: { + token: TokenData + update: (data: Partial) => void +}) { + const ref = React.useRef(null) + + React.useEffect(() => { + // Only run in the visible render (not the hidden registration pass) + // Check if we're inside a display:none container + if (ref.current && ref.current.offsetParent === null) { + return + } + + // If no name yet, "fetch" and update (simulates async user lookup) + if (!token.name && token.raw.startsWith('@')) { + const timeout = setTimeout(() => { + update({ name: 'User Name' }) + }, 10) + return () => clearTimeout(timeout) + } + }, [token.raw, token.name, update]) + + return ( + + {token.name ?? token.raw} + + ) +} + +/** + * Test fixture with auto-updating diverged tokens. + * Each @mention triggers an update() call after mount. + */ +export function AutoUpdateInlay({ initial }: { initial: string }) { + const [value, setValue] = React.useState(initial) + + const plugins: Plugin[] = React.useMemo( + () => [ + { + props: {}, + matcher: createRegexMatcher('mention', { + regex: /@\w+/g, + transform: (m) => ({ raw: m[0] }) + }), + render: ({ token, update }) => ( + + ), + portal: () => null, + onInsert: () => {}, + onKeyDown: () => false + } + ], + [] + ) + + return ( + + ) +} diff --git a/src/inlay/__ct__/fixtures/controlled-token-inlay.tsx b/src/inlay/__ct__/fixtures/controlled-token-inlay.tsx new file mode 100644 index 0000000..9c5877a --- /dev/null +++ b/src/inlay/__ct__/fixtures/controlled-token-inlay.tsx @@ -0,0 +1,17 @@ +import React from 'react' +import { Inlay } from '../..' + +export function ControlledTokenInlay({ initial }: { initial: string }) { + const [value, setValue] = React.useState(initial) + return ( + + {value.includes('@x') ? ( + + @x + + ) : ( + + )} + + ) +} diff --git a/src/inlay/__ct__/fixtures/diverged-token-inlay.tsx b/src/inlay/__ct__/fixtures/diverged-token-inlay.tsx new file mode 100644 index 0000000..aa0b5f7 --- /dev/null +++ b/src/inlay/__ct__/fixtures/diverged-token-inlay.tsx @@ -0,0 +1,35 @@ +import React from 'react' +import { Inlay } from '../..' + +/** + * A fixture with a "diverged" token where the visual display + * differs from the raw value: + * - Raw value: "@alice" (7 chars) + * - Visual display: "Alice" (5 chars) + */ +export function DivergedTokenInlay({ + initial, + onValueChange +}: { + initial: string + onValueChange?: (value: string) => void +}) { + const [value, setValue] = React.useState(initial) + + const handleChange = (newValue: string) => { + setValue(newValue) + onValueChange?.(newValue) + } + + return ( + + {value.includes('@alice') ? ( + + Alice + + ) : ( + + )} + + ) +} diff --git a/src/inlay/__ct__/fixtures/mobile-inlay.tsx b/src/inlay/__ct__/fixtures/mobile-inlay.tsx new file mode 100644 index 0000000..7fb76c5 --- /dev/null +++ b/src/inlay/__ct__/fixtures/mobile-inlay.tsx @@ -0,0 +1,137 @@ +import React from 'react' +import { Inlay } from '../../index' +import { createRegexMatcher } from '../../internal/string-utils' +import type { Plugin } from '../../structured/plugins/plugin' + +const ITEMS = [ + { id: '1', label: 'Apple' }, + { id: '2', label: 'Banana' }, + { id: '3', label: 'Cherry' } +] + +type TokenData = { mention: string } + +const mentionMatcher = createRegexMatcher('mention', { + regex: /@\w+/g, + transform: (match) => ({ mention: match[0] }) +}) + +/** + * A fixture for testing mobile interactions with Inlay. + * Includes portal for testing touch-based portal navigation. + */ +export function MobileInlay({ + initial = '', + onSelect, + onKeyboardChange +}: { + initial?: string + onSelect?: (item: (typeof ITEMS)[number]) => void + onKeyboardChange?: (open: boolean) => void +}) { + const [selectedItem, setSelectedItem] = React.useState(null) + const [rawValue, setRawValue] = React.useState(initial) + const [keyboardOpen, setKeyboardOpen] = React.useState(false) + + const handleKeyboardChange = React.useCallback( + (open: boolean) => { + setKeyboardOpen(open) + onKeyboardChange?.(open) + }, + [onKeyboardChange] + ) + + const plugin: Plugin< + Record, + TokenData, + 'mention' + > = React.useMemo( + () => ({ + props: {}, + matcher: mentionMatcher, + render: ({ token }) => ( + + {token.mention} + + ), + portal: ({ replace }) => { + return ( + { + replace(`@${item.id} `) + setSelectedItem(item.label) + onSelect?.(item) + }} + data-testid="portal-list" + className="portal-list" + > + {ITEMS.map((item) => ( + + {item.label} + + ))} + + ) + }, + onInsert: () => {}, + onKeyDown: () => false + }), + [onSelect] + ) + + return ( + + + {selectedItem || 'none'} + {rawValue} + {keyboardOpen ? 'open' : 'closed'} + + ) +} + +/** + * Simple fixture for basic mobile touch tests without plugins. + */ +export function SimpleMobileInlay({ initial = '' }: { initial?: string }) { + const [value, setValue] = React.useState(initial) + + return ( + + + + User + + + {value} + + ) +} diff --git a/src/inlay/__ct__/fixtures/overlapping-plugins-inlay.tsx b/src/inlay/__ct__/fixtures/overlapping-plugins-inlay.tsx new file mode 100644 index 0000000..24f7beb --- /dev/null +++ b/src/inlay/__ct__/fixtures/overlapping-plugins-inlay.tsx @@ -0,0 +1,191 @@ +import React from 'react' +import { StructuredInlay } from '../../structured/structured-inlay' +import { createRegexMatcher } from '../../internal/string-utils' +import type { Plugin } from '../../structured/plugins/plugin' + +/** + * Plugin A: matches @test exactly + * Uses DIVERGED rendering: raw "@test" → visual "[A:test]" + */ +type PluginAData = { raw: string } + +function createPluginA(): Plugin { + return { + props: {}, + matcher: createRegexMatcher('pluginA', { + regex: /@test(?!\w)/g, + transform: (match) => ({ raw: match[0] }) + }), + render: ({ token }) => ( + + [A:test] + + ), + portal: () => null, + onInsert: () => {}, + onKeyDown: () => false + } +} + +/** + * Plugin B: also matches @test exactly (same range as Plugin A) + * Uses DIVERGED rendering: raw "@test" → visual "[B:test]" + * For exact-same-substring test: we can tell which plugin won by the visual + */ +type PluginBData = { raw: string } + +function createPluginB(): Plugin { + return { + props: {}, + matcher: createRegexMatcher('pluginB', { + regex: /@test(?!\w)/g, + transform: (match) => ({ raw: match[0] }) + }), + render: ({ token }) => ( + + [B:test] + + ), + portal: () => null, + onInsert: () => {}, + onKeyDown: () => false + } +} + +/** + * Plugin Short: matches @alice (shorter pattern) + * This creates a genuine overlap scenario with Plugin Long + * Raw "@alice" → visual "[short:alice]" + */ +type ShortData = { raw: string } + +function createShortPlugin(): Plugin { + return { + props: {}, + matcher: createRegexMatcher('short', { + // Matches @alice even when followed by more characters + regex: /@alice/g, + transform: (match) => ({ raw: match[0] }) + }), + render: ({ token }) => ( + + [short:alice] + + ), + portal: () => null, + onInsert: () => {}, + onKeyDown: () => false + } +} + +/** + * Plugin Long: matches @alice_vip (longer pattern, same start position) + * This should WIN over Short plugin when both match starting at same position + * Raw "@alice_vip" → visual "[long:alice_vip]" + */ +type LongData = { raw: string } + +function createLongPlugin(): Plugin { + return { + props: {}, + matcher: createRegexMatcher('long', { + regex: /@alice_vip/g, + transform: (match) => ({ raw: match[0] }) + }), + render: ({ token }) => ( + + [long:alice_vip] + + ), + portal: () => null, + onInsert: () => {}, + onKeyDown: () => false + } +} + +/** + * Plugin Hashtag: matches #hashtag pattern (for non-overlapping scenarios) + */ +type HashtagData = { raw: string } + +function createHashtagPlugin(): Plugin { + return { + props: {}, + matcher: createRegexMatcher('hashtag', { + regex: /#\w+/g, + transform: (match) => ({ raw: match[0] }) + }), + render: ({ token }) => ( + + {token.raw} + + ), + portal: () => null, + onInsert: () => {}, + onKeyDown: () => false + } +} + +/** + * Plugin Mention: matches @username (for non-overlapping scenarios) + */ +type MentionData = { raw: string } + +function createMentionPlugin(): Plugin { + return { + props: {}, + matcher: createRegexMatcher('mention', { + regex: /@\w+/g, + transform: (match) => ({ raw: match[0] }) + }), + render: ({ token }) => ( + + {token.raw} + + ), + portal: () => null, + onInsert: () => {}, + onKeyDown: () => false + } +} + +export type OverlapScenario = + | 'exact-same' // Two plugins match exact same @test - diverged renders let us see which won + | 'longer-wins' // @alice vs @alice_vip at same start position - longer should win + | 'non-overlapping' // @bob and #hashtag - both should render + +type Props = { + initial: string + scenario: OverlapScenario +} + +export function OverlappingPluginsInlay({ initial, scenario }: Props) { + const [value, setValue] = React.useState(initial) + + const plugins = React.useMemo(() => { + switch (scenario) { + case 'exact-same': + // Both plugins match @test at the same range + // Plugin A renders "[A:test]", Plugin B renders "[B:test]" + // We can tell which won by the visual output + return [createPluginA(), createPluginB()] as const + case 'longer-wins': + // Short matches "@alice" (6 chars), Long matches "@alice_vip" (10 chars) + // Both start at the same position, but Long is longer + // Long should win per longest-match-wins + return [createShortPlugin(), createLongPlugin()] as const + case 'non-overlapping': + // Different patterns that don't overlap + return [createMentionPlugin(), createHashtagPlugin()] as const + } + }, [scenario]) + + return ( + + ) +} diff --git a/src/inlay/__ct__/fixtures/portal-navigation-inlay.tsx b/src/inlay/__ct__/fixtures/portal-navigation-inlay.tsx new file mode 100644 index 0000000..eabd7b3 --- /dev/null +++ b/src/inlay/__ct__/fixtures/portal-navigation-inlay.tsx @@ -0,0 +1,92 @@ +import React from 'react' +import { Inlay } from '../../index' +import { createRegexMatcher } from '../../internal/string-utils' +import type { Plugin } from '../../structured/plugins/plugin' + +const ITEMS = [ + { id: '1', label: 'Apple' }, + { id: '2', label: 'Banana' }, + { id: '3', label: 'Cherry' } +] + +type TokenData = { mention: string; resolved?: boolean } + +const mentionMatcher = createRegexMatcher('mention', { + regex: /@\w+/g, // Use + to require at least one char after @ + transform: (match) => ({ mention: match[0] }) +}) + +/** + * A fixture for testing Inlay.Portal.List keyboard navigation. + * Uses Inlay.StructuredInlay with a mention-style plugin. + */ +export function PortalNavigationInlay({ + initial = '', + onSelect +}: { + initial?: string + onSelect?: (item: (typeof ITEMS)[number]) => void +}) { + const [selectedItem, setSelectedItem] = React.useState(null) + const [rawValue, setRawValue] = React.useState(initial) + + const plugin: Plugin< + Record, + TokenData, + 'mention' + > = React.useMemo( + () => ({ + props: {}, + matcher: mentionMatcher, + render: ({ token }) => ( + + {token.mention} + + ), + portal: ({ replace }) => { + // Always show portal for testing + return ( + { + replace(`@${item.id} `) + setSelectedItem(item.label) + setRawValue((prev) => prev.replace(/@\w*$/, `@${item.id} `)) + onSelect?.(item) + }} + data-testid="portal-list" + className="portal-list" + > + {ITEMS.map((item) => ( + + {item.label} + + ))} + + ) + }, + onInsert: () => {}, + onKeyDown: () => false + }), + [onSelect] + ) + + return ( + + + {selectedItem || 'none'} + {rawValue} + + ) +} diff --git a/src/inlay/__ct__/fixtures/structured-actions-inlay.tsx b/src/inlay/__ct__/fixtures/structured-actions-inlay.tsx new file mode 100644 index 0000000..54f3549 --- /dev/null +++ b/src/inlay/__ct__/fixtures/structured-actions-inlay.tsx @@ -0,0 +1,61 @@ +import React from 'react' +import { StructuredInlay } from '../../structured/structured-inlay' +import { createRegexMatcher } from '../../internal/string-utils' +import type { Plugin } from '../../structured/plugins/plugin' + +type TokenData = { raw: string; label?: string } + +/** + * Test fixture that exposes replace/update via buttons in the portal for easy testing + */ +export function StructuredActionsInlay({ initial }: { initial: string }) { + const [value, setValue] = React.useState(initial) + + const plugins: Plugin[] = React.useMemo( + () => [ + { + props: {}, + matcher: createRegexMatcher('mention', { + regex: /@\w+/g, + transform: (m) => ({ raw: m[0] }) + }), + render: ({ token }) => ( + {token.label ?? token.raw} + ), + portal: ({ replace, update }) => { + return ( + e.preventDefault()}> + update({ label: 'UpdatedLabel' })} + > + Update + + replace('@replaced')} + > + Replace + + + ) + }, + onInsert: () => {}, + onKeyDown: () => false + } + ], + [] + ) + + return ( + + + {value} + + ) +} diff --git a/src/inlay/__ct__/inlay.a11y.spec.tsx b/src/inlay/__ct__/inlay.a11y.spec.tsx new file mode 100644 index 0000000..06f5fcc --- /dev/null +++ b/src/inlay/__ct__/inlay.a11y.spec.tsx @@ -0,0 +1,51 @@ +import { test, expect } from '@playwright/experimental-ct-react' +import AxeBuilder from '@axe-core/playwright' +import { DivergedTokenInlay } from './fixtures/diverged-token-inlay' +import { Inlay } from '../..' + +test.describe('Inlay accessibility', () => { + test('empty state has no a11y violations', async ({ mount, page }) => { + await mount() + const results = await new AxeBuilder({ page }) + .include('[role="textbox"]') + .analyze() + expect(results.violations).toEqual([]) + }) + + test('with tokens has no a11y violations', async ({ mount, page }) => { + await mount() + const results = await new AxeBuilder({ page }) + .include('[role="textbox"]') + .analyze() + expect(results.violations).toEqual([]) + }) + + test('focused state has no a11y violations', async ({ mount, page }) => { + await mount() + await page.getByRole('textbox').focus() + const results = await new AxeBuilder({ page }) + .include('[role="textbox"]') + .analyze() + expect(results.violations).toEqual([]) + }) + + test('has default aria-label', async ({ mount, page }) => { + await mount( + + + + ) + const editor = page.getByRole('textbox') + await expect(editor).toHaveAttribute('aria-label', 'Text input') + }) + + test('aria-label can be overridden', async ({ mount, page }) => { + await mount( + + + + ) + const editor = page.getByRole('textbox') + await expect(editor).toHaveAttribute('aria-label', 'Message composer') + }) +}) diff --git a/src/inlay/__ct__/inlay.basic.spec.tsx b/src/inlay/__ct__/inlay.basic.spec.tsx new file mode 100644 index 0000000..b623d29 --- /dev/null +++ b/src/inlay/__ct__/inlay.basic.spec.tsx @@ -0,0 +1,23 @@ +import { test, expect } from '@playwright/experimental-ct-react' +import { Inlay } from '../' + +test('basic typing/backspace', async ({ mount, page }) => { + await mount( + + + + ) + + const root = page.getByTestId('root') + await expect(root).toHaveCount(1) + + const ed = page.getByRole('textbox') + await ed.click() + await page.keyboard.press('ArrowRight') + await page.keyboard.press('ArrowRight') + await page.keyboard.type('c') + await expect(ed).toHaveText('abc') + for (let i = 0; i < 10; i++) await page.keyboard.press('ArrowRight') + await page.keyboard.press('Backspace') + await expect(ed).toHaveText('ab') +}) diff --git a/src/inlay/__ct__/inlay.clipboard.spec.tsx b/src/inlay/__ct__/inlay.clipboard.spec.tsx new file mode 100644 index 0000000..8f03867 --- /dev/null +++ b/src/inlay/__ct__/inlay.clipboard.spec.tsx @@ -0,0 +1,216 @@ +import { test, expect } from '@playwright/experimental-ct-react' +import { DivergedTokenInlay } from './fixtures/diverged-token-inlay' +import { AutoUpdateInlay } from './fixtures/auto-update-inlay' + +// Run clipboard tests serially to avoid clipboard state pollution between parallel tests +test.describe.serial('Clipboard operations with diverged tokens (CT)', () => { + // Skip on mobile-safari: WebKit's mobile emulation has different clipboard behavior + // that causes copy/paste operations to behave unexpectedly. Desktop webkit passes. + test.beforeEach(async ({}, testInfo) => { + test.skip( + testInfo.project.name === 'mobile-safari', + 'Skipped on mobile-safari: clipboard behavior differs in mobile WebKit emulation' + ) + }) + test('Select all (Ctrl/Cmd+a) selects entire content', async ({ + mount, + page + }) => { + await mount() + + const ed = page.getByRole('textbox') + await ed.click() + await expect(ed).toHaveText('Hello World') + + await page.keyboard.press('ControlOrMeta+a') + await page.keyboard.press('Backspace') + await expect(ed).toHaveText('') + }) + + test('Copy and paste a diverged token preserves raw value', async ({ + mount, + page + }) => { + await mount() + + const ed = page.getByRole('textbox') + await ed.click() + await expect(ed).toHaveText('Hello Alice!') + + // Navigate to token: start → right 6 (past "Hello ") → select token + for (let i = 0; i < 20; i++) await page.keyboard.press('ArrowLeft') + for (let i = 0; i < 6; i++) await page.keyboard.press('ArrowRight') + await page.keyboard.press('Shift+ArrowRight') + + await page.keyboard.press('ControlOrMeta+c') + + // Move to end and paste + for (let i = 0; i < 20; i++) await page.keyboard.press('ArrowRight') + await page.keyboard.press('ControlOrMeta+v') + + await expect(ed).toHaveText('Hello Alice!Alice') + await expect(ed.locator('[data-token-text="@alice"]')).toHaveCount(2) + }) + + test('Copy text containing diverged token includes raw value', async ({ + mount, + page + }) => { + await mount() + + const ed = page.getByRole('textbox') + await ed.click() + await expect(ed).toHaveText('AAliceB') + + await page.keyboard.press('ControlOrMeta+a') + await page.keyboard.press('ControlOrMeta+c') + + // Clear and type X + await page.keyboard.press('Backspace') + await expect(ed).toHaveText('') + await page.keyboard.type('X') + await expect(ed).toHaveText('X') + + // Paste at end + await page.keyboard.press('ArrowRight') + await page.keyboard.press('ControlOrMeta+v') + + await expect(ed).toHaveText('XAAliceB') + await expect(ed.locator('[data-token-text="@alice"]')).toHaveCount(1) + }) + + test('Cut diverged token removes it and pastes raw value', async ({ + mount, + page + }) => { + await mount() + + const ed = page.getByRole('textbox') + await ed.click() + await expect(ed).toHaveText('XAliceY') + + // Navigate past X and select token + for (let i = 0; i < 20; i++) await page.keyboard.press('ArrowLeft') + await page.keyboard.press('ArrowRight') + await page.keyboard.press('Shift+ArrowRight') + + await page.keyboard.press('ControlOrMeta+x') + + await expect(ed).toHaveText('XY') + await expect(ed.locator('[data-token-text="@alice"]')).toHaveCount(0) + + // Paste at end + for (let i = 0; i < 20; i++) await page.keyboard.press('ArrowRight') + await page.keyboard.press('ControlOrMeta+v') + + await expect(ed).toHaveText('XYAlice') + await expect(ed.locator('[data-token-text="@alice"]')).toHaveCount(1) + }) + + test('Paste plain text into editor works correctly', async ({ + mount, + page + }) => { + await mount() + + const ed = page.getByRole('textbox') + await ed.click() + + // Move to end + for (let i = 0; i < 20; i++) await page.keyboard.press('ArrowRight') + + // Write to clipboard and paste using real keyboard shortcut + await page.evaluate(() => navigator.clipboard.writeText('world')) + await page.keyboard.press('ControlOrMeta+v') + + await expect(ed).toHaveText('Hello world') + }) + + test('Paste text that matches token pattern creates new token', async ({ + mount, + page + }) => { + await mount() + + const ed = page.getByRole('textbox') + await ed.click() + + // Move to end + for (let i = 0; i < 20; i++) await page.keyboard.press('ArrowRight') + + // Write to clipboard and paste using real keyboard shortcut + await page.evaluate(() => navigator.clipboard.writeText('@alice')) + await page.keyboard.press('ControlOrMeta+v') + + await expect(ed.locator('[data-token-text="@alice"]')).toHaveCount(1) + await expect(ed).toHaveText('Hi Alice') + }) + + test('Rapid paste maintains caret at end of inserted text', async ({ + mount, + page + }) => { + await mount() + + const ed = page.getByRole('textbox') + await ed.click() + + // Write text to clipboard + await page.evaluate(() => navigator.clipboard.writeText('abc')) + + // Simulate rapid pasting (like holding Ctrl+V) + // Fire multiple paste events in quick succession without waiting + const pasteCount = 5 + for (let i = 0; i < pasteCount; i++) { + await page.keyboard.press('ControlOrMeta+v', { delay: 0 }) + } + + // Expected: "abcabcabcabcabc" with caret at position 15 (end) + await expect(ed).toHaveText('abcabcabcabcabc') + + // Type a character to verify caret position - should appear at the end + await page.keyboard.type('X') + await expect(ed).toHaveText('abcabcabcabcabcX') + }) + + test('Pasting many auto-updating tokens does not cause infinite loop', async ({ + mount, + page + }) => { + // Capture React errors (error #185 = Maximum update depth exceeded) + const errors: string[] = [] + page.on('pageerror', (err) => errors.push(err.message)) + + await mount() + + const ed = page.getByRole('textbox') + await ed.click() + + // Paste many tokens that will each trigger an update() call + const manyTokens = Array(100).fill('@test').join(' ') + ' ' + await page.evaluate( + (text) => navigator.clipboard.writeText(text), + manyTokens + ) + await page.keyboard.press('ControlOrMeta+v') + + // Wait for all updates to process + await page.waitForTimeout(500) + + // Should not have crashed with "Maximum update depth exceeded" + expect(errors).toHaveLength(0) + + // Should render exactly 100 tokens (no duplicates) in the visible editor + // Note: 2-pass rendering means tokens exist in both hidden and visible divs + const editor = page.getByRole('textbox') + await expect(editor.locator('[data-testid="token-render"]')).toHaveCount( + 100, + { timeout: 5000 } + ) + + // Caret should be at end after all updates, not position 0 + await page.keyboard.type('X') + const text = await editor.textContent() + expect(text?.endsWith('X')).toBe(true) + }) +}) diff --git a/src/inlay/__ct__/inlay.composition.cdp.spec.tsx b/src/inlay/__ct__/inlay.composition.cdp.spec.tsx new file mode 100644 index 0000000..63fffcb --- /dev/null +++ b/src/inlay/__ct__/inlay.composition.cdp.spec.tsx @@ -0,0 +1,78 @@ +import { test, expect, type Locator } from '@playwright/experimental-ct-react' +import { Inlay } from '../' + +// Chromium-only: use CDP to simulate IME composition end-to-end +const composeWithCDP = async ( + page: import('@playwright/test').Page, + text: string +) => { + const client = await page.context().newCDPSession(page) + await client.send('Input.imeSetComposition', { + text, + selectionStart: text.length, + selectionEnd: text.length + }) + await client.send('Input.insertText', { text }) + await client.send('Input.imeSetComposition', { + text: '', + selectionStart: 0, + selectionEnd: 0 + }) +} + +// Check that composition didn't leave broken DOM structure (multiple text nodes, etc.) +async function assertCleanTextContent(ed: Locator): Promise { + return ed.evaluate((el: HTMLElement) => { + // The editor should not have stray elements or deeply nested structures + // after composition. Text content check is the primary validation. + const brs = el.querySelectorAll('br') + return brs.length === 0 + }) +} + +test.describe.serial('IME composition via CDP (Chromium)', () => { + test.skip( + ({ browserName }) => browserName !== 'chromium', + 'CDP IME APIs are Chromium-only' + ) + + test('Space commit produces composed text and a trailing space', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + const ed = page.getByRole('textbox') + await ed.click() + + await composeWithCDP(page, 'にほん') + await page.keyboard.press('Space') + + await expect(ed).toHaveText('にほん ') + const ok = await assertCleanTextContent(ed) + expect(ok).toBe(true) + }) + + test('Enter commit composes text and does not add a stray newline immediately', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + const ed = page.getByRole('textbox') + await ed.click() + + await composeWithCDP(page, 'テスト') + await page.keyboard.press('Enter') + + await expect(ed).toHaveText('テスト') + const ok = await assertCleanTextContent(ed) + expect(ok).toBe(true) + }) +}) diff --git a/src/inlay/__ct__/inlay.grapheme.spec.tsx b/src/inlay/__ct__/inlay.grapheme.spec.tsx new file mode 100644 index 0000000..7e73627 --- /dev/null +++ b/src/inlay/__ct__/inlay.grapheme.spec.tsx @@ -0,0 +1,149 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { test, expect } from '@playwright/experimental-ct-react' +import { Inlay } from '../' + +test.describe('Grapheme handling (CT)', () => { + test('Backspace deletes an entire emoji grapheme cluster', async ({ + mount, + page + }) => { + const cluster = '👍🏼' + await mount( + + + + ) + + page.on('console', (msg) => console.log('EMOJI TEST LOG:', msg.text())) + + const ed = page.getByRole('textbox') + await ed.click() + // Normalize caret to start by overshooting ArrowLeft + for (let i = 0; i < 10; i++) await page.keyboard.press('ArrowLeft') + // Move caret to end using ArrowRight (cluster is one grapheme) + await page.keyboard.press('ArrowRight') + + await page.keyboard.press('Backspace') + await expect(ed).toHaveText('') + }) + + test('Backspace with selection slicing through a grapheme removes the whole grapheme', async ({ + mount, + page + }) => { + const cluster = '👍🏼' + const text = `a${cluster}b` + await mount( + + + + ) + + const ed = page.getByRole('textbox') + await ed.click() + // Normalize caret to start by overshooting ArrowLeft + for (let i = 0; i < 10; i++) await page.keyboard.press('ArrowLeft') + // Move to just before the grapheme (after the initial 'a') + await page.keyboard.press('ArrowRight') + // Extend selection across exactly one grapheme + await page.keyboard.press('Shift+ArrowRight') + + await page.keyboard.press('Backspace') + await expect(ed).toHaveText('ab') + }) + + test('Backspace deletes entire flag grapheme (regional indicators) before caret', async ({ + mount, + page + }) => { + const flag = '🇺🇸' + await mount( + + + + ) + + const ed = page.getByRole('textbox') + await ed.click() + // Normalize caret to start by overshooting ArrowLeft + for (let i = 0; i < 10; i++) await page.keyboard.press('ArrowLeft') + // Move to after 'a' and then after the flag grapheme + await page.keyboard.press('ArrowRight') + await page.keyboard.press('ArrowRight') + + await page.keyboard.press('Backspace') + await expect(ed).toHaveText('ab') + }) + + test('Backspace deletes composed character with combining mark as a single grapheme', async ({ + mount, + page + }) => { + const composed = 'e\u0301' + await mount( + + + + ) + + const ed = page.getByRole('textbox') + await ed.click() + // Move cursor to end + await page.keyboard.press('End') + + // Capture page console logs + page.on('console', (msg) => console.log('PAGE:', msg.text())) + + // Test log to verify capturing works + await page.evaluate(() => console.log('TEST LOG FROM PAGE')) + + // Check if React's onBeforeInput is attached + await page.evaluate(() => { + const editor = document.querySelector('[role="textbox"]') as HTMLElement + const reactKey = Object.keys(editor).find((k) => + k.startsWith('__reactProps$') + ) + if (reactKey) { + const props = (editor as any)[reactKey] + console.log( + 'React props keys:', + Object.keys(props || {}).filter((k) => + k.toLowerCase().includes('input') + ) + ) + console.log('Has onBeforeInput:', typeof props?.onBeforeInput) + } else { + console.log('No React props found') + } + }) + + // Check value before backspace + const valueBefore = await page.evaluate(() => { + const editor = document.querySelector('[role="textbox"]') as HTMLElement + return { + textContent: editor.textContent, + innerHTML: editor.innerHTML, + charCodes: Array.from(editor.textContent || '').map((c) => + c.charCodeAt(0) + ) + } + }) + console.log('Before backspace:', JSON.stringify(valueBefore)) + + await page.keyboard.press('Backspace') + + // Check what the actual text content is + const valueAfter = await page.evaluate(() => { + const editor = document.querySelector('[role="textbox"]') as HTMLElement + return { + textContent: editor.textContent, + charCodes: Array.from(editor.textContent || '').map((c) => + c.charCodeAt(0) + ) + } + }) + console.log('After backspace:', JSON.stringify(valueAfter)) + + await expect(ed).toHaveText('') + }) +}) diff --git a/src/inlay/__ct__/inlay.ios-swipe-text.spec.tsx b/src/inlay/__ct__/inlay.ios-swipe-text.spec.tsx new file mode 100644 index 0000000..d7901dd --- /dev/null +++ b/src/inlay/__ct__/inlay.ios-swipe-text.spec.tsx @@ -0,0 +1,1394 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { test, expect } from '@playwright/experimental-ct-react' +import { Inlay } from '../' + +/** + * iOS Swipe-Text Bug Reproduction + * + * THE BUG (from real iOS device testing): + * When user swipe-types "hello" and presses backspace ONCE: + * 1. iOS fires a SINGLE insertText event with data="hello" (whole word) + * 2. iOS fires a SINGLE deleteContentBackward event with targetRanges=4-5 (last char only!) + * 3. If we DON'T preventDefault, iOS fires 5 rapid deleteContentBackward events + * and natively deletes the whole word + * 4. But if we DO preventDefault on the first event, iOS stops sending the + * remaining events, so we only see the range for the last char + * + * EXPECTED: Entire swipe-typed word deleted + * ACTUAL BUG: Only last char deleted, "hello" -> "hell" + * + * THE FIX: Track multi-char inserts, and if deleteContentBackward happens + * immediately after at the end of the inserted chunk, delete the whole chunk. + * + * SPACE PRESERVATION: When swipe-typing after existing text, iOS inserts + * " word" (with leading space). On backspace, only "word" should be deleted, + * preserving the auto-inserted space. + * + * NOTE: These tests use synthetic events to simulate iOS/Android native IME behavior. + * They pass on desktop browsers but fail on Playwright's mobile-safari emulation + * because WebKit in mobile touch mode has different event handling. The mobile-safari + * project is NOT real iOS Safari - it's desktop WebKit with iPhone viewport/user-agent. + * Real iOS testing requires actual devices or cloud device farms. + */ + +test.describe('iOS swipe-text bug', () => { + // Skip on mobile-safari: Playwright's mobile-safari is desktop WebKit with mobile viewport, + // not real iOS Safari. It has bugs with keyboard input (text reversal) and synthetic events. + test.beforeEach(async ({}, testInfo) => { + test.skip( + testInfo.project.name === 'mobile-safari', + 'Skipped on mobile-safari: cannot emulate iOS native IME behavior' + ) + }) + + /** + * Test that input works after deleting content. + * Verifies no crash when typing after deletion. + */ + test('input after deleting swipe-typed word should not crash', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + await page.keyboard.press('End') + await expect(ed).toHaveText('hello') + + // Delete all characters with delay between presses + for (let i = 0; i < 5; i++) { + await page.keyboard.press('Backspace') + await page.waitForTimeout(30) + } + + // Should be empty (or have zero-width space) + await expect(ed).toHaveText(/^[\u200B]?$/) + + // Wait for selection to settle + await page.waitForTimeout(50) + + // Type new text - should not crash + await page.keyboard.type('world', { delay: 30 }) + + await expect(ed).toHaveText('world') + }) + + /** + * Test that selection updates work correctly even after DOM nodes are replaced. + * This tests the robustness of setDomSelection when React re-renders. + */ + test('setDomSelection should not crash when nodes are replaced', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + await page.keyboard.press('End') + await expect(ed).toHaveText('hello') + + // Delete all characters - this causes DOM nodes to be replaced + for (let i = 0; i < 5; i++) { + await page.keyboard.press('Backspace') + await page.waitForTimeout(30) + } + + // Should be empty + await expect(ed).toHaveText(/^[\u200B]?$/) + + // Wait for selection to settle + await page.waitForTimeout(50) + + // Insert new text - should work without crash + await page.keyboard.type('x') + + await expect(ed).toHaveText('x') + }) + + /** + * CRASH BUG VARIANT: Selection points outside editor after deletion. + * On iOS, after rapid deletions the selection may end up pointing to + * a node that's no longer in the editor, causing getAbsoluteOffset to fail. + */ + test('insert should handle selection outside editor gracefully', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + await page.keyboard.press('End') + + const result = await page.evaluate(async () => { + const editor = document.querySelector('[role="textbox"]') as HTMLElement + + const reactPropsKey = Object.keys(editor).find((k) => + k.startsWith('__reactProps$') + ) + if (!reactPropsKey) return { error: 'No React props' } + + const reactProps = (editor as any)[reactPropsKey] + const onBeforeInput = reactProps?.onBeforeInput + if (!onBeforeInput) return { error: 'No onBeforeInput' } + + // Force selection to be outside the editor (simulating iOS bug) + const bodyTextNode = document.createTextNode('outside') + document.body.appendChild(bodyTextNode) + const sel = window.getSelection() + sel?.removeAllRanges() + const badRange = document.createRange() + badRange.setStart(bodyTextNode, 0) + badRange.setEnd(bodyTextNode, 0) + sel?.addRange(badRange) + + let insertError: string | undefined + + const insertEvent = { + type: 'beforeinput', + inputType: 'insertText', + data: 'x', + preventDefault: () => {}, + stopPropagation: () => {}, + nativeEvent: { + type: 'beforeinput', + inputType: 'insertText', + data: 'x', + getTargetRanges: () => [] + } + } + + try { + onBeforeInput(insertEvent) + } catch (e) { + insertError = String(e) + } + + // Cleanup + document.body.removeChild(bodyTextNode) + + await new Promise((r) => setTimeout(r, 50)) + + return { + insertError, + finalText: editor.textContent + } + }) + + console.log('Result:', JSON.stringify(result, null, 2)) + + // Should NOT crash - should handle gracefully + expect(result.insertError).toBeUndefined() + }) + + /** + * iOS VALUE DIVERGENCE BUG: + * After swipe-typing and pressing backspace, the React value state + * must stay in sync with the DOM. + * + * This test verifies that backspace via native beforeinput correctly + * updates the value even when coming from swipe-text scenarios. + */ + test('backspace via beforeinput after insert should update value', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + + // Ensure we're at the end of the content + await page.keyboard.press('End') + await expect(ed).toHaveText('hello') + + // Press backspace - this fires native beforeinput which our handler catches + await page.keyboard.press('Backspace') + + // Value should update from "hello" to "hell" + await expect(ed).toHaveText('hell') + }) + + /** + * iOS Safari crash: backspace then swipe-type causes "The object can not be found here" + * + * This test verifies that pressing backspace then typing doesn't crash + * after DOM updates from deletion. + */ + test('iOS Safari: backspace then textInput should not crash', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + await page.keyboard.press('End') + + // Step 1: Backspace to delete "o" + await page.keyboard.press('Backspace') + await expect(ed).toHaveText('hell') + + // Wait for selection to settle after React re-render + await page.waitForTimeout(50) + + // Step 2: Type new text (one char at a time with delay to avoid race) + await page.keyboard.type('world', { delay: 30 }) + + // Should have backspaced then typed + await expect(ed).toHaveText('hellworld') + }) + + /** + * Test that text insertion works correctly via real browser events. + * Note: iOS Safari's legacy textInput event is handled by the native + * beforeinput listener on real iOS devices. + */ + test('iOS Safari textInput event should insert text', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + + // Type text using real browser events + await page.keyboard.type('hello') + + // Text should be inserted + await expect(ed).toHaveText('hello') + }) + + test('swipe-text after backspace should not crash', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + await page.keyboard.press('End') + await expect(ed).toHaveText('hello') + + // Delete all characters with backspace (with delay to avoid race conditions) + for (let i = 0; i < 5; i++) { + await page.keyboard.press('Backspace') + await page.waitForTimeout(30) + } + + // Wait for deletions to complete + await expect(ed).toHaveText(/^[\u200B]?$/) // empty or zero-width space + + // Wait for selection to settle + await page.waitForTimeout(50) + + // Now type new text + await page.keyboard.type('world', { delay: 30 }) + + // Text should be "world" after inserting + await expect(ed).toHaveText('world') + }) + + /** + * iOS swipe-text word deletion - ACTUAL BEHAVIOR (from real device testing): + * + * When user swipe-types "hello" and presses backspace ONCE: + * 1. iOS fires a SINGLE insertText event with data="hello" (whole word) + * 2. iOS fires a SINGLE deleteContentBackward event with targetRanges=4-5 (last char only!) + * 3. BUT if we don't preventDefault, iOS natively deletes the whole word + * + * The bug: When we preventDefault and handle it ourselves, we only see the + * targetRange for the last character and delete only that character. + * + * The fix (Option 2): Track when a multi-char insert happens, and if + * deleteContentBackward is pressed immediately after at the end of that + * inserted chunk, delete the whole chunk. + */ + test('iOS swipe-text: backspace after swipe-typed word should delete entire word', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + + // Simulate iOS swipe-text behavior + const result = await page.evaluate(async () => { + const editor = document.querySelector('[role="textbox"]') as HTMLElement + + // Step 1: Simulate swipe-typing "hello" - iOS sends a single insertText with the whole word + const insertEvent = new InputEvent('beforeinput', { + inputType: 'insertText', + data: 'hello', + bubbles: true, + cancelable: true + }) + + // Set up a collapsed range at position 0 for the insertion point + const sel = window.getSelection() + if (sel && sel.rangeCount > 0) { + const range = sel.getRangeAt(0) + ;(insertEvent as any).getTargetRanges = () => [ + { + startContainer: range.startContainer, + startOffset: range.startOffset, + endContainer: range.endContainer, + endOffset: range.endOffset, + collapsed: true + } + ] + } else { + ;(insertEvent as any).getTargetRanges = () => [] + } + + editor.dispatchEvent(insertEvent) + + // Wait for React to process the insert + await new Promise((r) => setTimeout(r, 50)) + + const afterInsert = editor.textContent?.replace(/\u200B/g, '') + + // Step 2: Simulate pressing backspace - iOS sends ONE deleteContentBackward + // with targetRange covering only the LAST character (4-5), NOT the whole word + const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT) + let textNode: Text | null = null + while (walker.nextNode()) { + const node = walker.currentNode as Text + if (node.textContent && node.textContent.length > 0) { + textNode = node + break + } + } + + if (!textNode) { + return { error: 'No text node found after insert', afterInsert } + } + + const deleteEvent = new InputEvent('beforeinput', { + inputType: 'deleteContentBackward', + bubbles: true, + cancelable: true + }) + + // iOS only provides targetRange for the LAST character, not the whole word! + // This is the key part of the bug - even though iOS would natively delete + // the whole word, the beforeinput event only reports the last char range. + const textLen = textNode.textContent?.length || 0 + ;(deleteEvent as any).getTargetRanges = () => [ + { + startContainer: textNode, + startOffset: textLen - 1, // e.g., 4 for "hello" + endContainer: textNode, + endOffset: textLen, // e.g., 5 for "hello" + collapsed: false + } + ] + + editor.dispatchEvent(deleteEvent) + + // Wait for React to process the delete + await new Promise((r) => setTimeout(r, 50)) + + return { + afterInsert, + finalText: editor.textContent, + finalLength: editor.textContent?.replace(/\u200B/g, '').length + } + }) + + console.log('Result:', JSON.stringify(result, null, 2)) + + // Verify the insert worked + expect(result.afterInsert).toBe('hello') + + // EXPECTED: Entire swipe-typed word should be deleted + // ACTUAL BUG: Only last char deleted, "hello" -> "hell" + expect(result.finalLength).toBe(0) + }) + + /** + * iOS swipe-text space preservation: + * + * When user has "hello|" and swipe-types "world", iOS inserts " world" (with leading space). + * When backspace is pressed, iOS native deletes only "world" and preserves the space. + * Result should be "hello " (with trailing space), not "hello" (no space). + */ + test('iOS swipe-text: backspace after swipe-typed word should preserve auto-inserted space', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + await page.keyboard.press('End') + await expect(ed).toHaveText('hello') + + // Simulate iOS swipe-text behavior with leading space + const result = await page.evaluate(async () => { + const editor = document.querySelector('[role="textbox"]') as HTMLElement + + // Step 1: Simulate swipe-typing " world" (with leading space) after "hello" + // iOS automatically adds a space when swipe-typing after existing text + const insertEvent = new InputEvent('beforeinput', { + inputType: 'insertText', + data: ' world', // Note the leading space! + bubbles: true, + cancelable: true + }) + + const sel = window.getSelection() + if (sel && sel.rangeCount > 0) { + const range = sel.getRangeAt(0) + ;(insertEvent as any).getTargetRanges = () => [ + { + startContainer: range.startContainer, + startOffset: range.startOffset, + endContainer: range.endContainer, + endOffset: range.endOffset, + collapsed: true + } + ] + } else { + ;(insertEvent as any).getTargetRanges = () => [] + } + + editor.dispatchEvent(insertEvent) + + // Wait for React to process the insert + await new Promise((r) => setTimeout(r, 50)) + + const afterInsert = editor.textContent?.replace(/\u200B/g, '') + + // Step 2: Simulate pressing backspace + const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT) + let textNode: Text | null = null + while (walker.nextNode()) { + const node = walker.currentNode as Text + if (node.textContent && node.textContent.length > 0) { + textNode = node + break + } + } + + if (!textNode) { + return { error: 'No text node found after insert', afterInsert } + } + + const deleteEvent = new InputEvent('beforeinput', { + inputType: 'deleteContentBackward', + bubbles: true, + cancelable: true + }) + + // iOS only provides targetRange for the last character + const textLen = textNode.textContent?.length || 0 + ;(deleteEvent as any).getTargetRanges = () => [ + { + startContainer: textNode, + startOffset: textLen - 1, + endContainer: textNode, + endOffset: textLen, + collapsed: false + } + ] + + editor.dispatchEvent(deleteEvent) + + // Wait for React to process the delete + await new Promise((r) => setTimeout(r, 50)) + + return { + afterInsert, + finalText: editor.textContent?.replace(/\u200B/g, ''), + // Check if trailing space is preserved + hasTrailingSpace: editor.textContent + ?.replace(/\u200B/g, '') + .endsWith(' ') + } + }) + + console.log('Result:', JSON.stringify(result, null, 2)) + + // Verify the insert worked (including the space) + expect(result.afterInsert).toBe('hello world') + + // EXPECTED: "world" deleted but space preserved -> "hello " + // ACTUAL BUG: Both space and word deleted -> "hello" + expect(result.finalText).toBe('hello ') + expect(result.hasTrailingSpace).toBe(true) + }) +}) + +/** + * iOS Safari Text Suggestion Bug + * + * THE BUG (from real iOS device testing): + * When user types "hel" and taps a keyboard suggestion "hello": + * 1. iOS fires insertReplacementText with data=null but dataTransfer="hello" + * 2. Our code checks `if (!data) return` and bails out + * 3. iOS then fires insertText with data=" " to insert a space + * 4. Result: "hel" becomes " " instead of "hello" + * + * EXPECTED: "hel" → "hello" (the suggested word) + * ACTUAL BUG: "hel" → " " (just a space) + * + * THE FIX: Check event.dataTransfer.getData('text/plain') as fallback when data is null + */ +test.describe('iOS Safari text suggestion', () => { + // Skip on mobile-safari: cannot emulate iOS native IME behavior + test.beforeEach(async ({}, testInfo) => { + test.skip( + testInfo.project.name === 'mobile-safari', + 'Skipped on mobile-safari: cannot emulate iOS native IME behavior' + ) + }) + + /** + * iOS Safari sends insertReplacementText with the replacement text in + * dataTransfer instead of data (unlike Android GBoard which uses data). + * + * Note: This test is skipped on webkit/mobile-safari because the test environment + * doesn't support passing DataTransfer to InputEvent constructor. The fix has been + * verified on real iOS Safari devices via console logging. + */ + test('tapping a keyboard suggestion should replace in-progress word', async ({ + mount, + page, + browserName + }) => { + // Skip webkit in test environment - DataTransfer constructor doesn't work the same way + // Real iOS Safari behavior verified via console logging on actual device + test.skip( + browserName === 'webkit', + 'webkit test env does not support DataTransfer in InputEvent constructor' + ) + + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + + // Step 1: Type "hel" (simulating user typing before suggestion) + await page.keyboard.type('hel') + await expect(ed).toHaveText('hel') + + // Step 2: Simulate iOS Safari text suggestion tap + // iOS sends insertReplacementText with data=null and replacement in dataTransfer + const result = await page.evaluate(async () => { + const editor = document.querySelector('[role="textbox"]') as HTMLElement + + // Find the text node containing "hel" + const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT) + let textNode: Text | null = null + while (walker.nextNode()) { + const node = walker.currentNode as Text + if (node.textContent && node.textContent.includes('hel')) { + textNode = node + break + } + } + + if (!textNode) { + return { + error: 'No text node found', + editorContent: editor.textContent + } + } + + // Create a mock DataTransfer with the suggestion text + const dataTransfer = new DataTransfer() + dataTransfer.setData('text/plain', 'hello') + + // Create the insertReplacementText event (iOS Safari style) + const replaceEvent = new InputEvent('beforeinput', { + inputType: 'insertReplacementText', + data: null, // iOS Safari puts null here! + dataTransfer: dataTransfer, + bubbles: true, + cancelable: true + }) + + // Set up getTargetRanges to return the range to replace ("hel" at 0-3) + const textContent = textNode.textContent || '' + const helStart = textContent.indexOf('hel') + ;(replaceEvent as any).getTargetRanges = () => [ + { + startContainer: textNode, + startOffset: helStart >= 0 ? helStart : 0, + endContainer: textNode, + endOffset: helStart >= 0 ? helStart + 3 : 3, + collapsed: false + } + ] + + editor.dispatchEvent(replaceEvent) + + // Wait for React to process + await new Promise((r) => setTimeout(r, 50)) + + return { + finalText: editor.textContent?.replace(/\u200B/g, ''), + wasReplaced: editor.textContent?.includes('hello') + } + }) + + console.log('iOS text suggestion result:', JSON.stringify(result, null, 2)) + + // EXPECTED: "hel" should be replaced with "hello" + // ACTUAL BUG: "hel" remains unchanged because we bail out when data is null + expect(result.error).toBeUndefined() + expect(result.finalText).toBe('hello') + expect(result.wasReplaced).toBe(true) + }) + + /** + * Verify that Android-style insertReplacementText (with data in event.data) + * still works after the iOS fix. + */ + test('Android GBoard style suggestion should still work', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + + // Type "hel" + await page.keyboard.type('hel') + await expect(ed).toHaveText('hel') + + // Simulate Android GBoard style - data is in event.data (not dataTransfer) + const result = await page.evaluate(async () => { + const editor = document.querySelector('[role="textbox"]') as HTMLElement + + const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT) + let textNode: Text | null = null + while (walker.nextNode()) { + const node = walker.currentNode as Text + if (node.textContent && node.textContent.includes('hel')) { + textNode = node + break + } + } + + if (!textNode) { + return { error: 'No text node found' } + } + + // Android style: data is in event.data + const replaceEvent = new InputEvent('beforeinput', { + inputType: 'insertReplacementText', + data: 'hello', // Android puts the replacement here + bubbles: true, + cancelable: true + }) + + const textContent = textNode.textContent || '' + const helStart = textContent.indexOf('hel') + ;(replaceEvent as any).getTargetRanges = () => [ + { + startContainer: textNode, + startOffset: helStart >= 0 ? helStart : 0, + endContainer: textNode, + endOffset: helStart >= 0 ? helStart + 3 : 3, + collapsed: false + } + ] + + editor.dispatchEvent(replaceEvent) + + await new Promise((r) => setTimeout(r, 50)) + + return { + finalText: editor.textContent?.replace(/\u200B/g, '') + } + }) + + expect(result.error).toBeUndefined() + expect(result.finalText).toBe('hello') + }) +}) + +/** + * iOS Swipe-Text Trailing Space Bug + * + * THE BUG (from real iOS device testing): + * When user swipe-types a word, iOS sends: + * 1. insertText with the swiped word (e.g., "hello") - multi-char, we track it + * 2. insertText with a single space " " - this CLEARS our tracking! + * 3. User presses backspace - tracking is null, so we only delete one char + * + * EXPECTED: Backspace after swipe+space should delete the swiped word + * ACTUAL BUG: Only deletes the trailing space because tracking was cleared + * + * THE FIX: When a single space follows a multi-char insert within a short + * time window, extend the tracking to include the space instead of clearing it. + */ +test.describe('iOS swipe-text trailing space', () => { + // Skip on mobile-safari: cannot emulate iOS native IME behavior + test.beforeEach(async ({}, testInfo) => { + test.skip( + testInfo.project.name === 'mobile-safari', + 'Skipped on mobile-safari: cannot emulate iOS native IME behavior' + ) + }) + + /** + * iOS sends a trailing space after swipe-typing, which clears our tracking. + * Backspace should still delete the whole swiped word. + */ + test('backspace after swipe + trailing space should delete swiped word', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + + const result = await page.evaluate(async () => { + const editor = document.querySelector('[role="textbox"]') as HTMLElement + + // Step 1: Simulate swipe-typing "hello" (multi-char insert) + const swipeEvent = new InputEvent('beforeinput', { + inputType: 'insertText', + data: 'hello', + bubbles: true, + cancelable: true + }) + + const sel = window.getSelection() + if (sel && sel.rangeCount > 0) { + const range = sel.getRangeAt(0) + ;(swipeEvent as any).getTargetRanges = () => [ + { + startContainer: range.startContainer, + startOffset: range.startOffset, + endContainer: range.endContainer, + endOffset: range.endOffset, + collapsed: true + } + ] + } else { + ;(swipeEvent as any).getTargetRanges = () => [] + } + + editor.dispatchEvent(swipeEvent) + await new Promise((r) => setTimeout(r, 30)) + + const afterSwipe = editor.textContent?.replace(/\u200B/g, '') + + // Step 2: iOS sends a trailing space as a SEPARATE single-char event + // This is the bug trigger - it clears our multi-char tracking! + const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT) + let textNode: Text | null = null + while (walker.nextNode()) { + const node = walker.currentNode as Text + if (node.textContent && node.textContent.length > 0) { + textNode = node + break + } + } + + if (!textNode) { + return { error: 'No text node found after swipe', afterSwipe } + } + + const spaceEvent = new InputEvent('beforeinput', { + inputType: 'insertText', + data: ' ', // Single space - this triggers the bug! + bubbles: true, + cancelable: true + }) + + const textLen = textNode.textContent?.length || 0 + ;(spaceEvent as any).getTargetRanges = () => [ + { + startContainer: textNode, + startOffset: textLen, + endContainer: textNode, + endOffset: textLen, + collapsed: true + } + ] + + editor.dispatchEvent(spaceEvent) + await new Promise((r) => setTimeout(r, 30)) + + const afterSpace = editor.textContent?.replace(/\u200B/g, '') + + // Step 3: Press backspace - should delete the whole swiped word + // Find the text node again after React re-render + const walker2 = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT) + let textNode2: Text | null = null + while (walker2.nextNode()) { + const node = walker2.currentNode as Text + if (node.textContent && node.textContent.length > 0) { + textNode2 = node + break + } + } + + if (!textNode2) { + return { + error: 'No text node found after space', + afterSwipe, + afterSpace + } + } + + const deleteEvent = new InputEvent('beforeinput', { + inputType: 'deleteContentBackward', + bubbles: true, + cancelable: true + }) + + const textLen2 = textNode2.textContent?.length || 0 + ;(deleteEvent as any).getTargetRanges = () => [ + { + startContainer: textNode2, + startOffset: textLen2 - 1, + endContainer: textNode2, + endOffset: textLen2, + collapsed: false + } + ] + + editor.dispatchEvent(deleteEvent) + await new Promise((r) => setTimeout(r, 50)) + + return { + afterSwipe, + afterSpace, + finalText: editor.textContent?.replace(/\u200B/g, ''), + // Check what was deleted + deletedCorrectly: + editor.textContent?.replace(/\u200B/g, '') === '' || + editor.textContent?.replace(/\u200B/g, '') === ' ' + } + }) + + console.log('Trailing space test result:', JSON.stringify(result, null, 2)) + + // Verify setup worked + expect(result.error).toBeUndefined() + expect(result.afterSwipe).toBe('hello') + expect(result.afterSpace).toBe('hello ') + + // EXPECTED: Entire swiped word deleted (leaving empty or just the space) + // ACTUAL BUG: Only one char deleted because tracking was cleared by space + // We expect the result to be empty string (whole word + space deleted) + // or just a space (word deleted, space preserved) + expect(result.finalText).toMatch(/^[ ]?$/) // Empty or single space + }) + + /** + * Multiple swipes in sequence - each swipe's trailing space should not + * break backspace behavior for the most recent swipe. + */ + test('backspace after multiple swipes should delete most recent word', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + + const result = await page.evaluate(async () => { + const editor = document.querySelector('[role="textbox"]') as HTMLElement + + const dispatchInsert = async (text: string) => { + const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT) + let textNode: Text | null = null + let textLen = 0 + while (walker.nextNode()) { + const node = walker.currentNode as Text + if (node.textContent) { + textNode = node + textLen = node.textContent.length + } + } + + const event = new InputEvent('beforeinput', { + inputType: 'insertText', + data: text, + bubbles: true, + cancelable: true + }) + + if (textNode) { + ;(event as any).getTargetRanges = () => [ + { + startContainer: textNode, + startOffset: textLen, + endContainer: textNode, + endOffset: textLen, + collapsed: true + } + ] + } else { + const sel = window.getSelection() + if (sel && sel.rangeCount > 0) { + const range = sel.getRangeAt(0) + ;(event as any).getTargetRanges = () => [ + { + startContainer: range.startContainer, + startOffset: range.startOffset, + endContainer: range.endContainer, + endOffset: range.endOffset, + collapsed: true + } + ] + } else { + ;(event as any).getTargetRanges = () => [] + } + } + + editor.dispatchEvent(event) + await new Promise((r) => setTimeout(r, 30)) + } + + // Swipe "hello" + trailing space + await dispatchInsert('hello') + await dispatchInsert(' ') + + // Swipe "world" + trailing space + await dispatchInsert('world') + await dispatchInsert(' ') + + const beforeDelete = editor.textContent?.replace(/\u200B/g, '') + + // Press backspace + const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT) + let textNode: Text | null = null + while (walker.nextNode()) { + const node = walker.currentNode as Text + if (node.textContent && node.textContent.length > 0) { + textNode = node + break + } + } + + if (!textNode) { + return { error: 'No text node', beforeDelete } + } + + const deleteEvent = new InputEvent('beforeinput', { + inputType: 'deleteContentBackward', + bubbles: true, + cancelable: true + }) + + const textLen = textNode.textContent?.length || 0 + ;(deleteEvent as any).getTargetRanges = () => [ + { + startContainer: textNode, + startOffset: textLen - 1, + endContainer: textNode, + endOffset: textLen, + collapsed: false + } + ] + + editor.dispatchEvent(deleteEvent) + await new Promise((r) => setTimeout(r, 50)) + + return { + beforeDelete, + finalText: editor.textContent?.replace(/\u200B/g, '') + } + }) + + console.log('Multiple swipes test result:', JSON.stringify(result, null, 2)) + + expect(result.error).toBeUndefined() + expect(result.beforeDelete).toBe('hello world ') + + // EXPECTED: "world " deleted, leaving "hello " or "hello" + // ACTUAL BUG: Only one char deleted, leaving "hello world" + expect(result.finalText).toMatch(/^hello ?$/) + }) +}) + +/** + * iOS Swipe-Text Double Space Prevention + * + * THE BUG (from real iOS device testing): + * When user swipes "hello", iOS auto-adds a trailing space → "hello " + * When user then swipes "world", iOS sends " world" (with leading space) + * Result: "hello world" with DOUBLE SPACE + * + * EXPECTED: "hello world" (single space between words) + * ACTUAL BUG: "hello world" (double space) + * + * THE FIX: When inserting a multi-char string that starts with a space, + * check if the character before is already a space. If so, strip the + * leading space from the insert to avoid double-spacing. + */ +test.describe('iOS swipe-text double space prevention', () => { + // Skip on mobile-safari: cannot emulate iOS native IME behavior + test.beforeEach(async ({}, testInfo) => { + test.skip( + testInfo.project.name === 'mobile-safari', + 'Skipped on mobile-safari: cannot emulate iOS native IME behavior' + ) + }) + + /** + * When swiping " world" after "hello " (which already has trailing space), + * the leading space should be stripped to avoid double-spacing. + */ + test('swipe after trailing space should not create double space', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + + const result = await page.evaluate(async () => { + const editor = document.querySelector('[role="textbox"]') as HTMLElement + + const dispatchInsert = async (text: string) => { + const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT) + let textNode: Text | null = null + let textLen = 0 + while (walker.nextNode()) { + const node = walker.currentNode as Text + if (node.textContent) { + textNode = node + textLen = node.textContent.length + } + } + + const event = new InputEvent('beforeinput', { + inputType: 'insertText', + data: text, + bubbles: true, + cancelable: true + }) + + if (textNode) { + ;(event as any).getTargetRanges = () => [ + { + startContainer: textNode, + startOffset: textLen, + endContainer: textNode, + endOffset: textLen, + collapsed: true + } + ] + } else { + const sel = window.getSelection() + if (sel && sel.rangeCount > 0) { + const range = sel.getRangeAt(0) + ;(event as any).getTargetRanges = () => [ + { + startContainer: range.startContainer, + startOffset: range.startOffset, + endContainer: range.endContainer, + endOffset: range.endOffset, + collapsed: true + } + ] + } else { + ;(event as any).getTargetRanges = () => [] + } + } + + editor.dispatchEvent(event) + await new Promise((r) => setTimeout(r, 30)) + } + + // Step 1: Swipe "hello" + await dispatchInsert('hello') + const afterHello = editor.textContent?.replace(/\u200B/g, '') + + // Step 2: iOS adds trailing space + await dispatchInsert(' ') + const afterSpace = editor.textContent?.replace(/\u200B/g, '') + + // Step 3: Swipe " world" (iOS sends with leading space) + await dispatchInsert(' world') + const afterWorld = editor.textContent?.replace(/\u200B/g, '') + + // Count spaces between hello and world + const match = afterWorld?.match(/hello( +)world/) + const spaceCount = match ? match[1].length : 0 + + return { + afterHello, + afterSpace, + afterWorld, + spaceCount, + hasDoubleSpace: afterWorld?.includes(' ') + } + }) + + console.log('Double space test result:', JSON.stringify(result, null, 2)) + + expect(result.afterHello).toBe('hello') + expect(result.afterSpace).toBe('hello ') + + // EXPECTED: "hello world" (single space) + // ACTUAL BUG: "hello world" (double space) + expect(result.afterWorld).toBe('hello world') + expect(result.spaceCount).toBe(1) + expect(result.hasDoubleSpace).toBe(false) + }) +}) + +/** + * iOS swipe after newline tests + * + * When swiping on a new line (after Enter), iOS often adds a leading space. + * This space should be stripped since it's at the start of a line. + */ +test.describe('iOS swipe-text after newline', () => { + // Skip on mobile-safari: cannot emulate iOS native IME behavior + test.beforeEach(async ({}, testInfo) => { + test.skip( + testInfo.project.name === 'mobile-safari', + 'Skipped on mobile-safari: cannot emulate iOS native IME behavior' + ) + }) + + /** + * When swiping "world" after "hello\n", the leading space should be stripped. + * iOS sends swipe data with leading space even at start of line. + */ + test('swipe after newline should not have leading space', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + + const result = await page.evaluate(async () => { + const editor = document.querySelector('[role="textbox"]') as HTMLElement + + // Helper to dispatch beforeinput and let it be handled + const dispatchBeforeInput = async ( + inputType: string, + data: string | null + ) => { + const event = new InputEvent('beforeinput', { + inputType, + data, + bubbles: true, + cancelable: true + }) + + const sel = window.getSelection() + if (sel && sel.rangeCount > 0) { + const range = sel.getRangeAt(0) + ;(event as any).getTargetRanges = () => [ + { + startContainer: range.startContainer, + startOffset: range.startOffset, + endContainer: range.endContainer, + endOffset: range.endOffset, + collapsed: range.collapsed + } + ] + } else { + ;(event as any).getTargetRanges = () => [] + } + + editor.dispatchEvent(event) + await new Promise((r) => setTimeout(r, 50)) + } + + // Step 1: Type "hello" (simulating char-by-char typing via swipe for simplicity) + await dispatchBeforeInput('insertText', 'hello') + + // Step 2: Press Enter (simulated via keydown) + const enterEvent = new KeyboardEvent('keydown', { + key: 'Enter', + bubbles: true, + cancelable: true + }) + editor.dispatchEvent(enterEvent) + await new Promise((r) => setTimeout(r, 50)) + + const afterEnter = editor.innerText?.replace(/\u200B/g, '').trim() + + // Step 3: Swipe " world" on the new line (iOS sends with leading space) + await dispatchBeforeInput('insertText', ' world') + + const finalValue = editor.innerText?.replace(/\u200B/g, '') + + return { + afterEnter, + finalValue, + startsWithSpaceOnLine2: finalValue?.split('\n')[1]?.startsWith(' ') + } + }) + + console.log('Swipe after newline result:', JSON.stringify(result, null, 2)) + + // The second line should NOT start with a space + expect(result.startsWithSpaceOnLine2).toBe(false) + // Final value should be "hello\nworld" not "hello\n world" + expect(result.finalValue).toMatch(/hello\n\s*world/) + expect(result.finalValue).not.toContain('\n ') + }) + + /** + * iOS sometimes sends a single space as a separate event BEFORE the swiped word. + * This space should be skipped entirely at the start of a line. + */ + test('single space before swipe word at start of line should be skipped', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + + const result = await page.evaluate(async () => { + const editor = document.querySelector('[role="textbox"]') as HTMLElement + + const dispatchBeforeInput = async ( + inputType: string, + data: string | null + ) => { + const event = new InputEvent('beforeinput', { + inputType, + data, + bubbles: true, + cancelable: true + }) + + const sel = window.getSelection() + if (sel && sel.rangeCount > 0) { + const range = sel.getRangeAt(0) + ;(event as any).getTargetRanges = () => [ + { + startContainer: range.startContainer, + startOffset: range.startOffset, + endContainer: range.endContainer, + endOffset: range.endOffset, + collapsed: range.collapsed + } + ] + } else { + ;(event as any).getTargetRanges = () => [] + } + + editor.dispatchEvent(event) + await new Promise((r) => setTimeout(r, 50)) + } + + // Step 1: Insert "hello" + await dispatchBeforeInput('insertText', 'hello') + + // Step 2: Press Enter + const enterEvent = new KeyboardEvent('keydown', { + key: 'Enter', + bubbles: true, + cancelable: true + }) + editor.dispatchEvent(enterEvent) + await new Promise((r) => setTimeout(r, 50)) + + // Step 3: iOS sends JUST a space first (separate event before the word) + await dispatchBeforeInput('insertText', ' ') + const afterSpace = editor.innerText?.replace(/\u200B/g, '') + + // Step 4: iOS sends the actual word + await dispatchBeforeInput('insertText', 'world') + const finalValue = editor.innerText?.replace(/\u200B/g, '') + + return { + afterSpace, + finalValue, + line2: finalValue?.split('\n')[1] + } + }) + + console.log( + 'Single space before swipe result:', + JSON.stringify(result, null, 2) + ) + + // The space should have been skipped, so line 2 should be "world" not " world" + expect(result.line2).toBe('world') + expect(result.finalValue).not.toContain('\n ') + }) +}) diff --git a/src/inlay/__ct__/inlay.mobile.spec.tsx b/src/inlay/__ct__/inlay.mobile.spec.tsx new file mode 100644 index 0000000..b6ec115 --- /dev/null +++ b/src/inlay/__ct__/inlay.mobile.spec.tsx @@ -0,0 +1,155 @@ +import { test, expect } from '@playwright/experimental-ct-react' +import { MobileInlay, SimpleMobileInlay } from './fixtures/mobile-inlay' + +// These tests run on mobile projects defined in playwright-ct.config.mts +// Run with: bun run test:ct -- --project=mobile-chrome +// or: bun run test:ct -- --project=mobile-safari + +test.describe('Mobile touch interaction', () => { + test('tap positions caret in editor', async ({ mount, page }) => { + await mount() + + const editor = page.getByRole('textbox') + await expect(editor).toHaveCount(1) + + // Click to focus (tap in mobile projects) + await editor.click() + await expect(editor).toBeFocused() + }) + + test('editor has correct mobile attributes', async ({ mount, page }) => { + await mount() + + const editor = page.getByRole('textbox') + await expect(editor).toHaveAttribute('inputmode', 'text') + await expect(editor).toHaveAttribute('autocapitalize', 'sentences') + // Note: autoCorrect is intentionally omitted (undefined) to let iOS use native behavior + // for keyboard suggestions. When not set, iOS defaults to system settings. + await expect(editor).toHaveAttribute('spellcheck', 'false') + }) + + test('editor has touch-action manipulation style', async ({ + mount, + page + }) => { + await mount() + + const editor = page.getByRole('textbox') + const touchAction = await editor.evaluate( + (el) => window.getComputedStyle(el).touchAction + ) + expect(touchAction).toBe('manipulation') + }) + + test('typing works after focus', async ({ mount, page }) => { + await mount() + + const editor = page.getByRole('textbox') + await editor.click() + + // Type one character at a time and verify it appears + await page.keyboard.type('a') + const rawValue = await page.getByTestId('raw-value').textContent() + expect(rawValue).toContain('a') + }) + + test('text editing works', async ({ mount, page }) => { + await mount() + + const editor = page.getByRole('textbox') + await editor.click() + await expect(editor).toBeFocused() + + // Type at caret position + await page.keyboard.type('!') + // Value should have changed + const rawValue = await page.getByTestId('raw-value').textContent() + expect(rawValue).toContain('!') + }) +}) + +test.describe('Portal interaction', () => { + test('portal items respond to click/tap', async ({ mount, page }) => { + await mount() + + const editor = page.getByRole('textbox') + await editor.click() + + // Portal should be visible when there's a mention token + const portal = page.getByTestId('portal') + await expect(portal).toBeVisible() + + // Click on a portal item + const item = page.getByTestId('item-1') + await expect(item).toBeVisible() + await item.click() + + // Should have selected the item + await expect(page.getByTestId('selected')).toHaveText('Apple') + }) + + test('portal item selection works', async ({ mount, page }) => { + await mount() + + const editor = page.getByRole('textbox') + await editor.click() + + const portal = page.getByTestId('portal') + await expect(portal).toBeVisible() + + // Click item 2 + const item2 = page.getByTestId('item-2') + await item2.click() + + // Should have selected item 2 + await expect(page.getByTestId('selected')).toHaveText('Banana') + }) + + test('portal has touch-action manipulation style', async ({ + mount, + page + }) => { + await mount() + + const editor = page.getByRole('textbox') + await editor.click() + + const portal = page.getByTestId('portal-list') + await expect(portal).toBeVisible() + + const touchAction = await portal.evaluate( + (el) => window.getComputedStyle(el).touchAction + ) + expect(touchAction).toBe('manipulation') + }) +}) + +test.describe('Token interaction', () => { + test('interacting near token works correctly', async ({ mount, page }) => { + await mount() + + const editor = page.getByRole('textbox') + await editor.click() + + // Should be able to type + await page.keyboard.type('!') + const rawValue = await page.getByTestId('raw-value').textContent() + expect(rawValue).toContain('!') + }) + + test('backspace near token works', async ({ mount, page }) => { + await mount() + + const editor = page.getByRole('textbox') + await editor.click() + + // Move to end + for (let i = 0; i < 10; i++) await page.keyboard.press('ArrowRight') + + // Backspace should delete token characters + await page.keyboard.press('Backspace') + const rawValue = await page.getByTestId('raw-value').textContent() + // Should have deleted something + expect(rawValue?.length).toBeLessThan('a@user'.length) + }) +}) diff --git a/src/inlay/__ct__/inlay.overlap.spec.tsx b/src/inlay/__ct__/inlay.overlap.spec.tsx new file mode 100644 index 0000000..4037fba --- /dev/null +++ b/src/inlay/__ct__/inlay.overlap.spec.tsx @@ -0,0 +1,134 @@ +import { test, expect } from '@playwright/experimental-ct-react' +import { OverlappingPluginsInlay } from './fixtures/overlapping-plugins-inlay' + +test.describe('Plugin overlap resolution (CT)', () => { + test.describe('Exact same substring - two plugins match @test at identical range', () => { + test('only one token renders and first plugin wins (diverged output confirms which)', async ({ + mount, + page + }) => { + // Input: "hello @test world" + // Plugin A matches @test → renders "[A:test]" + // Plugin B matches @test → renders "[B:test]" + // Both match the EXACT same range. Only one should render. + // With proper overlap resolution, first plugin (A) should win. + await mount( + + ) + + const ed = page.getByRole('textbox') + await expect(ed).toBeVisible() + + // Should have exactly one token, not two + const allTokens = ed.locator('[data-plugin]') + await expect(allTokens).toHaveCount(1) + + // The visual output should be from Plugin A (first in array) + // This confirms which plugin "won" via diverged rendering + await expect(ed).toContainText('[A:test]') + await expect(ed).not.toContainText('[B:test]') + + // Confirm it's Plugin A's token + await expect(allTokens.first()).toHaveAttribute('data-plugin', 'pluginA') + }) + }) + + test.describe('Longer match wins - @alice vs @alice_vip at same start', () => { + test('longer @alice_vip token wins over shorter @alice match', async ({ + mount, + page + }) => { + // Input: "hello @alice_vip world" + // Short plugin matches "@alice" at position 6-12 + // Long plugin matches "@alice_vip" at position 6-16 + // Both start at position 6, but Long is 10 chars vs Short's 6 chars + // Longest-match-wins: Long plugin should win + await mount( + + ) + + const ed = page.getByRole('textbox') + await expect(ed).toBeVisible() + + // Should have exactly one token (the longer match consumed the range) + const allTokens = ed.locator('[data-plugin]') + await expect(allTokens).toHaveCount(1) + + // The visual should be from Long plugin, not Short + await expect(ed).toContainText('[long:alice_vip]') + await expect(ed).not.toContainText('[short:alice]') + + // Confirm it's Long plugin's token + const token = allTokens.first() + await expect(token).toHaveAttribute('data-plugin', 'long') + await expect(token).toHaveAttribute('data-token-raw', '@alice_vip') + }) + + test('short match still works when long pattern does not apply', async ({ + mount, + page + }) => { + // Input: "hello @alice world" (no _vip suffix) + // Short plugin matches "@alice" at position 6-12 + // Long plugin does NOT match (no @alice_vip in text) + // Short plugin should render since there's no competition + await mount( + + ) + + const ed = page.getByRole('textbox') + await expect(ed).toBeVisible() + + // Should have exactly one token from Short plugin + const allTokens = ed.locator('[data-plugin]') + await expect(allTokens).toHaveCount(1) + + await expect(ed).toContainText('[short:alice]') + await expect(allTokens.first()).toHaveAttribute('data-plugin', 'short') + }) + }) + + test.describe('Non-overlapping tokens preserved', () => { + test('multiple non-overlapping tokens from different plugins are all rendered', async ({ + mount, + page + }) => { + await mount( + + ) + + const ed = page.getByRole('textbox') + await expect(ed).toBeVisible() + + // Should have 4 tokens total (2 mentions, 2 hashtags) + const allTokens = ed.locator('[data-plugin]') + await expect(allTokens).toHaveCount(4) + + // Two mentions + const mentionTokens = ed.locator('[data-plugin="mention"]') + await expect(mentionTokens).toHaveCount(2) + + // Two hashtags + const hashtagTokens = ed.locator('[data-plugin="hashtag"]') + await expect(hashtagTokens).toHaveCount(2) + + // Verify specific values exist + await expect(ed.locator('[data-token-raw="@alice"]')).toHaveCount(1) + await expect(ed.locator('[data-token-raw="@bob"]')).toHaveCount(1) + await expect(ed.locator('[data-token-raw="#react"]')).toHaveCount(1) + await expect(ed.locator('[data-token-raw="#vue"]')).toHaveCount(1) + }) + }) +}) diff --git a/src/inlay/__ct__/inlay.portal-navigation.spec.tsx b/src/inlay/__ct__/inlay.portal-navigation.spec.tsx new file mode 100644 index 0000000..a80dc98 --- /dev/null +++ b/src/inlay/__ct__/inlay.portal-navigation.spec.tsx @@ -0,0 +1,157 @@ +import { test, expect } from '@playwright/experimental-ct-react' +import type { Page } from '@playwright/test' +import type { MountResult } from '@playwright/experimental-ct-react' +import { PortalNavigationInlay } from './fixtures/portal-navigation-inlay' + +test.describe('Portal keyboard navigation (CT)', () => { + // Helper to set up the test with portal open + async function setupPortal( + mount: (component: React.ReactElement) => Promise, + page: Page + ) { + await mount() + + const editor = page.getByTestId('editor') + await expect(editor).toBeVisible() + + // Wait for token to be rendered - scope to editor to avoid hidden first-pass + const tokenRender = editor.getByTestId('token-render') + await expect(tokenRender).toBeVisible() + + // Click on the token to activate portal + await tokenRender.click() + + // Wait for portal to appear + const portal = page.getByTestId('portal') + await expect(portal).toBeVisible() + + return { editor, portal } + } + + test('ArrowDown navigates to next item', async ({ mount, page }) => { + await setupPortal(mount, page) + + // First item should be active by default + const item1 = page.getByTestId('item-1') + const item2 = page.getByTestId('item-2') + + await expect(item1).toHaveAttribute('data-active') + await expect(item2).not.toHaveAttribute('data-active') + + // Press ArrowDown + await page.keyboard.press('ArrowDown') + + await expect(item1).not.toHaveAttribute('data-active') + await expect(item2).toHaveAttribute('data-active') + }) + + test('ArrowUp navigates to previous item', async ({ mount, page }) => { + await setupPortal(mount, page) + + // Navigate down first + await page.keyboard.press('ArrowDown') + await page.keyboard.press('ArrowDown') + + const item3 = page.getByTestId('item-3') + await expect(item3).toHaveAttribute('data-active') + + // Press ArrowUp + await page.keyboard.press('ArrowUp') + + const item2 = page.getByTestId('item-2') + await expect(item2).toHaveAttribute('data-active') + }) + + test('ArrowDown wraps from last to first item', async ({ mount, page }) => { + await setupPortal(mount, page) + + // Navigate to last item + await page.keyboard.press('ArrowDown') + await page.keyboard.press('ArrowDown') + + const item3 = page.getByTestId('item-3') + await expect(item3).toHaveAttribute('data-active') + + // Press ArrowDown again - should wrap to first + await page.keyboard.press('ArrowDown') + + const item1 = page.getByTestId('item-1') + await expect(item1).toHaveAttribute('data-active') + }) + + test('ArrowUp wraps from first to last item', async ({ mount, page }) => { + await setupPortal(mount, page) + + // First item is active by default + const item1 = page.getByTestId('item-1') + await expect(item1).toHaveAttribute('data-active') + + // Press ArrowUp - should wrap to last + await page.keyboard.press('ArrowUp') + + const item3 = page.getByTestId('item-3') + await expect(item3).toHaveAttribute('data-active') + }) + + test('Enter selects the active item', async ({ mount, page }) => { + await setupPortal(mount, page) + + // Navigate to second item + await page.keyboard.press('ArrowDown') + + // Press Enter to select + await page.keyboard.press('Enter') + + // Check that the item was selected + const selected = page.getByTestId('selected') + await expect(selected).toHaveText('Banana') + + // Check that the raw value was updated + const rawValue = page.getByTestId('raw-value') + await expect(rawValue).toHaveText('hello @2 ') + }) + + test('Mouse hover changes active item', async ({ mount, page }) => { + await setupPortal(mount, page) + + const item1 = page.getByTestId('item-1') + const item3 = page.getByTestId('item-3') + + // First item is active by default + await expect(item1).toHaveAttribute('data-active') + + // Hover over third item + await item3.hover() + + // Third item should now be active + await expect(item3).toHaveAttribute('data-active') + await expect(item1).not.toHaveAttribute('data-active') + }) + + test('Click selects the item', async ({ mount, page }) => { + await setupPortal(mount, page) + + // Click on third item + const item3 = page.getByTestId('item-3') + await item3.click() + + // Check that the item was selected + const selected = page.getByTestId('selected') + await expect(selected).toHaveText('Cherry') + + // Check that the raw value was updated + const rawValue = page.getByTestId('raw-value') + await expect(rawValue).toHaveText('hello @3 ') + }) + + test('Focus stays in editor after navigation', async ({ mount, page }) => { + const { editor } = await setupPortal(mount, page) + + // Navigate with arrow keys + await page.keyboard.press('ArrowDown') + await page.keyboard.press('ArrowDown') + + // Focus should still be in editor + await expect(editor).toBeFocused() + }) +}) diff --git a/src/inlay/__ct__/inlay.structured-actions.spec.tsx b/src/inlay/__ct__/inlay.structured-actions.spec.tsx new file mode 100644 index 0000000..0d2d648 --- /dev/null +++ b/src/inlay/__ct__/inlay.structured-actions.spec.tsx @@ -0,0 +1,126 @@ +import { test, expect } from '@playwright/experimental-ct-react' +import { StructuredActionsInlay } from './fixtures/structured-actions-inlay' + +test.describe('StructuredInlay replace/update actions (CT)', () => { + test('update changes rendered label without changing raw value', async ({ + mount, + page + }) => { + await mount() + + const ed = page.getByTestId('editor') + await expect(ed).toBeVisible() + + // Wait for token to be weaved (scope to editor to avoid hidden first-pass element) + const tokenRender = ed.getByTestId('token-render') + await expect(tokenRender).toBeVisible() + await expect(tokenRender).toHaveText('@alice') + + // Click on the token to activate portal + await tokenRender.click() + + // Wait for portal to appear + const portal = page.getByTestId('portal') + await expect(portal).toBeVisible() + + // Click the update button + await page.getByTestId('btn-update').click() + + // Verify rendered label changed + await expect(tokenRender).toHaveText('UpdatedLabel') + + // Verify raw value is unchanged + const rawValue = page.getByTestId('raw-value') + await expect(rawValue).toHaveText('hello @alice world') + }) + + test('replace changes raw value', async ({ mount, page }) => { + await mount() + + const ed = page.getByTestId('editor') + await expect(ed).toBeVisible() + + // Wait for token to be weaved (scope to editor) + const tokenRender = ed.getByTestId('token-render') + await expect(tokenRender).toBeVisible() + + // Click on the token to activate portal + await tokenRender.click() + + // Wait for portal to appear + const portal = page.getByTestId('portal') + await expect(portal).toBeVisible() + + // Click the replace button (replaces @alice with @replaced) + await page.getByTestId('btn-replace').click() + + // Verify raw value changed + const rawValue = page.getByTestId('raw-value') + await expect(rawValue).toHaveText('hello @replaced world') + + // Verify the new token is rendered + await expect(tokenRender).toHaveText('@replaced') + }) + + test('replace positions caret at end of new text', async ({ + mount, + page + }) => { + await mount() + + const ed = page.getByTestId('editor') + await expect(ed).toBeVisible() + + // Wait for token (scope to editor) + const tokenRender = ed.getByTestId('token-render') + await expect(tokenRender).toBeVisible() + await expect(tokenRender).toHaveText('@a') + + // Click on token to activate portal + await tokenRender.click() + await expect(page.getByTestId('portal')).toBeVisible() + + // Replace @a with @replaced + // The portal has onMouseDown preventDefault, so focus stays in editor + await page.getByTestId('btn-replace').click() + + // Verify the value updated correctly + const rawValue = page.getByTestId('raw-value') + await expect(rawValue).toHaveText('hi @replaced bye') + + // Type to verify caret position (focus should still be in editor) + await page.keyboard.type('X') + + // Caret should be after @replaced + await expect(rawValue).toHaveText('hi @replacedX bye') + }) + + test('replace with longer text updates raw value correctly', async ({ + mount, + page + }) => { + await mount() + + const ed = page.getByTestId('editor') + await expect(ed).toBeVisible() + + // Wait for token (scope to editor) + const tokenRender = ed.getByTestId('token-render') + await expect(tokenRender).toBeVisible() + await expect(tokenRender).toHaveText('@a') + + // Click on token to activate portal + await tokenRender.click() + await expect(page.getByTestId('portal')).toBeVisible() + + // Replace @a with @replaced + await page.getByTestId('btn-replace').click() + + // Verify the value updated correctly + const rawValue = page.getByTestId('raw-value') + await expect(rawValue).toHaveText('hi @replaced bye') + + // Verify the token is re-rendered with new value + await expect(tokenRender).toHaveText('@replaced') + }) +}) diff --git a/src/inlay/__ct__/inlay.tokens.spec.tsx b/src/inlay/__ct__/inlay.tokens.spec.tsx new file mode 100644 index 0000000..3920f3e --- /dev/null +++ b/src/inlay/__ct__/inlay.tokens.spec.tsx @@ -0,0 +1,53 @@ +import { test, expect } from '@playwright/experimental-ct-react' +import { ControlledTokenInlay } from './fixtures/controlled-token-inlay' + +test.describe('Token-aware deletions (CT)', () => { + test('Backspace at end of token deletes last raw char', async ({ + mount, + page + }) => { + await mount() + + const ed = page.getByRole('textbox') + await ed.click() + // Ensure token has been weaved into the DOM and content is present + await expect(ed.locator('[data-token-text="@x"]').first()).toBeVisible() + await expect(ed).toHaveText('A@xB') + // overshoot to the right + for (let i = 0; i < 10; i++) await page.keyboard.press('ArrowRight') + await page.keyboard.press('ArrowLeft') + await page.keyboard.press('Backspace') + await expect(ed).toHaveText('A@B') + await expect(ed.locator('[data-token-text]')).toHaveCount(0) + }) + + test('Range delete across token removes the token completely', async ({ + mount, + page + }) => { + await mount() + + const ed = page.getByRole('textbox') + await ed.click() + // Ensure token has been weaved into the DOM and content is present + const token = ed.locator('[data-token-text="@x"]').first() + await expect(token).toBeVisible() + await expect(ed).toHaveText('A@xB') + const edBox = await ed.boundingBox() + const tokBox = await token.boundingBox() + if (!edBox || !tokBox) throw new Error('no boxes') + const y = edBox.y + edBox.height / 2 + const startX = Math.max(edBox.x + 2, tokBox.x - 4) + const endX = Math.min( + edBox.x + edBox.width - 2, + tokBox.x + tokBox.width + 2 + ) + await page.mouse.move(startX, y) + await page.mouse.down() + await page.mouse.move(endX, y, { steps: 5 }) + await page.mouse.up() + await page.keyboard.press('Backspace') + await expect(ed).toHaveText('AB') + await expect(ed.locator('[data-token-text]')).toHaveCount(0) + }) +}) diff --git a/src/inlay/__tests__/inlay-editor-behavior.test.tsx b/src/inlay/__tests__/inlay-editor-behavior.test.tsx new file mode 100644 index 0000000..870fde1 --- /dev/null +++ b/src/inlay/__tests__/inlay-editor-behavior.test.tsx @@ -0,0 +1,695 @@ +import React from 'react' +import { describe, it, expect } from 'vitest' +import { render, fireEvent, act, waitFor } from '@testing-library/react' +import * as Inlay from '../inlay' +import { getAbsoluteOffset, setDomSelection } from '../internal/dom-utils' + +function flush() { + return new Promise((r) => setTimeout(r, 0)) +} + +// Helper to dispatch native beforeinput event for backspace +// This is needed because our handler uses native event listeners +function fireBackspace(element: HTMLElement) { + const sel = window.getSelection() + // Range is captured for potential future use in getTargetRanges simulation + const _range = sel?.rangeCount ? sel.getRangeAt(0) : null + void _range // Acknowledge intentionally unused + + const event = new InputEvent('beforeinput', { + bubbles: true, + cancelable: true, + inputType: 'deleteContentBackward', + data: null + }) + + // jsdom doesn't support getTargetRanges, so we need to ensure selection is set + element.dispatchEvent(event) +} + +// Helper to dispatch native beforeinput for delete forward +function fireDelete(element: HTMLElement) { + const event = new InputEvent('beforeinput', { + bubbles: true, + cancelable: true, + inputType: 'deleteContentForward', + data: null + }) + element.dispatchEvent(event) +} + +// Empty state rendering +describe('Inlay empty state', () => { + it('renders zero-width space for consistent caret height', async () => { + const { getByTestId } = render( + + + + ) + const ed = getByTestId('editor') as HTMLElement + + await act(async () => { + await flush() + }) + + // Should contain a zero-width space (U+200B) to maintain caret baseline + expect(ed.textContent).toBe('\u200B') + }) +}) + +// Editing basics (Space/Enter) — Backspace covered in its own block below +describe('Inlay editing (Space/Enter)', () => { + it('Space and Enter modify value appropriately', async () => { + function Test() { + const [value, setValue] = React.useState('ab') + return ( + + + + ) + } + + const { getByTestId } = render() + const ed = getByTestId('editor') as HTMLElement + + await act(async () => { + ;(ed as HTMLElement).focus() + setDomSelection(ed as HTMLElement, 2) + await flush() + }) + + await act(async () => { + fireEvent.keyDown(ed, { key: ' ' }) + await flush() + }) + expect((ed as HTMLElement).textContent).toBe('ab ') + + await act(async () => { + fireEvent.keyDown(ed, { key: 'Enter' }) + await flush() + }) + expect((ed as HTMLElement).textContent).toBe('ab \n') + + // Backspace semantics are covered separately + }) +}) + +// Backspace semantics +describe('Inlay Backspace semantics', () => { + it('does nothing at start of content (collapsed at 0)', async () => { + function Test() { + const [value, setValue] = React.useState('ab') + return ( + + + + ) + } + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + await act(async () => { + ed.focus() + setDomSelection(ed, 0) + await flush() + }) + + await act(async () => { + fireBackspace(ed) + await flush() + }) + + expect(ed.textContent).toBe('ab') + const sel = window.getSelection()! + const caret = getAbsoluteOffset(ed, sel.focusNode!, sel.focusOffset) + expect(caret).toBe(0) + }) + + it('deletes previous char when collapsed (not at start)', async () => { + function Test() { + const [value, setValue] = React.useState('ab') + return ( + + + + ) + } + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + await act(async () => { + ed.focus() + setDomSelection(ed, 2) + await flush() + }) + + await act(async () => { + fireBackspace(ed) + await flush() + }) + + expect(ed.textContent).toBe('a') + const sel = window.getSelection()! + const caret = getAbsoluteOffset(ed, sel.focusNode!, sel.focusOffset) + expect(caret).toBe(1) + }) + + it('deletes selected range (within plain text)', async () => { + function Test() { + const [value, setValue] = React.useState('abcd') + return ( + + + + ) + } + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + await act(async () => { + ed.focus() + setDomSelection(ed, 1, 3) // select 'bc' + await flush() + }) + + await act(async () => { + fireBackspace(ed) + await flush() + }) + + expect(ed.textContent).toBe('ad') + const sel = window.getSelection()! + const caret = getAbsoluteOffset(ed, sel.focusNode!, sel.focusOffset) + expect(caret).toBe(1) + }) + + it('range delete across a token removes the token raw span', async () => { + function Test() { + const [value, setValue] = React.useState('A@xB') + return ( + + {/* Register token for '@x' so weaving recognizes it */} + + @x + + + ) + } + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + await waitFor(() => { + expect(ed.querySelector('[data-token-text="@x"]')).toBeTruthy() + }) + + await act(async () => { + ed.focus() + setDomSelection(ed, 1, 3) + await flush() + }) + + await act(async () => { + fireBackspace(ed) + await flush() + }) + + expect(ed.querySelector('[data-token-text]')).toBeFalsy() + }) + + it('collapsed backspace at end of token deletes last raw char of token', async () => { + function Test() { + const [value, setValue] = React.useState('A@xB') + return ( + + + @x + + + {({ value }) => } + + + ) + } + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + await waitFor(() => { + expect(ed.querySelector('[data-token-text]')).toBeTruthy() + }) + + await act(async () => { + ed.focus() + setDomSelection(ed, 3) // right after '@x' + await flush() + }) + + await act(async () => { + fireBackspace(ed) + await flush() + }) + + // Deletes 'x' -> raw becomes 'A@B'; token no longer matches + const ctxVal = getByTestId('ctx-val') + expect(ctxVal.getAttribute('data-value')).toBe('A@B') + expect(ed.querySelector('[data-token-text]')).toBeFalsy() + }) +}) + +// onKeyDown interception +describe('Inlay onKeyDown interception', () => { + it('returns true to prevent built-in handling (Space, Enter)', async () => { + const ref = React.createRef() + + function Test() { + const [value, setValue] = React.useState('ab') + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === ' ' || e.key === 'Enter') return true + return false + } + return ( + + + + ) + } + + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + await act(async () => { + ref.current!.setSelection(2) + await flush() + }) + + await act(async () => { + fireEvent.keyDown(ed, { key: ' ' }) + await flush() + }) + expect(ed.textContent).toBe('ab') + + await act(async () => { + fireEvent.keyDown(ed, { key: 'Enter' }) + await flush() + }) + expect(ed.textContent).toBe('ab') + }) +}) + +// Newline rendering +describe('Inlay trailing newline rendering', () => { + it('adds a when value ends with \n (when at least one token is present)', () => { + function Test({ value }: { value: string }) { + return ( + {}} data-testid="root"> + + X + + + ) + } + + const { getByTestId, rerender } = render() + const editor = getByTestId('root') as HTMLElement + expect(editor.querySelector('br')).toBeTruthy() + + rerender() + expect(editor.querySelector('br')).toBeFalsy() + }) + + it('first Enter on empty editor creates a visible newline (trailing )', async () => { + function Test() { + const [value, setValue] = React.useState('') + return ( + + + + ) + } + + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + await act(async () => { + ed.focus() + fireEvent.keyDown(ed, { key: 'Enter' }) + await flush() + }) + + // Expect a trailing to appear immediately after first Enter + expect(ed.querySelector('br')).toBeTruthy() + }) +}) + +// Grapheme cluster behavior +describe('Inlay Backspace with grapheme clusters', () => { + it('deletes full grapheme cluster (emoji + skin tone) when collapsed', async () => { + function Test() { + const [value, setValue] = React.useState('👍🏼') + return ( + + + + ) + } + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + await act(async () => { + ed.focus() + setDomSelection(ed, '👍🏼'.length) // caret after the cluster + await flush() + }) + + await act(async () => { + fireBackspace(ed) + await flush() + }) + + expect(ed.textContent).toBe('\u200B') + const sel = window.getSelection()! + const caret = getAbsoluteOffset(ed, sel.focusNode!, sel.focusOffset) + expect(caret).toBe(0) + }) + + it('deletes an entire flag grapheme (regional indicators) before caret', async () => { + const flag = '🇺🇸' + function Test() { + const [value, setValue] = React.useState(`a${flag}b`) + return ( + + + + ) + } + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + const value = `a${flag}b` + const posAfterFlag = value.indexOf(flag) + flag.length + + await act(async () => { + ed.focus() + setDomSelection(ed, posAfterFlag) + await flush() + }) + + await act(async () => { + fireBackspace(ed) + await flush() + }) + + expect(ed.textContent).toBe('ab') + }) + + it('deletes composed character with combining mark as a single grapheme', async () => { + const composed = 'e\u0301' // e + combining acute + function Test() { + const [value, setValue] = React.useState(composed) + return ( + + + + ) + } + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + await act(async () => { + ed.focus() + setDomSelection(ed, composed.length) + await flush() + }) + + await act(async () => { + fireBackspace(ed) + await flush() + }) + + expect(ed.textContent).toBe('\u200B') + }) +}) + +describe('Inlay selection deletion is grapheme-aware', () => { + it('Backspace with selection slicing through a grapheme deletes the whole grapheme', async () => { + const cluster = '👍🏼' + const text = `a${cluster}b` + function Test() { + const [value, setValue] = React.useState(text) + return ( + + + + ) + } + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + const start = 1 + 1 // into the grapheme + const end = 1 + cluster.length - 1 // still inside the grapheme + + await act(async () => { + ed.focus() + setDomSelection(ed, start, end) + await flush() + }) + + await act(async () => { + fireBackspace(ed) + await flush() + }) + + // Expect entire grapheme removed, leaving 'ab' + expect(ed.textContent).toBe('ab') + const sel = window.getSelection()! + const caret = getAbsoluteOffset(ed, sel.focusNode!, sel.focusOffset) + expect(caret).toBe(1) + }) + + it('Delete with selection slicing through a grapheme deletes the whole grapheme', async () => { + const cluster = '🇺🇸' + const text = `x${cluster}y` + function Test() { + const [value, setValue] = React.useState(text) + return ( + + + + ) + } + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + const start = 1 // start at grapheme start + const end = 1 + 1 // end in the middle of grapheme + + await act(async () => { + ed.focus() + setDomSelection(ed, start, end) + await flush() + }) + + await act(async () => { + fireDelete(ed) + await flush() + }) + + expect(ed.textContent).toBe('xy') + const sel = window.getSelection()! + const caret = getAbsoluteOffset(ed, sel.focusNode!, sel.focusOffset) + expect(caret).toBe(1) + }) +}) + +// ZWJ grapheme sequences and selection snapping +describe('Inlay grapheme advanced cases', () => { + it('Backspace/Delete remove whole ZWJ grapheme (family emoji)', async () => { + const family = '👨👩👧👦' + function Test() { + const [value, setValue] = React.useState(family) + return ( + + + + ) + } + const { getByTestId, rerender } = render() + const ed = getByTestId('ed') as HTMLElement + + await act(async () => { + ed.focus() + setDomSelection(ed, family.length) + await flush() + }) + await act(async () => { + fireBackspace(ed) + await flush() + }) + expect(ed.textContent).toBe('\u200B') + + // Reset and test Delete + rerender( + {}} data-testid="ed"> + + + ) + await act(async () => { + ed.focus() + setDomSelection(ed, 0) + await flush() + }) + await act(async () => { + fireDelete(ed) + await flush() + }) + expect(ed.textContent).toBe('\u200B') + }) + + it('setSelection snaps to grapheme boundaries', async () => { + const cluster = '👍🏼' + const text = `a${cluster}b` + const ref = React.createRef() + function Test() { + const [value, setValue] = React.useState(text) + return ( + + + {({ selection, getSelectionRange }) => { + const range = getSelectionRange() + let rawStart = -1 + let rawEnd = -1 + const root = ref.current?.root + if (root && range) { + rawStart = getAbsoluteOffset( + root, + range.startContainer, + range.startOffset + ) + rawEnd = getAbsoluteOffset( + root, + range.endContainer, + range.endOffset + ) + } + return ( + + ) + }} + + + + ) + } + const { getByTestId } = render() + + const mid = 1 + Math.floor(cluster.length / 2) + await act(async () => { + ref.current!.setSelection(mid) + await flush() + }) + + const ctx = getByTestId('ctx') + // Expect snapped to start of grapheme (offset 1) + expect(ctx.getAttribute('data-start')).toBe('1') + expect(ctx.getAttribute('data-end')).toBe('1') + expect(ctx.getAttribute('data-raw-start')).toBe('1') + expect(ctx.getAttribute('data-raw-end')).toBe('1') + }) +}) + +// Placeholder +describe('Inlay placeholder', () => { + it('shows placeholder only when value is empty', () => { + const placeholder = 'Type here...' + const { container, rerender } = render( + {}} placeholder={placeholder}> + + + ) + + expect(container.textContent).toContain(placeholder) + + rerender( + {}} placeholder={placeholder}> + + + ) + + expect(container.textContent).not.toContain(placeholder) + }) +}) + +// Multiline behavior +describe('Inlay multiline prop', () => { + it('multiline=false blocks Enter and Shift+Enter on empty editor', async () => { + function Test() { + const [value, setValue] = React.useState('') + return ( + + + + ) + } + + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + await act(async () => { + ed.focus() + fireEvent.keyDown(ed, { key: 'Enter' }) + await flush() + }) + expect(ed.querySelector('br')).toBeFalsy() + expect(ed.textContent).toBe('\u200B') + + await act(async () => { + fireEvent.keyDown(ed, { key: 'Enter', shiftKey: true }) + await flush() + }) + expect(ed.querySelector('br')).toBeFalsy() + expect(ed.textContent).toBe('\u200B') + }) + + it('multiline=false does not render trailing even if value ends with \n', async () => { + function Test() { + const [value, setValue] = React.useState('A\n') + return ( + + + + ) + } + + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + expect(ed.querySelector('br')).toBeFalsy() + }) +}) + +// (IME composition tests removed; to be covered in Playwright later) diff --git a/src/inlay/__tests__/inlay-selection-and-active-token.test.tsx b/src/inlay/__tests__/inlay-selection-and-active-token.test.tsx new file mode 100644 index 0000000..fd100a9 --- /dev/null +++ b/src/inlay/__tests__/inlay-selection-and-active-token.test.tsx @@ -0,0 +1,202 @@ +import React from 'react' +import { describe, it, expect } from 'vitest' +import { render, act, waitFor } from '@testing-library/react' +import * as Inlay from '../inlay' +import { getAbsoluteOffset } from '../internal/dom-utils' + +function flush() { + return new Promise((r) => setTimeout(r, 0)) +} + +describe('Inlay public selection API', () => { + it('setSelection updates public context and getSelectionRange round-trips to same raw offsets', async () => { + const ref = React.createRef() + + const Test = () => ( + {}} + data-testid="ed" + > + + {({ selection, getSelectionRange }) => { + const range = getSelectionRange() + let rawStart = -1 + let rawEnd = -1 + const root = ref.current?.root + if (root && range) { + rawStart = getAbsoluteOffset( + root, + range.startContainer, + range.startOffset + ) + rawEnd = getAbsoluteOffset( + root, + range.endContainer, + range.endOffset + ) + } + return ( + + ) + }} + + + ) + + const { getByTestId } = render() + + await act(async () => { + ref.current!.setSelection(1, 3) + await flush() + }) + + await waitFor(() => { + const ctx = getByTestId('ctx') + expect(ctx.getAttribute('data-start')).toBe('1') + expect(ctx.getAttribute('data-end')).toBe('3') + expect(ctx.getAttribute('data-range')).toBe('1') + expect(ctx.getAttribute('data-raw-start')).toBe('1') + expect(ctx.getAttribute('data-raw-end')).toBe('3') + }) + }) +}) + +describe('Inlay active token and state', () => { + it('middle/end for non-diverged token; start boundary is spacer', async () => { + const ref = React.createRef() + const { getByTestId } = render( + {}} data-testid="ed"> + + @x + + + {({ activeToken, activeTokenState }) => ( + + )} + + + ) + + const ed = getByTestId('ed') as HTMLElement + + await waitFor(() => { + expect(ed.querySelector('[data-token-text]')).toBeTruthy() + }) + + // Start boundary selects spacer [0,1] + await act(async () => { + ref.current!.setSelection(1) + await flush() + }) + await waitFor(() => { + const ctx = document.querySelector('[data-testid="ctx"]') as HTMLElement + expect(ctx.getAttribute('data-start')).toBe('0') + expect(ctx.getAttribute('data-end')).toBe('1') + expect(ctx.getAttribute('data-collapsed')).toBe('1') + expect(ctx.getAttribute('data-atstart')).toBe('0') + expect(ctx.getAttribute('data-atend')).toBe('1') + }) + + // Middle of token (offset 2) + await act(async () => { + ref.current!.setSelection(2) + await flush() + }) + await waitFor(() => { + const ctx = document.querySelector('[data-testid="ctx"]') as HTMLElement + expect(ctx.getAttribute('data-start')).toBe('1') + expect(ctx.getAttribute('data-end')).toBe('3') + expect(ctx.getAttribute('data-collapsed')).toBe('1') + expect(ctx.getAttribute('data-atstart')).toBe('0') + expect(ctx.getAttribute('data-atend')).toBe('0') + }) + + // End of token (offset 3) + await act(async () => { + ref.current!.setSelection(3) + await flush() + }) + await waitFor(() => { + const ctx = document.querySelector('[data-testid="ctx"]') as HTMLElement + expect(ctx.getAttribute('data-start')).toBe('1') + expect(ctx.getAttribute('data-end')).toBe('3') + expect(ctx.getAttribute('data-collapsed')).toBe('1') + expect(ctx.getAttribute('data-atstart')).toBe('0') + expect(ctx.getAttribute('data-atend')).toBe('1') + }) + }) + + it('diverged: start boundary is spacer; end-of-token reports atEnd', async () => { + const ref = React.createRef() + const { getByTestId } = render( + {}} + data-testid="ed" + > + + Alex + + + {({ activeToken, activeTokenState }) => ( + + )} + + + ) + + const ed = getByTestId('ed') as HTMLElement + + await waitFor(() => { + expect(ed.querySelector('[data-token-text]')).toBeTruthy() + }) + + // Start boundary is spacer [0,1] + await act(async () => { + ref.current!.setSelection(1) + await flush() + }) + await waitFor(() => { + const ctx = document.querySelector('[data-testid="ctx"]') as HTMLElement + expect(ctx.getAttribute('data-start')).toBe('0') + expect(ctx.getAttribute('data-end')).toBe('1') + expect(ctx.getAttribute('data-atstart')).toBe('0') + expect(ctx.getAttribute('data-atend')).toBe('1') + }) + + // End of token (offset 6) + await act(async () => { + ref.current!.setSelection(6) + await flush() + }) + await waitFor(() => { + const ctx = document.querySelector('[data-testid="ctx"]') as HTMLElement + expect(ctx.getAttribute('data-atstart')).toBe('0') + expect(ctx.getAttribute('data-atend')).toBe('1') + }) + }) +}) diff --git a/src/inlay/__tests__/inlay-tokens-and-registry.test.tsx b/src/inlay/__tests__/inlay-tokens-and-registry.test.tsx new file mode 100644 index 0000000..79b7164 --- /dev/null +++ b/src/inlay/__tests__/inlay-tokens-and-registry.test.tsx @@ -0,0 +1,264 @@ +import React from 'react' +import { describe, it, expect } from 'vitest' +import { render, waitFor, act } from '@testing-library/react' +import * as Inlay from '../inlay' + +function flush() { + return new Promise((r) => setTimeout(r, 0)) +} + +// Ancestor registration +describe('Inlay ancestor registration', () => { + it('wraps a token with its ancestor element in the visible weaved output', async () => { + const { getByTestId } = render( + {}} data-testid="ed"> + + + X + + + + ) + + const editor = getByTestId('ed') as HTMLElement + + await waitFor(() => { + const token = editor.querySelector('[data-token-text="X"]') + expect(token).toBeTruthy() + }) + + const wrapped = editor.querySelector( + '[data-anc="outer"] [data-token-text="X"]' + ) + expect(wrapped).toBeTruthy() + }) + + it('uses the top-most ancestor, not inner ones, as the captured node (outer wrapper present)', async () => { + const { getByTestId } = render( + {}} data-testid="ed"> + + + + X + + + + + ) + + const editor = getByTestId('ed') as HTMLElement + + await waitFor(() => { + const token = editor.querySelector('[data-token-text="X"]') + expect(token).toBeTruthy() + }) + + const outerWrapped = editor.querySelector( + '[data-anc="outer"] [data-token-text="X"]' + ) + expect(outerWrapped).toBeTruthy() + }) + + it('when no ancestor wrapper is present, token renders directly without ancestor', async () => { + const { getByTestId } = render( + {}} data-testid="ed"> + + X + + + ) + + const editor = getByTestId('ed') as HTMLElement + + await waitFor(() => { + const token = editor.querySelector('[data-token-text="X"]') + expect(token).toBeTruthy() + }) + + expect(editor.querySelector('[data-anc]')).toBeFalsy() + }) +}) + +// Adjacent tokens +describe('Inlay adjacent tokens (weaving)', () => { + it('renders back-to-back tokens without a spacer', () => { + const { getByTestId } = render( + {}} data-testid="ed"> + + @x + + + @x + + + ) + + const editor = getByTestId('ed') as HTMLElement + const tokens = editor.querySelectorAll('[data-token-text="@x"]') + expect(tokens.length).toBe(2) + }) +}) + +// Registry staleness +describe('Inlay token registry staleness', () => { + it('when value changes and token child is removed, no stale token renders; plain text is shown', async () => { + const Test = ({ + value, + withToken + }: { + value: string + withToken: boolean + }) => ( + {}} data-testid="ed"> + {withToken ? ( + + X + + ) : ( + + )} + + ) + + const { getByTestId, rerender } = render( + + ) + const ed = getByTestId('ed') as HTMLElement + + await waitFor(() => { + expect(ed.querySelector('[data-token-text="X"]')).toBeTruthy() + }) + + rerender() + + await waitFor(() => { + expect(ed.querySelector('[data-token-text]')).toBeFalsy() + expect(ed.textContent).toBe('Y') + }) + }) + + it('when value changes but a stale token child remains, it should not appear in the visible editor', async () => { + const { getByTestId, rerender } = render( + {}} data-testid="ed"> + + X + + + ) + const ed = getByTestId('ed') as HTMLElement + + await waitFor(() => { + expect(ed.querySelector('[data-token-text="X"]')).toBeTruthy() + }) + + rerender( + {}} data-testid="ed"> + + X + + + ) + + await waitFor(() => { + expect(ed.querySelector('[data-token-text]')).toBeFalsy() + }) + }) +}) + +// External controlled updates +describe('Inlay external controlled updates', () => { + it('re-weaves tokens when parent changes value (appears/disappears)', async () => { + function Test() { + const [value, setValue] = React.useState('A') + ;(window as any).__setVal = setValue + return ( + + + @x + + + ) + } + + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + await waitFor(() => { + expect(ed.querySelector('[data-token-text]')).toBeFalsy() + }) + + await act(async () => { + ;(window as any).__setVal('@x') + await flush() + }) + + await waitFor(() => { + expect(ed.querySelector('[data-token-text="@x"]')).toBeTruthy() + }) + + await act(async () => { + ;(window as any).__setVal('B') + await flush() + }) + + await waitFor(() => { + expect(ed.querySelector('[data-token-text]')).toBeFalsy() + }) + }) + + it('context remains usable after external value change (can set selection and get active token)', async () => { + const ref = React.createRef() + function Test() { + const [value, setValue] = React.useState('@x') + ;(window as any).__setVal2 = setValue + return ( + + + @x + + + {({ activeToken }) => ( + + )} + + + ) + } + + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + await waitFor(() => { + expect(ed.querySelector('[data-token-text]')).toBeTruthy() + }) + + await act(async () => { + ;(window as any).__setVal2('A@xB') + await flush() + }) + + await waitFor(() => { + expect(ed.querySelector('[data-token-text]')).toBeTruthy() + }) + + await act(async () => { + ref.current!.setSelection(2) + await flush() + }) + + await waitFor(() => { + const ctx = getByTestId('ctx') + expect(ctx.getAttribute('data-start')).toBe('1') + expect(ctx.getAttribute('data-end')).toBe('3') + }) + }) +}) diff --git a/src/inlay/hooks/use-clipboard.ts b/src/inlay/hooks/use-clipboard.ts new file mode 100644 index 0000000..c2b70d4 --- /dev/null +++ b/src/inlay/hooks/use-clipboard.ts @@ -0,0 +1,182 @@ +import { useCallback, useRef } from 'react' +import { flushSync } from 'react-dom' +import { + getAbsoluteOffset, + setDomSelection, + getClosestTokenEl, + getTokenRawRange +} from '../internal/dom-utils' + +export type ClipboardConfig = { + editorRef: React.RefObject + getValue: () => string + setValue: React.Dispatch> + pushUndoSnapshot?: () => void + isComposingRef: React.MutableRefObject +} + +function getSelectionFromDom( + root: HTMLElement +): { start: number; end: number } | null { + const domSelection = window.getSelection() + if (!domSelection || !domSelection.rangeCount) return null + + const range = domSelection.getRangeAt(0) + if (!root.contains(range.startContainer)) return null + + let start = getAbsoluteOffset(root, range.startContainer, range.startOffset) + let end = getAbsoluteOffset(root, range.endContainer, range.endOffset) + + // Handle snapped offsets when DOM selection exists but offsets collapsed + if (start === end && !range.collapsed) { + const startToken = getClosestTokenEl(range.startContainer) + const endToken = getClosestTokenEl(range.endContainer) + + if (startToken && startToken === endToken) { + const tokenRange = getTokenRawRange(root, startToken) + if (tokenRange) return tokenRange + } else if (startToken) { + const tokenRange = getTokenRawRange(root, startToken) + if (tokenRange) { + start = tokenRange.start + end = tokenRange.end + } + } else if (endToken) { + const tokenRange = getTokenRawRange(root, endToken) + if (tokenRange) { + start = tokenRange.start + end = tokenRange.end + } + } + } + + // Expand partial token selections to full token boundaries + const startToken = getClosestTokenEl(range.startContainer) + if (startToken) { + const tokenRange = getTokenRawRange(root, startToken) + if (tokenRange && start > tokenRange.start && start < tokenRange.end) { + start = tokenRange.start + } + } + + const endToken = getClosestTokenEl(range.endContainer) + if (endToken) { + const tokenRange = getTokenRawRange(root, endToken) + if (tokenRange && end > tokenRange.start && end < tokenRange.end) { + end = tokenRange.end + } + } + + return { start, end } +} + +export function useClipboard(cfg: ClipboardConfig) { + // Track pending selection for rapid operations where DOM hasn't caught up yet + const pendingSelectionRef = useRef<{ start: number; end: number } | null>( + null + ) + const pendingClearTimeoutRef = useRef(null) + + const onCopy = useCallback( + (event: React.ClipboardEvent) => { + event.preventDefault() + + const root = cfg.editorRef.current + if (!root) return + + const sel = getSelectionFromDom(root) + if (!sel || sel.start === sel.end) return + + const rawText = cfg.getValue().slice(sel.start, sel.end) + event.clipboardData.setData('text/plain', rawText) + }, + [cfg] + ) + + const onCut = useCallback( + (event: React.ClipboardEvent) => { + event.preventDefault() + + const root = cfg.editorRef.current + if (!root) return + + const sel = getSelectionFromDom(root) + if (!sel || sel.start === sel.end) return + + const rawText = cfg.getValue().slice(sel.start, sel.end) + event.clipboardData.setData('text/plain', rawText) + + cfg.pushUndoSnapshot?.() + + cfg.setValue((currentValue) => { + const before = currentValue.slice(0, sel.start) + const after = currentValue.slice(sel.end) + + requestAnimationFrame(() => { + const root = cfg.editorRef.current + if (root?.isConnected) setDomSelection(root, sel.start) + }) + + return before + after + }) + }, + [cfg] + ) + + const onPaste = useCallback( + (event: React.ClipboardEvent) => { + if (cfg.isComposingRef.current) return + event.preventDefault() + + const pastedText = event.clipboardData.getData('text/plain') + if (!pastedText) return + + const root = cfg.editorRef.current + if (!root) return + + // Use pending selection if available (from rapid paste), otherwise read from DOM + const sel = pendingSelectionRef.current ?? getSelectionFromDom(root) + if (!sel) return + + cfg.pushUndoSnapshot?.() + + const len = cfg.getValue().length + const safeStart = Math.max(0, Math.min(sel.start, len)) + const newSelection = safeStart + pastedText.length + + // Update pending selection synchronously BEFORE state update + // so subsequent rapid pastes use the correct position + pendingSelectionRef.current = { start: newSelection, end: newSelection } + + // Use flushSync to ensure DOM is updated synchronously + flushSync(() => { + cfg.setValue((currentValue) => { + const len = currentValue.length + const safeStart = Math.max(0, Math.min(sel.start, len)) + const safeEnd = Math.max(0, Math.min(sel.end, len)) + const before = currentValue.slice(0, safeStart) + const after = currentValue.slice(safeEnd) + return before + pastedText + after + }) + }) + + // Set selection immediately after flushSync (DOM is now updated) + if (root.isConnected) { + setDomSelection(root, newSelection) + } + + // Clear pending selection after a short delay, giving time for + // rapid paste events to complete before falling back to DOM + if (pendingClearTimeoutRef.current !== null) { + clearTimeout(pendingClearTimeoutRef.current) + } + pendingClearTimeoutRef.current = window.setTimeout(() => { + pendingClearTimeoutRef.current = null + pendingSelectionRef.current = null + }, 100) + }, + [cfg] + ) + + return { onCopy, onCut, onPaste } +} diff --git a/src/inlay/hooks/use-composition.ts b/src/inlay/hooks/use-composition.ts new file mode 100644 index 0000000..309f6f1 --- /dev/null +++ b/src/inlay/hooks/use-composition.ts @@ -0,0 +1,164 @@ +import { useCallback, useRef, useState } from 'react' +import { getAbsoluteOffset, setDomSelection } from '../internal/dom-utils' + +// Platform detection for iOS-specific handling +const isIOS = + typeof navigator !== 'undefined' && + (/iPad|iPhone|iPod/.test(navigator.userAgent) || + (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)) + +export function useComposition( + editorRef: React.RefObject, + serializeRawFromDom: () => string, + handleSelectionChange: () => void, + setValue: (updater: (prev: string) => string) => void, + getCurrentValue: () => string +) { + const [isComposing, setIsComposing] = useState(false) + const [contentKey, setContentKey] = useState(0) + const isComposingRef = useRef(false) + const compositionStartSelectionRef = useRef<{ + start: number + end: number + } | null>(null) + const compositionInitialValueRef = useRef(null) + const suppressNextBeforeInputRef = useRef(false) + const suppressNextKeydownCommitRef = useRef(null) + const compositionCommitKeyRef = useRef<'enter' | 'space' | null>(null) + const compositionJustEndedAtRef = useRef(0) + // Track last composition data for iOS workaround + const lastCompositionDataRef = useRef('') + + const onCompositionStart = useCallback( + (event: React.CompositionEvent) => { + console.log('[compositionstart]', { + data: event.data + }) + if (!editorRef.current) return + isComposingRef.current = true + setIsComposing(true) + const sel = window.getSelection() + if (sel && sel.rangeCount > 0) { + const r = sel.getRangeAt(0) + const start = getAbsoluteOffset( + editorRef.current, + r.startContainer, + r.startOffset + ) + const end = getAbsoluteOffset( + editorRef.current, + r.endContainer, + r.endOffset + ) + compositionStartSelectionRef.current = { start, end } + } + compositionInitialValueRef.current = getCurrentValue() + }, + [editorRef, getCurrentValue] + ) + + const onCompositionUpdate = useCallback( + (event: React.CompositionEvent) => { + // iOS Safari sometimes doesn't include data in compositionend, + // so we track the last composition data during updates + const data = (event as unknown as { data?: string }).data + if (data) { + lastCompositionDataRef.current = data + } + }, + [] + ) + + const onCompositionEnd = useCallback( + (event: React.CompositionEvent) => { + const eventData = (event as unknown as { data?: string }).data + console.log('[compositionend]', { + data: eventData, + lastCompositionData: lastCompositionDataRef.current + }) + + const root = editorRef.current + if (!root) { + isComposingRef.current = false + setIsComposing(false) + return + } + suppressNextBeforeInputRef.current = true + console.log('[compositionend] setting suppressNextBeforeInput = true') + + // Build committed value + let committed = (event as unknown as { data?: string }).data || '' + + // iOS Safari workaround: compositionend may not include data, + // fall back to last tracked composition data from updates + if (!committed && isIOS && lastCompositionDataRef.current) { + committed = lastCompositionDataRef.current + } + + const baseValue = compositionInitialValueRef.current ?? getCurrentValue() + const range = compositionStartSelectionRef.current ?? { start: 0, end: 0 } + const len = baseValue.length + const safeStart = Math.max(0, Math.min(range.start, len)) + const safeEnd = Math.max(0, Math.min(range.end, len)) + const before = baseValue.slice(0, safeStart) + const after = baseValue.slice(safeEnd) + + if (!committed) { + const domText = serializeRawFromDom() + const replacedLen = safeEnd - safeStart + const insertedLen = Math.max( + 0, + domText.length - (baseValue.length - replacedLen) + ) + if (insertedLen > 0 && safeStart + insertedLen <= domText.length) { + committed = domText.slice(safeStart, safeStart + insertedLen) + } + } + + setValue(() => before + committed + after) + + // Force a remount to purge any transient IME DOM artifacts left behind + setContentKey((k) => k + 1) + + // One-shot suppress the immediate commit keydown regardless of engine + // Prefer the key recorded during composition; default to 'enter' + suppressNextKeydownCommitRef.current = + compositionCommitKeyRef.current ?? 'enter' + compositionJustEndedAtRef.current = Date.now() + + requestAnimationFrame(() => { + const r = editorRef.current + if (!r) return + setDomSelection(r, safeStart + committed.length) + handleSelectionChange() + }) + + isComposingRef.current = false + setIsComposing(false) + compositionInitialValueRef.current = null + compositionStartSelectionRef.current = null + compositionCommitKeyRef.current = null + lastCompositionDataRef.current = '' + }, + [ + editorRef, + getCurrentValue, + handleSelectionChange, + serializeRawFromDom, + setValue + ] + ) + + return { + isComposing, + isComposingRef, + suppressNextBeforeInputRef, + suppressNextKeydownCommitRef, + compositionCommitKeyRef, + compositionJustEndedAtRef, + contentKey, + onCompositionStart, + onCompositionUpdate, + onCompositionEnd + } +} diff --git a/src/inlay/hooks/use-history.ts b/src/inlay/hooks/use-history.ts new file mode 100644 index 0000000..74961bb --- /dev/null +++ b/src/inlay/hooks/use-history.ts @@ -0,0 +1,98 @@ +import { useCallback, useRef } from 'react' + +export type Snapshot = { + value: string + selection: { start: number; end: number } +} + +export function useHistory( + getCurrentSnapshot: () => Snapshot, + applySnapshot: (snap: Snapshot) => void, + maxHistory: number = 200 +) { + const undoStackRef = useRef([]) + const redoStackRef = useRef([]) + + const pushUndoSnapshot = useCallback(() => { + const snap = getCurrentSnapshot() + const stack = undoStackRef.current + if (stack.length >= maxHistory) stack.shift() + stack.push(snap) + // New edits invalidate redo history + redoStackRef.current = [] + }, [getCurrentSnapshot, maxHistory]) + + const editSessionRef = useRef<{ + type: 'insert' | 'delete' | null + timer: number | null + }>({ + type: null, + timer: null + }) + + const endEditSession = useCallback(() => { + const s = editSessionRef.current + if (s.timer != null) { + clearTimeout(s.timer) + } + editSessionRef.current = { type: null, timer: null } + }, []) + + const beginEditSession = useCallback( + (type: 'insert' | 'delete') => { + const s = editSessionRef.current + if (s.type !== type) { + // Different kind resets session + endEditSession() + } + if (editSessionRef.current.type === null) { + // Start of a new coalesced chunk: push snapshot + pushUndoSnapshot() + } + // Refresh session + const timer = window.setTimeout(() => { + endEditSession() + }, 800) + editSessionRef.current = { type, timer } + }, + [endEditSession, pushUndoSnapshot] + ) + + const undo = useCallback(() => { + const stack = undoStackRef.current + if (stack.length > 0) { + const current = getCurrentSnapshot() + const last = stack.pop()! + const redoStack = redoStackRef.current + if (redoStack.length >= maxHistory) redoStack.shift() + redoStack.push(current) + applySnapshot(last) + return true + } + return false + }, [applySnapshot, getCurrentSnapshot, maxHistory]) + + const redo = useCallback(() => { + const redoStack = redoStackRef.current + if (redoStack.length > 0) { + const current = getCurrentSnapshot() + const next = redoStack.pop()! + const undoStack = undoStackRef.current + if (undoStack.length >= maxHistory) undoStack.shift() + undoStack.push(current) + applySnapshot(next) + return true + } + return false + }, [applySnapshot, getCurrentSnapshot, maxHistory]) + + return { + undoStackRef, + redoStackRef, + pushUndoSnapshot, + beginEditSession, + endEditSession, + undo, + redo + } +} diff --git a/src/inlay/hooks/use-key-handlers.ts b/src/inlay/hooks/use-key-handlers.ts new file mode 100644 index 0000000..8b489dc --- /dev/null +++ b/src/inlay/hooks/use-key-handlers.ts @@ -0,0 +1,1013 @@ +import React, { useCallback, useEffect, useRef } from 'react' +import { + getAbsoluteOffset, + getClosestTokenEl, + setDomSelection, + serializeRawFromDom +} from '../internal/dom-utils' +import { + nextGraphemeEnd, + prevGraphemeStart, + snapGraphemeEnd, + snapGraphemeStart +} from '../internal/string-utils' + +const isJsdom = + typeof navigator !== 'undefined' && /jsdom/i.test(navigator.userAgent || '') + +// Platform detection for iOS-specific handling +const isIOS = + typeof navigator !== 'undefined' && + (/iPad|iPhone|iPod/.test(navigator.userAgent) || + (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)) + +function scheduleSelection(cb: () => void) { + if (isJsdom) { + setTimeout(cb, 0) + } else if (typeof requestAnimationFrame === 'function') { + requestAnimationFrame(cb) + } else { + setTimeout(cb, 0) + } +} + +// Timeout for pending selection validity (ms) +const PENDING_SELECTION_TIMEOUT = 100 + +// Timeout for iOS swipe-text word deletion detection (ms) +// When backspace is pressed at the end of a recent multi-char insert, delete the whole chunk. +// iOS has no timeout - we use a generous value to avoid false negatives while still +// clearing stale tracking eventually. The main protection is position matching. +const SWIPE_TEXT_DELETE_TIMEOUT = 30000 // 30 seconds + +export type KeyHandlersConfig = { + editorRef: React.RefObject + contentKey: number // Changes when editor element is recreated (e.g., after IME composition) + multiline: boolean + onKeyDownProp?: (event: React.KeyboardEvent) => boolean | void + beginEditSession: (type: 'insert' | 'delete') => void + endEditSession: () => void + pushUndoSnapshot: () => void + undo: () => boolean + redo: () => boolean + isComposingRef: React.MutableRefObject + compositionCommitKeyRef: React.MutableRefObject<'enter' | 'space' | null> + suppressNextBeforeInputRef: React.MutableRefObject + suppressNextKeydownCommitRef: React.MutableRefObject + compositionJustEndedAtRef: React.MutableRefObject + setValue: React.Dispatch> + valueRef: React.MutableRefObject // Current value for sync checks + getActiveToken: () => { start: number; end: number } | null +} + +function selectionIntersectsToken(editor: HTMLElement): boolean { + const sel = window.getSelection() + if (!sel || sel.rangeCount === 0) return false + const rng = sel.getRangeAt(0) + const tokens = editor.querySelectorAll('[data-token-text]') + for (let i = 0; i < tokens.length; i++) { + const el = tokens[i] + const rangeIntersects = rng.intersectsNode + if (typeof rangeIntersects === 'function') { + if (rangeIntersects.call(rng, el)) return true + } else { + const tr = document.createRange() + tr.selectNode(el) + const overlap = + rng.compareBoundaryPoints(Range.END_TO_START, tr) === 1 && + rng.compareBoundaryPoints(Range.START_TO_END, tr) === -1 + if (overlap) return true + } + } + return false +} + +export function useKeyHandlers(cfg: KeyHandlersConfig) { + // Track pending selection to avoid stale window.getSelection() during rapid input + const pendingSelectionRef = useRef<{ pos: number; time: number } | null>(null) + + // Track last multi-char insert for iOS swipe-text word deletion detection + // When user swipe-types a word and presses backspace, iOS only sends one + // deleteContentBackward event for the last char, but expects the whole word deleted + const lastMultiCharInsertRef = useRef<{ + start: number + end: number + data: string // Track the actual inserted text for space preservation + time: number + } | null>(null) + + // Track when we actually prevented a beforeinput event (for MutationObserver to know) + const lastPreventedTimeRef = useRef(0) + + // Handle beforeinput via native event listener (React's synthetic event is unreliable) + const handleBeforeInput = useCallback( + (event: InputEvent) => { + const { editorRef } = cfg + if (!editorRef.current) return + + // Helper to mark that we're handling this event (for input handler to know) + const preventAndMark = () => { + event.preventDefault() + lastPreventedTimeRef.current = Date.now() + } + + const data: string | null | undefined = event.data + const inputType: string | undefined = event.inputType + + if (cfg.suppressNextBeforeInputRef.current) { + cfg.suppressNextBeforeInputRef.current = false + preventAndMark() + return + } + + // Immediately after compositionend, WebKit may fire an extra beforeinput inserting a newline. + // Block it within a short window regardless of current composition state. + if ( + cfg.compositionJustEndedAtRef.current && + Date.now() - cfg.compositionJustEndedAtRef.current < 50 && + (inputType === 'insertParagraph' || inputType === 'insertLineBreak') + ) { + preventAndMark() + return + } + + if (cfg.isComposingRef.current) { + if ( + inputType === 'insertParagraph' || + inputType === 'insertLineBreak' + ) { + preventAndMark() + } + return + } + + // Handle text insertions (insertText and insertReplacementText) + if (inputType === 'insertText' || inputType === 'insertReplacementText') { + // iOS handling: Let iOS modify DOM directly (prevents multi-word suggestions) + // EXCEPT when: has newlines (React crash) or in token context (stale DOM attributes) + const hasNewlines = cfg.valueRef.current.includes('\n') + const domSelection = window.getSelection() + const range = domSelection?.rangeCount + ? domSelection.getRangeAt(0) + : null + const isInTokenContext = range + ? !!getClosestTokenEl(range.startContainer) + : false + + const insertData = + inputType === 'insertReplacementText' + ? (data ?? event.dataTransfer?.getData('text/plain')) + : data + + if (isIOS && !hasNewlines && !isInTokenContext) { + return + } + + if (isIOS && hasNewlines && !insertData) { + // iOS with newlines but no data available - can't handle safely + // This shouldn't normally happen, but if it does, prevent and skip + preventAndMark() + return + } + + preventAndMark() + + // insertData already obtained above + if (!insertData) return + + // For insertReplacementText, use target ranges + let start: number + let end: number + + if (inputType === 'insertReplacementText') { + const targetRanges = event.getTargetRanges?.() + if (!targetRanges || targetRanges.length === 0) return + const targetRange = targetRanges[0] + start = getAbsoluteOffset( + editorRef.current, + targetRange.startContainer, + targetRange.startOffset + ) + end = getAbsoluteOffset( + editorRef.current, + targetRange.endContainer, + targetRange.endOffset + ) + } else { + // insertText: use current selection + const pending = pendingSelectionRef.current + if ( + pending && + Date.now() - pending.time < PENDING_SELECTION_TIMEOUT + ) { + start = pending.pos + end = pending.pos + } else { + const domSelection = window.getSelection() + if (!domSelection || !domSelection.rangeCount) return + const range = domSelection.getRangeAt(0) + start = getAbsoluteOffset( + editorRef.current, + range.startContainer, + range.startOffset + ) + end = getAbsoluteOffset( + editorRef.current, + range.endContainer, + range.endOffset + ) + } + } + + cfg.beginEditSession('insert') + cfg.setValue((currentValue) => { + const len = currentValue.length + const safeStart = Math.max(0, Math.min(start, len)) + const safeEnd = Math.max(0, Math.min(end, len)) + const before = currentValue.slice(0, safeStart) + const after = currentValue.slice(safeEnd) + + // Strip leading space when: + // 1. Inserting after a space (prevent double-space) + // 2. Inserting at start of line (after newline) - iOS swipe often adds leading space + // 3. Inserting at start of content (empty before) + // + // iOS sometimes sends the space as a SEPARATE event before the word, + // so we need to handle both single-space and space-prefixed insertions. + let finalInsertData = insertData + const shouldStripSpace = + insertData.startsWith(' ') && + (before.endsWith(' ') || + before.endsWith('\n') || + before.length === 0) + + if (shouldStripSpace) { + if (insertData === ' ') { + // Single space at start of line - skip entirely + return currentValue + } + // Multi-char with leading space - strip the space + finalInsertData = insertData.slice(1) + } + + const newValue = before + finalInsertData + after + const newSelection = safeStart + finalInsertData.length + + // Track as multi-char insert for swipe-text backspace detection + // ONLY track insertText (swipe-typing), NOT insertReplacementText (autocomplete) + // When user types "hel" and taps "hello" suggestion, backspace should delete char-by-char + const isSwipeText = inputType === 'insertText' + + if (isSwipeText && finalInsertData.length > 1) { + lastMultiCharInsertRef.current = { + start: safeStart, + end: newSelection, + data: finalInsertData, + time: Date.now() + } + } else if ( + isSwipeText && + finalInsertData === ' ' && + lastMultiCharInsertRef.current + ) { + // Extend tracking if adding trailing space to multi-char insert + const lastInsert = lastMultiCharInsertRef.current + if (safeStart === lastInsert.end) { + lastMultiCharInsertRef.current = { + start: lastInsert.start, + end: newSelection, + data: lastInsert.data + ' ', + time: lastInsert.time + } + } else { + lastMultiCharInsertRef.current = null + } + } else { + // Autocomplete (insertReplacementText) or single char - clear swipe tracking + lastMultiCharInsertRef.current = null + } + + pendingSelectionRef.current = { pos: newSelection, time: Date.now() } + + scheduleSelection(() => { + const root = editorRef.current + if (!root || !root.isConnected) return + setDomSelection(root, newSelection) + }) + return newValue + }) + return + } + + // Handle various delete input types (including mobile-specific ones) + if ( + inputType === 'deleteContentBackward' || + inputType === 'deleteContentForward' || + inputType === 'deleteWordBackward' || + inputType === 'deleteWordForward' || + inputType === 'deleteSoftLineBackward' || + inputType === 'deleteSoftLineForward' + ) { + preventAndMark() + + // iOS swipe-text fix: Use targetRanges when available AND the nodes are still connected. + // After rapid beforeinput events (e.g., iOS word deletion), React re-renders replace + // text nodes, making subsequent targetRanges point to orphaned nodes. + // In that case, use pending selection which tracks the expected cursor position. + const targetRanges = event.getTargetRanges?.() + + let start: number + let end: number + + // Check if targetRanges point to nodes still in the DOM + const targetRange = targetRanges?.[0] + const targetNodesConnected = + targetRange && + editorRef.current?.contains(targetRange.startContainer) && + editorRef.current?.contains(targetRange.endContainer) + + if (targetRange && targetNodesConnected) { + start = getAbsoluteOffset( + editorRef.current, + targetRange.startContainer, + targetRange.startOffset + ) + end = getAbsoluteOffset( + editorRef.current, + targetRange.endContainer, + targetRange.endOffset + ) + } else { + // Fallback: Use pending selection (for rapid deletes) or window.getSelection() + const pending = pendingSelectionRef.current + if ( + pending && + Date.now() - pending.time < PENDING_SELECTION_TIMEOUT + ) { + // Use pending selection for rapid iOS word deletion + start = pending.pos + end = pending.pos + } else { + const domSelection = window.getSelection() + if (!domSelection || !domSelection.rangeCount) return + const range = domSelection.getRangeAt(0) + start = getAbsoluteOffset( + editorRef.current, + range.startContainer, + range.startOffset + ) + end = getAbsoluteOffset( + editorRef.current, + range.endContainer, + range.endOffset + ) + } + } + + // iOS swipe-text word deletion detection: + // When user swipe-types a word and presses backspace, iOS sends ONE + // deleteContentBackward event with targetRange for just the last char. + // But the user expects the whole word to be deleted. + // Detect this by checking if we're deleting at the end of a recent multi-char insert. + const lastInsert = lastMultiCharInsertRef.current + const timeSinceInsert = lastInsert ? Date.now() - lastInsert.time : null + const matchesEnd = lastInsert ? end === lastInsert.end : false + const withinTimeout = + timeSinceInsert !== null && + timeSinceInsert < SWIPE_TEXT_DELETE_TIMEOUT + + if ( + inputType === 'deleteContentBackward' && + lastInsert && + withinTimeout && + matchesEnd + ) { + // This looks like iOS swipe-text deletion - delete the whole chunk + // But if the insert started with a space (iOS auto-inserts space before swipe words), + // preserve that space - only delete the word part + // EXCEPT: if the character before the insert is also a space (double-space scenario), + // delete the leading space too to avoid leaving a double space + const startsWithSpace = lastInsert.data.startsWith(' ') + + cfg.setValue((currentValue) => { + const charBefore = + lastInsert.start > 0 + ? currentValue.charAt(lastInsert.start - 1) + : '' + const prevIsSpace = charBefore === ' ' + + // If starts with space AND prev char is NOT a space, preserve the space + // Otherwise, delete from the start (including the leading space) + const preserveLeadingSpace = startsWithSpace && !prevIsSpace + const deleteStart = preserveLeadingSpace + ? lastInsert.start + 1 + : lastInsert.start + + // Actually perform the deletion here since we need currentValue + const before = currentValue.slice(0, deleteStart) + const after = currentValue.slice(end) + const newValue = before + after + + pendingSelectionRef.current = { + pos: deleteStart, + time: Date.now() + } + + scheduleSelection(() => { + const root = editorRef.current + if (!root || !root.isConnected) return + setDomSelection(root, deleteStart) + }) + + return newValue + }) + + lastMultiCharInsertRef.current = null // Clear tracking + return // Early return since we handled the deletion + } + + cfg.beginEditSession('delete') + cfg.setValue((currentValue) => { + const len = currentValue.length + const safeStart = Math.max(0, Math.min(start, len)) + const safeEnd = Math.max(0, Math.min(end, len)) + let newSelection = safeStart + let before = '' + let after = '' + + // For deleteContentBackward: + // - If there's a range selection (safeStart !== safeEnd), delete the entire selection + // - If it's a collapsed cursor, delete one grapheme backward + if (inputType === 'deleteContentBackward') { + // Handle range selection (delete entire selected range) + if (safeStart !== safeEnd) { + if (selectionIntersectsToken(editorRef.current!)) { + before = currentValue.slice(0, safeStart) + after = currentValue.slice(safeEnd) + newSelection = safeStart + } else { + const adjStart = snapGraphemeStart(currentValue, safeStart) + const adjEnd = snapGraphemeEnd(currentValue, safeEnd) + before = currentValue.slice(0, adjStart) + after = currentValue.slice(adjEnd) + newSelection = adjStart + } + } else { + // Collapsed cursor - delete one char/grapheme backward + const cursorPos = safeEnd + if (cursorPos === 0) return currentValue + const active = cfg.getActiveToken() + const insideToken = !!( + active && + cursorPos > active.start && + cursorPos <= active.end + ) + // Clear swipe-text tracking when near a token + if (active) lastMultiCharInsertRef.current = null + if (insideToken) { + const delStart = cursorPos - 1 + before = currentValue.slice(0, delStart) + after = currentValue.slice(cursorPos) + newSelection = delStart + } else { + const clusterStart = prevGraphemeStart(currentValue, cursorPos) + before = currentValue.slice(0, clusterStart) + after = currentValue.slice(cursorPos) + newSelection = clusterStart + } + } + } else if (inputType === 'deleteContentForward') { + const cursorPos = safeStart + if (cursorPos === len) return currentValue + const active = cfg.getActiveToken() + const insideToken = !!( + active && + cursorPos >= active.start && + cursorPos < active.end + ) + if (insideToken) { + const delEnd = cursorPos + 1 + before = currentValue.slice(0, cursorPos) + after = currentValue.slice(delEnd) + newSelection = cursorPos + } else { + const clusterEnd = nextGraphemeEnd(currentValue, cursorPos) + before = currentValue.slice(0, cursorPos) + after = currentValue.slice(clusterEnd) + newSelection = cursorPos + } + } else { + // Other delete types (word, line) - use the provided range + if (selectionIntersectsToken(editorRef.current!)) { + before = currentValue.slice(0, safeStart) + after = currentValue.slice(safeEnd) + newSelection = safeStart + } else { + const adjStart = snapGraphemeStart(currentValue, safeStart) + const adjEnd = snapGraphemeEnd(currentValue, safeEnd) + before = currentValue.slice(0, adjStart) + after = currentValue.slice(adjEnd) + newSelection = adjStart + } + } + // Store pending selection for rapid input handling + pendingSelectionRef.current = { pos: newSelection, time: Date.now() } + + scheduleSelection(() => { + const root = editorRef.current + if (!root || !root.isConnected) return + setDomSelection(root, newSelection) + }) + return before + after + }) + return + } + }, + [cfg] + ) + + // Attach native beforeinput listener (React's synthetic event is unreliable for beforeinput) + // Re-attach when contentKey changes (editor element is recreated after IME composition) + useEffect(() => { + const editor = cfg.editorRef.current + if (!editor) return + + const beforeInputListener = (e: Event) => handleBeforeInput(e as InputEvent) + editor.addEventListener('beforeinput', beforeInputListener) + + // iOS DOM → React state sync: + // When we don't preventDefault on iOS text insertions, iOS modifies the DOM directly. + // We use the input event to sync the DOM content back to React state. + // This uses serializeRawFromDom to correctly handle token elements. + const inputListener = (e: Event) => { + if (!isIOS) return // Only needed for iOS + + const ie = e as InputEvent + const inputType = ie.inputType + + // Handle text insertions (we didn't preventDefault, iOS modified DOM) + if ( + inputType === 'insertText' || + inputType === 'insertReplacementText' || + inputType === 'insertFromPaste' || + inputType === 'insertFromDrop' + ) { + // Skip if we just prevented a beforeinput event (handled it ourselves) + const now = Date.now() + if (now - lastPreventedTimeRef.current < 50) { + return + } + + // Get the raw DOM content (before stripping invisible chars) + const rawDomContent = editor.innerText || '' + + // Serialize DOM to get the correct text value (respecting token data-attributes) + // This strips zero-width spaces and other invisible characters + const newValue = serializeRawFromDom(editor) + + // Get current cursor position from DOM BEFORE React re-renders + const domSelection = window.getSelection() + let cursorPos = newValue.length + if (domSelection && domSelection.rangeCount > 0) { + const range = domSelection.getRangeAt(0) + const rawCursorPos = getAbsoluteOffset( + editor, + range.startContainer, + range.startOffset + ) + + // Adjust cursor position for any invisible characters that were stripped + // Count how many invisible chars exist before the cursor in raw content + const beforeCursor = rawDomContent.slice(0, rawCursorPos) + const invisibleCharsBeforeCursor = ( + beforeCursor.match(/[\u200B\uFEFF]/g) || [] + ).length + cursorPos = Math.max( + 0, + Math.min(rawCursorPos - invisibleCharsBeforeCursor, newValue.length) + ) + } + + // Track multi-char inserts for iOS swipe-text word deletion + // We need to distinguish between: + // - Swipe-typing: User swipes across keyboard to type a whole word at once + // → Backspace should delete the whole word + // - Autocomplete/suggestion: User types partial word, taps suggestion + // → Backspace should delete char-by-char + // + // iOS may send insertText for both! So we can't rely on inputType alone. + // Better heuristic: swipe-typing inserts AFTER a space or at start. + // Autocomplete typically replaces text mid-word. + const isSwipeText = inputType === 'insertText' + + // Check if cursor is in a token - skip swipe-text tracking if so + const range = domSelection?.rangeCount + ? domSelection.getRangeAt(0) + : null + const isInTokenContext = range + ? !!getClosestTokenEl(range.startContainer) + : false + + // We need to track if we added a space so we can adjust cursor position + let addedSpace = false + + cfg.setValue((oldValue) => { + let finalValue = newValue + let adjustedCursorPos = cursorPos + const insertedLength = newValue.length - oldValue.length + const insertStart = cursorPos - insertedLength + + // iOS often adds a leading space to swipe-typed words. + // Strip it when inserting after a newline or at the start of content. + if (insertedLength > 1 && insertStart >= 0) { + const charBeforeInsert = + insertStart > 0 ? oldValue.charAt(insertStart - 1) : '' + const insertedData = newValue.slice(insertStart, cursorPos) + + // Strip leading space if: + // 1. The inserted text starts with a space + // 2. AND we're at start of content OR after a newline OR after an existing space + if ( + insertedData.startsWith(' ') && + (insertStart === 0 || + charBeforeInsert === '\n' || + charBeforeInsert === ' ') + ) { + // Remove the leading space from the inserted text + finalValue = + oldValue.slice(0, insertStart) + + insertedData.slice(1) + + oldValue.slice(insertStart) + adjustedCursorPos = cursorPos - 1 + } + } + + // Recalculate for the adjusted value + const adjustedInsertedLength = finalValue.length - oldValue.length + const adjustedInsertStart = adjustedCursorPos - adjustedInsertedLength + + // Swipe-text vs autocomplete detection: + // - Swipe-typing: after space/start → track for whole-word deletion + // - Autocomplete: mid-word extension → char-by-char deletion + // - Token context: always char-by-char deletion + if (isInTokenContext) { + lastMultiCharInsertRef.current = null + } else if ( + isSwipeText && + adjustedInsertedLength > 1 && + adjustedInsertStart >= 0 + ) { + const lastCharOfOld = + oldValue.length > 0 ? oldValue.charAt(oldValue.length - 1) : '' + const endsWithWordChar = + lastCharOfOld && lastCharOfOld !== ' ' && lastCharOfOld !== '\n' + + // If oldValue ends mid-word, this is likely autocomplete + // (user typed "hel" and tapped "hello" to extend it) + if (endsWithWordChar) { + // Autocomplete - don't track for whole-word deletion + lastMultiCharInsertRef.current = null + } else { + // Swipe-typing (oldValue ends with space, newline, or is empty) + const charBeforeAdjustedInsert = + adjustedInsertStart > 0 + ? oldValue.charAt(adjustedInsertStart - 1) + : '' + const adjustedInsertedData = finalValue.slice( + adjustedInsertStart, + adjustedCursorPos + ) + + if ( + adjustedInsertStart > 0 && + charBeforeAdjustedInsert && + charBeforeAdjustedInsert !== ' ' && + charBeforeAdjustedInsert !== '\n' && + !adjustedInsertedData.startsWith(' ') + ) { + // Need to add a space before the inserted word + finalValue = + oldValue.slice(0, adjustedInsertStart) + + ' ' + + adjustedInsertedData + + oldValue.slice(adjustedInsertStart) + addedSpace = true + + // Track with the added space + lastMultiCharInsertRef.current = { + start: adjustedInsertStart, + end: adjustedCursorPos + 1, + data: ' ' + adjustedInsertedData, + time: Date.now() + } + } else { + // Normal swipe - track for whole-word deletion + lastMultiCharInsertRef.current = { + start: adjustedInsertStart, + end: adjustedCursorPos, + data: adjustedInsertedData, + time: Date.now() + } + } + } + } else if (isSwipeText && adjustedInsertedLength === 1) { + // Single char from swipe - check if it's a trailing space after a swipe + const insertedChar = finalValue.charAt(adjustedCursorPos - 1) + const lastInsert = lastMultiCharInsertRef.current + if ( + insertedChar === ' ' && + lastInsert && + adjustedInsertStart === lastInsert.end + ) { + // Extend the tracking to include the trailing space + lastMultiCharInsertRef.current = { + start: lastInsert.start, + end: adjustedCursorPos, + data: lastInsert.data + ' ', + time: lastInsert.time + } + } else { + // Regular single char, clear tracking + lastMultiCharInsertRef.current = null + } + } else if (!isSwipeText) { + // Autocomplete/suggestion (insertReplacementText) - clear swipe tracking + // User should be able to backspace char-by-char + lastMultiCharInsertRef.current = null + } + + // Update pending selection for rapid input handling + const finalCursorPos = addedSpace + ? adjustedCursorPos + 1 + : adjustedCursorPos + pendingSelectionRef.current = { + pos: finalCursorPos, + time: Date.now() + } + + return finalValue + }) + + // Restore caret position after React re-renders + // Use pendingSelectionRef since the actual position is calculated inside setValue + scheduleSelection(() => { + const root = cfg.editorRef.current + if (!root || !root.isConnected) return + const pending = pendingSelectionRef.current + const pos = pending ? pending.pos : cursorPos + setDomSelection(root, pos) + }) + } + + // After deletions, sync DOM → React to ensure they stay in sync + // This catches any cases where our deletion logic didn't perfectly match iOS's DOM state + if ( + inputType === 'deleteContentBackward' || + inputType === 'deleteContentForward' || + inputType === 'deleteWordBackward' || + inputType === 'deleteWordForward' || + inputType === 'deleteSoftLineBackward' || + inputType === 'deleteSoftLineForward' + ) { + // Small delay to let our beforeinput handler complete first + setTimeout(() => { + const root = cfg.editorRef.current + if (!root || !root.isConnected) return + + const domValue = serializeRawFromDom(root) + cfg.setValue((currentValue) => { + // Only sync if they're different (our handler might have already set it correctly) + if (currentValue !== domValue) { + return domValue + } + return currentValue + }) + }, 0) + } + } + editor.addEventListener('input', inputListener) + + return () => { + editor.removeEventListener('beforeinput', beforeInputListener) + editor.removeEventListener('input', inputListener) + } + }, [cfg.editorRef, cfg.contentKey, handleBeforeInput, cfg.setValue]) + + // Keep onBeforeInput as a no-op for backward compatibility (actual handling is via native listener) + const onBeforeInput = useCallback(() => { + // No-op: beforeinput is handled via native event listener + }, []) + + const onKeyDown = useCallback( + (event: React.KeyboardEvent) => { + // One-shot suppression for the immediate keydown fired after compositionend (WebKit bug) + if (cfg.suppressNextKeydownCommitRef.current) { + const sup = cfg.suppressNextKeydownCommitRef.current + const isEnter = event.key === 'Enter' || event.key === 'Return' + const isSpace = event.key === ' ' + if ((sup === 'enter' && isEnter) || (sup === 'space' && isSpace)) { + event.preventDefault() + event.stopPropagation() + cfg.suppressNextKeydownCommitRef.current = null + return + } + // Clear suppression if next key is different + cfg.suppressNextKeydownCommitRef.current = null + } + + if (cfg.onKeyDownProp?.(event)) return + + if (!cfg.multiline && event.key === 'Enter') { + event.preventDefault() + return + } + + if ( + event.key.startsWith('Arrow') || + event.metaKey || + event.ctrlKey || + event.altKey + ) { + if (!(event.key === 'Shift')) cfg.endEditSession() + } + + if ((event.metaKey || event.ctrlKey) && !event.altKey) { + const isUndo = event.key.toLowerCase() === 'z' && !event.shiftKey + const isRedo = + (event.key.toLowerCase() === 'z' && event.shiftKey) || + event.key.toLowerCase() === 'y' + if (isUndo) { + if (cfg.undo()) { + event.preventDefault() + return + } + } else if (isRedo) { + if (cfg.redo()) { + event.preventDefault() + return + } + } + } + + const { editorRef } = cfg + if (!editorRef.current) return + const domSelection = window.getSelection() + if (!domSelection || !domSelection.rangeCount) return + + const range = domSelection.getRangeAt(0) + const start = getAbsoluteOffset( + editorRef.current, + range.startContainer, + range.startOffset + ) + + if (cfg.isComposingRef.current) { + if (event.key === 'Enter' || event.key === 'Return') { + event.preventDefault() + event.stopPropagation() + cfg.compositionCommitKeyRef.current = 'enter' + return + } + if (event.key === ' ') { + event.preventDefault() + event.stopPropagation() + cfg.compositionCommitKeyRef.current = 'space' + return + } + return + } + + if (event.key === 'Enter') { + event.preventDefault() + const end = getAbsoluteOffset( + editorRef.current, + range.endContainer, + range.endOffset + ) + cfg.pushUndoSnapshot() + cfg.endEditSession() + cfg.setValue((currentValue) => { + const len = currentValue.length + const safeStart = Math.max(0, Math.min(start, len)) + const safeEnd = Math.max(0, Math.min(end, len)) + const before = currentValue.slice(0, safeStart) + const after = currentValue.slice(safeEnd) + const newValue = before + '\n' + after + const newSelection = safeStart + 1 + scheduleSelection(() => { + const root = editorRef.current + if (!root || !root.isConnected) return + setDomSelection(root, newSelection) + + // iOS fix: Reset autocomplete state after newline. + // iOS sometimes doesn't see as a word boundary and suggests + // merged words like "helloworld" instead of treating them separately. + // Toggle autocomplete attribute to reset iOS's autocomplete context + // without dismissing the keyboard (unlike blur/focus). + if (isIOS) { + const currentAutocomplete = root.getAttribute('autocomplete') + root.setAttribute('autocomplete', 'off') + requestAnimationFrame(() => { + if (root.isConnected) { + if (currentAutocomplete) { + root.setAttribute('autocomplete', currentAutocomplete) + } else { + root.removeAttribute('autocomplete') + } + } + }) + } + }) + return newValue + }) + } + + // On iOS, don't intercept space - let it flow to beforeinput/input + // so that iOS multi-word keyboard suggestions can work. + // On desktop, handle space directly for consistent behavior. + if (event.key === ' ' && !isIOS) { + event.preventDefault() + cfg.beginEditSession('insert') + cfg.setValue((currentValue) => { + const len = currentValue.length + const safeStart = Math.max(0, Math.min(start, len)) + const safeEnd = Math.max(0, Math.min(start, len)) + const before = currentValue.slice(0, safeStart) + const after = currentValue.slice(safeEnd) + const newValue = before + ' ' + after + const newSelection = safeStart + 1 + scheduleSelection(() => { + const root = editorRef.current + if (!root || !root.isConnected) return + setDomSelection(root, newSelection) + }) + return newValue + }) + } + + // Backspace is handled by beforeinput (deleteContentBackward) to support + // iOS swipe-text word deletion which fires multiple beforeinput events. + // Not calling preventDefault here allows beforeinput to fire. + + if (event.key === 'Delete') { + event.preventDefault() + cfg.beginEditSession('delete') + cfg.setValue((currentValue) => { + if (!currentValue) return '' + const len = currentValue.length + const safeStart = Math.max(0, Math.min(start, len)) + let newSelection = safeStart + let before: string + let after: string + if (range.collapsed) { + if (safeStart === len) return currentValue + const active = cfg.getActiveToken() + const insideToken = !!( + active && + safeStart >= active.start && + safeStart < active.end + ) + if (insideToken) { + const delEnd = safeStart + 1 + before = currentValue.slice(0, safeStart) + after = currentValue.slice(delEnd) + newSelection = safeStart + } else { + const clusterEnd = nextGraphemeEnd(currentValue, safeStart) + before = currentValue.slice(0, safeStart) + after = currentValue.slice(clusterEnd) + newSelection = safeStart + } + } else { + const end = getAbsoluteOffset( + editorRef.current!, + range.endContainer, + range.endOffset + ) + const safeEnd = Math.max(0, Math.min(end, len)) + if (selectionIntersectsToken(editorRef.current!)) { + before = currentValue.slice(0, safeStart) + after = currentValue.slice(safeEnd) + newSelection = safeStart + } else { + const adjStart = snapGraphemeStart(currentValue, safeStart) + const adjEnd = snapGraphemeEnd(currentValue, safeEnd) + before = currentValue.slice(0, adjStart) + after = currentValue.slice(adjEnd) + newSelection = adjStart + } + } + scheduleSelection(() => { + const root = editorRef.current + if (!root || !root.isConnected) return + setDomSelection(root, newSelection) + }) + return before + after + }) + } + }, + [cfg] + ) + + return { onBeforeInput, onKeyDown } +} diff --git a/src/inlay/hooks/use-placeholder-sync.ts b/src/inlay/hooks/use-placeholder-sync.ts new file mode 100644 index 0000000..480c5e2 --- /dev/null +++ b/src/inlay/hooks/use-placeholder-sync.ts @@ -0,0 +1,38 @@ +import { useLayoutEffect } from 'react' + +export function usePlaceholderSync( + editorRef: React.RefObject, + placeholderRef: React.RefObject, + deps: unknown[] +) { + useLayoutEffect(() => { + if (editorRef.current && placeholderRef.current) { + const editorStyles = window.getComputedStyle(editorRef.current) + const stylesToCopy: (keyof CSSStyleDeclaration)[] = [ + 'paddingTop', + 'paddingRight', + 'paddingBottom', + 'paddingLeft', + 'borderTopWidth', + 'borderRightWidth', + 'borderBottomWidth', + 'borderLeftWidth', + 'fontFamily', + 'fontSize', + 'lineHeight', + 'letterSpacing', + 'textAlign' + ] + + stylesToCopy.forEach((styleName) => { + const v = editorStyles[styleName] + if (v !== null) { + // @ts-expect-error - Style name is a valid CSSStyleDeclaration property + placeholderRef.current!.style[styleName] = v as string + } + }) + placeholderRef.current!.style.borderStyle = editorStyles.borderStyle + placeholderRef.current!.style.borderColor = 'transparent' + } + }, deps) +} diff --git a/src/inlay/hooks/use-selection-snap.ts b/src/inlay/hooks/use-selection-snap.ts new file mode 100644 index 0000000..5c5661d --- /dev/null +++ b/src/inlay/hooks/use-selection-snap.ts @@ -0,0 +1,175 @@ +import { useCallback } from 'react' +import { getAbsoluteOffset, setDomSelection } from '../internal/dom-utils' + +export type SelectionSnapConfig = { + editorRef: React.RefObject + setSelection: (sel: { start: number; end: number }) => void + lastAnchorRectRef: React.MutableRefObject + suppressNextSelectionAdjustRef: React.MutableRefObject + lastArrowDirectionRef: React.MutableRefObject< + 'left' | 'right' | 'up' | 'down' | null + > + lastShiftRef: React.MutableRefObject + isComposingRef: React.MutableRefObject +} + +export function useSelectionSnap(cfg: SelectionSnapConfig) { + const onSelect = useCallback(() => { + const root = cfg.editorRef.current + if (!root) return + + if (cfg.suppressNextSelectionAdjustRef.current) { + cfg.suppressNextSelectionAdjustRef.current = false + } + + const domSelection = window.getSelection() + if (!domSelection || !domSelection.rangeCount) return + + const range = domSelection.getRangeAt(0) + const clientRect = + range.getClientRects()[0] || range.getBoundingClientRect() + if (!(clientRect.x === 0 && clientRect.y === 0)) { + cfg.lastAnchorRectRef.current = new DOMRect( + clientRect.x, + clientRect.y, + clientRect.width, + clientRect.height + ) + } + + if (!root.contains(range.startContainer)) return + + const start = getAbsoluteOffset( + root, + range.startContainer, + range.startOffset + ) + const end = getAbsoluteOffset(root, range.endContainer, range.endOffset) + cfg.setSelection({ start, end }) + + if (cfg.isComposingRef.current) { + cfg.lastArrowDirectionRef.current = null + cfg.lastShiftRef.current = false + return + } + + const direction = cfg.lastArrowDirectionRef.current + const isShift = cfg.lastShiftRef.current + if (direction) { + requestAnimationFrame(() => { + const root = cfg.editorRef.current + if (!root) return + const sel = window.getSelection() + if (!sel || sel.rangeCount === 0) return + const rng = sel.getRangeAt(0) + + const getClosestTokenEl = (n: Node | null): HTMLElement | null => { + let curr: Node | null = n + while (curr) { + if (curr.nodeType === Node.ELEMENT_NODE) { + const asEl = curr as HTMLElement + if (asEl.hasAttribute('data-token-text')) return asEl + } + curr = curr.parentNode || null + } + return null + } + + const renderedLen = (el: Element): number => { + const walker = document.createTreeWalker( + el, + NodeFilter.SHOW_TEXT, + null + ) + let total = 0 + let n: Node | null + while ((n = walker.nextNode())) total += (n.textContent || '').length + return total + } + const rawLen = (el: Element): number => + (el.getAttribute('data-token-text') || '').length + + const findFirstTextNode = (el: Element): ChildNode | null => { + const walker = document.createTreeWalker( + el, + NodeFilter.SHOW_TEXT, + null + ) + return walker.nextNode() as ChildNode | null + } + const findLastTextNode = (el: Element): ChildNode | null => { + const walker = document.createTreeWalker( + el, + NodeFilter.SHOW_TEXT, + null + ) + let last: Node | null = null + let n: Node | null + while ((n = walker.nextNode())) last = n + return last as ChildNode | null + } + + const snapEdgeForToken = ( + tokenEl: Element, + prefer: 'start' | 'end' + ): number | null => { + if (!root) return null + if (prefer === 'start') { + const first = findFirstTextNode(tokenEl) + if (first) return getAbsoluteOffset(root, first, 0) + return null + } else { + const last = findLastTextNode(tokenEl) + if (last) { + const len = (last.textContent || '').length + return getAbsoluteOffset(root, last, len) + } + return null + } + } + + const arrowToEdge = (dir: typeof direction): 'start' | 'end' => + dir === 'left' || dir === 'up' ? 'start' : 'end' + + const tokenEl = getClosestTokenEl(rng.startContainer) + if (!tokenEl) { + cfg.lastArrowDirectionRef.current = null + cfg.lastShiftRef.current = false + return + } + + const isDiverged = renderedLen(tokenEl) !== rawLen(tokenEl) + if (!isDiverged) { + cfg.lastArrowDirectionRef.current = null + cfg.lastShiftRef.current = false + return + } + + if (!isShift) { + if (!rng.collapsed) return + const edge = arrowToEdge(direction) + const target = snapEdgeForToken(tokenEl, edge) + if (target == null) return + cfg.suppressNextSelectionAdjustRef.current = true + setDomSelection(root, target) + } else { + const anchorNode = sel.anchorNode + const anchorOffset = sel.anchorOffset + const edge = arrowToEdge(direction) + const focusRaw = snapEdgeForToken(tokenEl, edge) + if (focusRaw == null || !anchorNode) return + const anchorRaw = getAbsoluteOffset(root, anchorNode, anchorOffset) + const startRaw = Math.min(anchorRaw, focusRaw) + const endRaw = Math.max(anchorRaw, focusRaw) + cfg.suppressNextSelectionAdjustRef.current = true + setDomSelection(root, startRaw, endRaw) + } + + cfg.lastArrowDirectionRef.current = null + cfg.lastShiftRef.current = false + }) + } + }, [cfg]) + + return { onSelect } +} diff --git a/src/inlay/hooks/use-selection.ts b/src/inlay/hooks/use-selection.ts new file mode 100644 index 0000000..ff0690b --- /dev/null +++ b/src/inlay/hooks/use-selection.ts @@ -0,0 +1,175 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { getAbsoluteOffset, setDomSelection } from '../internal/dom-utils' +import { snapGraphemeEnd, snapGraphemeStart } from '../internal/string-utils' + +export type SelectionState = { start: number; end: number } + +// Detect iOS Safari - includes modern iPads that report as "MacIntel" with touch +const isIOS = + typeof navigator !== 'undefined' && + (/iPad|iPhone|iPod/.test(navigator.userAgent) || + (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)) + +export function useSelection( + editorRef: React.RefObject, + value: string +) { + const [selection, setSelection] = useState({ + start: 0, + end: 0 + }) + const lastAnchorRectRef = useRef(new DOMRect(0, 0, 0, 0)) + const virtualAnchorRef = useRef({ + getBoundingClientRect: () => lastAnchorRectRef.current + }) + const suppressNextSelectionAdjustRef = useRef(false) + + // Update just the anchor rect from current selection + const updateAnchorRect = useCallback(() => { + const domSelection = window.getSelection() + if (!domSelection || !domSelection.rangeCount) return + + const range = domSelection.getRangeAt(0) + const clientRect = + range.getClientRects()[0] || range.getBoundingClientRect() + if (!(clientRect.x === 0 && clientRect.y === 0)) { + lastAnchorRectRef.current = new DOMRect( + clientRect.x, + clientRect.y, + clientRect.width, + clientRect.height + ) + } + }, []) + + const handleSelectionChange = useCallback(() => { + if (!editorRef.current) return + + // Avoid feedback loop after we programmatically set selection + if (suppressNextSelectionAdjustRef.current) { + suppressNextSelectionAdjustRef.current = false + } + + const domSelection = window.getSelection() + if (!domSelection || !domSelection.rangeCount) return + + const range = domSelection.getRangeAt(0) + + // Update anchor rect + updateAnchorRect() + + if (!editorRef.current.contains(range.startContainer)) { + return + } + + const start = getAbsoluteOffset( + editorRef.current, + range.startContainer, + range.startOffset + ) + const end = getAbsoluteOffset( + editorRef.current, + range.endContainer, + range.endOffset + ) + setSelection({ start, end }) + }, [editorRef, updateAnchorRect]) + + const setSelectionImperative = useCallback( + (start: number, end?: number) => { + const root = editorRef.current + if (!root) return + const s = value + const rawStart = Math.max(0, Math.min(start, s.length)) + const rawEnd = + end != null ? Math.max(0, Math.min(end, s.length)) : rawStart + const a = Math.min(rawStart, rawEnd) + const b = Math.max(rawStart, rawEnd) + const snappedStart = snapGraphemeStart(s, a) + const snappedEnd = end != null ? snapGraphemeEnd(s, b) : snappedStart + suppressNextSelectionAdjustRef.current = true + setDomSelection(root, snappedStart, snappedEnd) + handleSelectionChange() + }, + [editorRef, handleSelectionChange, value] + ) + + // iOS Safari fires selectionchange on document, not the element. + // This listener ensures we capture selection changes on mobile Safari. + useEffect(() => { + const handleDocumentSelectionChange = () => { + const root = editorRef.current + if (!root) return + + // Only process if our editor is focused or contains the selection + const domSelection = window.getSelection() + if (!domSelection || !domSelection.rangeCount) return + + const range = domSelection.getRangeAt(0) + if (!root.contains(range.startContainer)) return + + // Delegate to our existing handler + handleSelectionChange() + } + + document.addEventListener('selectionchange', handleDocumentSelectionChange) + return () => { + document.removeEventListener( + 'selectionchange', + handleDocumentSelectionChange + ) + } + }, [editorRef, handleSelectionChange]) + + // iOS: Update anchor rect after input/viewport changes (caret rect can be stale) + useEffect(() => { + if (!isIOS) return + + let rafId: number | null = null + const deferredUpdate = () => { + if (rafId !== null) cancelAnimationFrame(rafId) + rafId = requestAnimationFrame(() => { + rafId = null + updateAnchorRect() + }) + } + + const handleInput = (e: Event) => { + const root = editorRef.current + if (root?.contains(e.target as Node)) deferredUpdate() + } + + const handleViewportChange = () => { + const root = editorRef.current + if ( + root && + (document.activeElement === root || + root.contains(document.activeElement)) + ) { + deferredUpdate() + } + } + + document.addEventListener('input', handleInput, true) + const vv = window.visualViewport + vv?.addEventListener('resize', handleViewportChange) + vv?.addEventListener('scroll', handleViewportChange) + + return () => { + if (rafId !== null) cancelAnimationFrame(rafId) + document.removeEventListener('input', handleInput, true) + vv?.removeEventListener('resize', handleViewportChange) + vv?.removeEventListener('scroll', handleViewportChange) + } + }, [editorRef, updateAnchorRect]) + + return { + selection, + setSelection, + setSelectionImperative, + handleSelectionChange, + lastAnchorRectRef, + virtualAnchorRef, + suppressNextSelectionAdjustRef + } +} diff --git a/src/inlay/hooks/use-token-weaver.tsx b/src/inlay/hooks/use-token-weaver.tsx new file mode 100644 index 0000000..cb9ad52 --- /dev/null +++ b/src/inlay/hooks/use-token-weaver.tsx @@ -0,0 +1,187 @@ +import React, { + useCallback, + useLayoutEffect, + useMemo, + useRef, + useState +} from 'react' + +export type TokenState = { + isCollapsed: boolean + isAtStartOfToken: boolean + isAtEndOfToken: boolean +} + +export type TokenInfo = { + text: string + node: React.ReactElement + start: number + end: number +} + +export function useTokenWeaver( + value: string, + selection: { start: number; end: number }, + multiline: boolean, + children: React.ReactNode +) { + const [isRegistered, setIsRegistered] = useState(false) + const tokenRegistry = useRef<{ text: string; node: React.ReactElement }[]>([]) + const activeTokenRef = useRef(null) + + if (!isRegistered) tokenRegistry.current = [] + + const registerToken = useCallback( + (token: { text: string; node: React.ReactElement }) => { + tokenRegistry.current.push(token) + }, + [] + ) + + useLayoutEffect(() => { + setIsRegistered(false) + }, [children]) + + useLayoutEffect(() => { + if (!isRegistered) setIsRegistered(true) + }, [isRegistered]) + + const { weavedChildren, activeToken, activeTokenState } = useMemo(() => { + if (!isRegistered) { + return { + weavedChildren: null as React.ReactNode, + activeToken: activeTokenRef.current, + activeTokenState: null as TokenState | null + } + } + + const isStale = tokenRegistry.current.some( + (token) => value.indexOf(token.text) === -1 + ) + if (isStale) { + return { + weavedChildren: null as React.ReactNode, + activeToken: activeTokenRef.current, + activeTokenState: null as TokenState | null + } + } + + // Empty state: render zero-width space to maintain consistent caret height + if (value.length === 0) { + return { + weavedChildren: {'\u200B'}, + activeToken: null, + activeTokenState: null as TokenState | null + } + } + + // Robust sorting to handle duplicate tokens + const sortedTokens: { text: string; node: React.ReactElement }[] = [] + const tokenPool = [...tokenRegistry.current] + let searchIndex = 0 + while (searchIndex < value.length && tokenPool.length > 0) { + let foundToken = false + for (let i = 0; i < tokenPool.length; i++) { + const token = tokenPool[i] + if (value.startsWith(token.text, searchIndex)) { + sortedTokens.push(token) + tokenPool.splice(i, 1) + searchIndex += token.text.length + foundToken = true + break + } + } + if (!foundToken) searchIndex++ + } + + const result: React.ReactNode[] = [] + const map: TokenInfo[] = [] + let currentIndex = 0 + if (sortedTokens.length === 0) { + const nodes: React.ReactNode[] = [{value}] + if (multiline && value.endsWith('\n')) + nodes.push() + const tokenMap = [ + { text: value, node: , start: 0, end: value.length } + ] + const active = + tokenMap.find( + (t) => selection.start >= t.start && selection.end <= t.end + ) || null + return { + weavedChildren: nodes, + activeToken: active, + activeTokenState: null as TokenState | null + } + } + + for (const token of sortedTokens) { + const tokenStartIndex = value.indexOf(token.text, currentIndex) + if (tokenStartIndex === -1) continue + if (tokenStartIndex > currentIndex) { + const spacerText = value.slice(currentIndex, tokenStartIndex) + result.push({spacerText}) + map.push({ + text: spacerText, + node: , + start: currentIndex, + end: tokenStartIndex + }) + } + const tokenWithKey = React.cloneElement(token.node, { + key: `token-${currentIndex}` + }) + result.push(tokenWithKey) + map.push({ + text: token.text, + node: token.node, + start: tokenStartIndex, + end: tokenStartIndex + token.text.length + }) + currentIndex = tokenStartIndex + token.text.length + } + + if (currentIndex < value.length) { + const trailingSpacer = value.slice(currentIndex) + result.push({trailingSpacer}) + map.push({ + text: trailingSpacer, + node: , + start: currentIndex, + end: value.length + }) + } + + if (multiline && value.endsWith('\n')) { + result.push() + } + + const active = + map.find((t) => selection.start >= t.start && selection.end <= t.end) || + null + + let activeTokenState: TokenState | null = null + if (active) { + const isCollapsed = selection.start === selection.end + activeTokenState = { + isCollapsed, + isAtStartOfToken: isCollapsed && selection.start === active.start, + isAtEndOfToken: isCollapsed && selection.end === active.end + } + } + + activeTokenRef.current = active + + return { weavedChildren: result, activeToken: active, activeTokenState } + }, [value, isRegistered, selection, multiline]) + + return { + isRegistered, + setIsRegistered, + registerToken, + weavedChildren, + activeToken, + activeTokenRef, + activeTokenState + } +} diff --git a/src/inlay/hooks/use-touch-selection.ts b/src/inlay/hooks/use-touch-selection.ts new file mode 100644 index 0000000..eb294d8 --- /dev/null +++ b/src/inlay/hooks/use-touch-selection.ts @@ -0,0 +1,144 @@ +import { useCallback, useRef } from 'react' +import { getClosestTokenEl } from '../internal/dom-utils' + +export type TouchSelectionConfig = { + editorRef: React.RefObject + handleSelectionChange: () => void + isComposingRef: React.MutableRefObject +} + +/** + * Handles touch-based selection interactions on mobile devices. + * - Debounces rapid touch events + * - Snaps selection to token boundaries when touching inside tokens + * - Handles long-press detection for native selection mode + */ +export function useTouchSelection(cfg: TouchSelectionConfig) { + const touchStartPosRef = useRef<{ x: number; y: number } | null>(null) + const longPressTimeoutRef = useRef(null) + const isTouchActiveRef = useRef(false) + + const clearLongPressTimeout = useCallback(() => { + if (longPressTimeoutRef.current !== null) { + clearTimeout(longPressTimeoutRef.current) + longPressTimeoutRef.current = null + } + }, []) + + const onTouchStart = useCallback( + (event: React.TouchEvent) => { + if (cfg.isComposingRef.current) return + + const touch = event.touches[0] + if (!touch) return + + touchStartPosRef.current = { x: touch.clientX, y: touch.clientY } + isTouchActiveRef.current = true + + // Long-press detection (500ms) - triggers native selection mode + // We don't prevent this; we just use it to know user wants selection + clearLongPressTimeout() + longPressTimeoutRef.current = window.setTimeout(() => { + // After long press, let native selection handle it + // We'll sync state in touchend or selectionchange + longPressTimeoutRef.current = null + }, 500) + }, + [cfg.isComposingRef, clearLongPressTimeout] + ) + + const onTouchMove = useCallback( + (event: React.TouchEvent) => { + if (!isTouchActiveRef.current) return + + const touch = event.touches[0] + if (!touch || !touchStartPosRef.current) return + + // If user moves finger significantly, cancel long-press + const dx = Math.abs(touch.clientX - touchStartPosRef.current.x) + const dy = Math.abs(touch.clientY - touchStartPosRef.current.y) + if (dx > 10 || dy > 10) { + clearLongPressTimeout() + } + }, + [clearLongPressTimeout] + ) + + const onTouchEnd = useCallback( + (event: React.TouchEvent) => { + clearLongPressTimeout() + isTouchActiveRef.current = false + touchStartPosRef.current = null + + if (cfg.isComposingRef.current) return + + const root = cfg.editorRef.current + if (!root) return + + // Check if touch ended inside a token + const changedTouch = event.changedTouches[0] + if (!changedTouch) return + + const elementAtPoint = document.elementFromPoint( + changedTouch.clientX, + changedTouch.clientY + ) + + if (elementAtPoint && root.contains(elementAtPoint)) { + const tokenEl = getClosestTokenEl(elementAtPoint) + + if (tokenEl) { + // If inside a token, snap selection to token boundary + // Let the native selection happen first, then sync + requestAnimationFrame(() => { + cfg.handleSelectionChange() + }) + } else { + // Normal text area - just sync selection + requestAnimationFrame(() => { + cfg.handleSelectionChange() + }) + } + } + }, + [cfg, clearLongPressTimeout] + ) + + return { + onTouchStart, + onTouchMove, + onTouchEnd + } +} + +/** + * Utility to detect if the current device supports touch + */ +export function isTouchDevice(): boolean { + if (typeof window === 'undefined') return false + return ( + 'ontouchstart' in window || + navigator.maxTouchPoints > 0 || + // @ts-expect-error - msMaxTouchPoints is IE-specific + navigator.msMaxTouchPoints > 0 + ) +} + +/** + * Utility to detect iOS devices + */ +export function isIOS(): boolean { + if (typeof navigator === 'undefined') return false + return ( + /iPad|iPhone|iPod/.test(navigator.userAgent) || + (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1) + ) +} + +/** + * Utility to detect Android devices + */ +export function isAndroid(): boolean { + if (typeof navigator === 'undefined') return false + return /Android/.test(navigator.userAgent) +} diff --git a/src/inlay/hooks/use-virtual-keyboard.ts b/src/inlay/hooks/use-virtual-keyboard.ts new file mode 100644 index 0000000..8c5bb29 --- /dev/null +++ b/src/inlay/hooks/use-virtual-keyboard.ts @@ -0,0 +1,67 @@ +import { useEffect, useRef, useCallback } from 'react' + +export type VirtualKeyboardConfig = { + editorRef: React.RefObject + onVirtualKeyboardChange?: (open: boolean) => void +} + +/** + * Detects virtual keyboard visibility changes using the visualViewport API. + * Scrolls the editor into view when the keyboard opens. + */ +export function useVirtualKeyboard(cfg: VirtualKeyboardConfig) { + const isKeyboardOpenRef = useRef(false) + const initialViewportHeightRef = useRef(null) + + const handleViewportResize = useCallback(() => { + const vv = window.visualViewport + if (!vv) return + + // Store initial viewport height on first call + if (initialViewportHeightRef.current === null) { + initialViewportHeightRef.current = vv.height + } + + // Detect keyboard by comparing current viewport height to initial + // A significant reduction (>25%) typically indicates keyboard is open + const threshold = initialViewportHeightRef.current * 0.75 + const keyboardOpen = vv.height < threshold + + // Only fire callback on state change + if (keyboardOpen !== isKeyboardOpenRef.current) { + isKeyboardOpenRef.current = keyboardOpen + cfg.onVirtualKeyboardChange?.(keyboardOpen) + + // When keyboard opens, scroll editor into view + if (keyboardOpen && cfg.editorRef.current) { + // Use a small delay to let the viewport settle + requestAnimationFrame(() => { + cfg.editorRef.current?.scrollIntoView({ + behavior: 'smooth', + block: 'center' + }) + }) + } + } + }, [cfg]) + + useEffect(() => { + const vv = window.visualViewport + if (!vv) return + + // Initialize with current height + initialViewportHeightRef.current = vv.height + + vv.addEventListener('resize', handleViewportResize) + vv.addEventListener('scroll', handleViewportResize) + + return () => { + vv.removeEventListener('resize', handleViewportResize) + vv.removeEventListener('scroll', handleViewportResize) + } + }, [handleViewportResize]) + + return { + isKeyboardOpen: isKeyboardOpenRef.current + } +} diff --git a/src/inlay/index.ts b/src/inlay/index.ts new file mode 100644 index 0000000..9586135 --- /dev/null +++ b/src/inlay/index.ts @@ -0,0 +1,15 @@ +import { Root, Token, Portal } from './inlay' +import { StructuredInlay } from './structured/structured-inlay' + +// Re-export types that consumers may need +export type { InlayProps, InlayRef, TokenState } from './inlay' +export type { Plugin } from './structured/plugins/plugin' +export type { Matcher, Match } from './internal/string-utils' + +// Compound component export — use as Inlay.Root, Inlay.Token, Inlay.Portal, etc. +export const Inlay = { + Root, + Token, + Portal, + StructuredInlay +} diff --git a/src/inlay/inlay.tsx b/src/inlay/inlay.tsx new file mode 100644 index 0000000..75af325 --- /dev/null +++ b/src/inlay/inlay.tsx @@ -0,0 +1,653 @@ +import type { Scope } from '@radix-ui/react-context' +import { createContextScope } from '@radix-ui/react-context' +import * as Popover from '@radix-ui/react-popover' +import { useControllableState } from '@radix-ui/react-use-controllable-state' +import React, { + createContext, + useCallback, + useContext, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState +} from 'react' +import { + getAbsoluteOffset, + setDomSelection, + serializeRawFromDom as serializeFromDom +} from './internal/dom-utils' +import { useHistory } from './hooks/use-history' +import { useSelection } from './hooks/use-selection' +import { useTokenWeaver } from './hooks/use-token-weaver' +import { useComposition } from './hooks/use-composition' +import { useKeyHandlers } from './hooks/use-key-handlers' +import { usePlaceholderSync } from './hooks/use-placeholder-sync' +import { useSelectionSnap } from './hooks/use-selection-snap' +import { + PortalList, + PortalItem, + type PortalKeyboardHandler +} from './portal-list' +import { useClipboard } from './hooks/use-clipboard' +import { useVirtualKeyboard } from './hooks/use-virtual-keyboard' +import { useTouchSelection } from './hooks/use-touch-selection' + +export const COMPONENT_NAME = 'Inlay' +export const TEXT_COMPONENT_NAME = 'Inlay.Text' + +export type ScopedProps = P & { __scope?: Scope } +const [createInlayContext] = createContextScope(COMPONENT_NAME) + +const AncestorContext = createContext(null) +const PopoverControlContext = createContext<{ + setOpen: (open: boolean) => void +} | null>(null) + +// Context for portal keyboard navigation +type PortalKeyboardContextValue = { + setHandler: (handler: PortalKeyboardHandler | null) => void +} +const PortalKeyboardContext = createContext( + null +) +export { PortalKeyboardContext } + +// Context for anchor rect - allows Portal to position itself based on caret position +type AnchorRectContextValue = { + getRect: () => DOMRect +} +const AnchorRectContext = createContext(null) + +function annotateWithAncestor( + node: React.ReactNode, + currentAncestor: React.ReactElement | null +): React.ReactNode { + if (!React.isValidElement(node)) { + return node + } + + const element = node as React.ReactElement<{ children?: React.ReactNode }> + const nextAncestor = currentAncestor ?? element + + const children = element.props.children + const wrappedChildren = React.Children.map( + children, + (child: React.ReactNode) => annotateWithAncestor(child, nextAncestor) + ) + + return ( + + {React.cloneElement(element, undefined, wrappedChildren)} + + ) +} + +// --- Internal Context for token registration --- +type InternalInlayContextValue = { + registerToken: (token: { text: string; node: React.ReactElement }) => void +} +const [InternalInlayProvider, useInternalInlayContext] = + createInlayContext(COMPONENT_NAME) + +// --- Public Context for consumer state --- +type TokenInfo = { + text: string + node: React.ReactElement + start: number + end: number +} + +export type TokenState = { + isCollapsed: boolean + isAtStartOfToken: boolean + isAtEndOfToken: boolean +} + +type PublicInlayContextValue = { + value: string + selection: { start: number; end: number } + activeToken: TokenInfo | null + activeTokenState: TokenState | null + getSelectionRange: () => Range | null +} + +const [PublicInlayProvider, usePublicInlayContext] = + createInlayContext(COMPONENT_NAME) + +export type InlayProps = ScopedProps< + { + children: React.ReactNode + value?: string + defaultValue?: string + onChange?: (value: string) => void + placeholder?: React.ReactNode + multiline?: boolean + // Mobile input attributes + inputMode?: + | 'text' + | 'search' + | 'email' + | 'tel' + | 'url' + | 'numeric' + | 'decimal' + | 'none' + autoCapitalize?: + | 'off' + | 'none' + | 'on' + | 'sentences' + | 'words' + | 'characters' + autoCorrect?: 'on' | 'off' + enterKeyHint?: + | 'enter' + | 'done' + | 'go' + | 'next' + | 'previous' + | 'search' + | 'send' + onVirtualKeyboardChange?: (open: boolean) => void + } & Omit< + React.HTMLAttributes, + 'onChange' | 'defaultValue' | 'onKeyDown' + > & { + onKeyDown?: (event: React.KeyboardEvent) => boolean // Return true to stop propagation + } & { + getPopoverAnchorRect?: (root: HTMLDivElement | null) => DOMRect + } +> + +export type InlayRef = { + root: HTMLDivElement | null + setSelection: (start: number, end?: number) => void +} + +const Inlay = React.forwardRef((props, forwardedRef) => { + const { + __scope, + children: allChildren, + value: valueProp, + defaultValue, + onChange, + onKeyDown: onKeyDownProp, + placeholder, + multiline = true, + getPopoverAnchorRect, + // Mobile input attributes + inputMode = 'text', + autoCapitalize = 'sentences', + autoCorrect, // Omit by default - let iOS use native behavior (single-word suggestions) + enterKeyHint, + onVirtualKeyboardChange, + ...inlayProps + } = props + + const popoverPortal = React.useMemo( + () => + React.Children.toArray(allChildren).find( + (child) => React.isValidElement(child) && child.type === Portal + ), + [allChildren] + ) + + const children = React.useMemo( + () => + React.Children.toArray(allChildren).filter( + (child) => !React.isValidElement(child) || child.type !== Portal + ), + [allChildren] + ) + + const [isPopoverOpen, setIsPopoverOpen] = useState(false) + + // Portal keyboard handler - allows Portal.List to intercept keyboard events + const portalKeyboardHandlerRef = useRef(null) + const portalKeyboardContext = useMemo( + () => ({ + setHandler: (handler: PortalKeyboardHandler | null) => { + portalKeyboardHandlerRef.current = handler + } + }), + [] + ) + const popoverControl = useMemo(() => ({ setOpen: setIsPopoverOpen }), []) + const [value, setValue] = useControllableState({ + prop: valueProp, + defaultProp: defaultValue || '', + onChange + }) + // Keep a ref to the current value for synchronous access in event handlers + const valueRef = useRef(value) + valueRef.current = value + const editorRef = useRef(null) + const placeholderRef = useRef(null) + const { + selection, + setSelection, + setSelectionImperative, + handleSelectionChange, + lastAnchorRectRef, + suppressNextSelectionAdjustRef + } = useSelection(editorRef, value) + const virtualAnchorRef = useRef({ + getBoundingClientRect: () => lastAnchorRectRef.current + }) + const customAnchorRef = useRef({ + getBoundingClientRect: () => new DOMRect(0, 0, 0, 0) + }) + useLayoutEffect(() => { + if (!getPopoverAnchorRect) return + customAnchorRef.current.getBoundingClientRect = () => { + try { + const root = editorRef.current + const rect = getPopoverAnchorRect(root) + return rect ?? new DOMRect(0, 0, 0, 0) + } catch { + return new DOMRect(0, 0, 0, 0) + } + } + }, [getPopoverAnchorRect]) + const chosenAnchorRef = getPopoverAnchorRect + ? customAnchorRef + : virtualAnchorRef + const { + isRegistered, + registerToken, + weavedChildren, + activeToken, + activeTokenRef, + activeTokenState + } = useTokenWeaver(value, selection, multiline, children) + + const lastArrowDirectionRef = useRef<'left' | 'right' | 'up' | 'down' | null>( + null + ) + const lastShiftRef = useRef(false) + + const getCurrentSnapshot = useCallback(() => { + const root = editorRef.current + if (root) { + const sel = window.getSelection() + if (sel && sel.rangeCount > 0) { + const r = sel.getRangeAt(0) + const start = getAbsoluteOffset(root, r.startContainer, r.startOffset) + const end = getAbsoluteOffset(root, r.endContainer, r.endOffset) + return { value, selection: { start, end } } + } + } + return { value, selection } + }, [value, selection]) + const applySnapshot = useCallback( + (snap: { value: string; selection: { start: number; end: number } }) => { + setValue(() => snap.value) + requestAnimationFrame(() => { + const root = editorRef.current + if (root) { + suppressNextSelectionAdjustRef.current = true + setDomSelection(root, snap.selection.start, snap.selection.end) + } + }) + }, + [setValue] + ) + const { pushUndoSnapshot, beginEditSession, endEditSession, undo, redo } = + useHistory(getCurrentSnapshot, applySnapshot, 200) + + const serializeRawFromDom = useCallback((): string => { + const root = editorRef.current + if (!root) return value + return serializeFromDom(root) + }, [value]) + + const { + isComposing, + isComposingRef, + suppressNextBeforeInputRef, + suppressNextKeydownCommitRef, + compositionCommitKeyRef, + compositionJustEndedAtRef, + contentKey, + onCompositionStart, + onCompositionUpdate, + onCompositionEnd + } = useComposition( + editorRef, + serializeRawFromDom, + handleSelectionChange, + setValue, + () => value + ) + + usePlaceholderSync(editorRef, placeholderRef, [value, placeholder]) + + // weaving moved + const getSelectionRange = useCallback(() => { + const domSelection = window.getSelection() + if (domSelection && domSelection.rangeCount > 0) { + return domSelection.getRangeAt(0) + } + return null + }, []) + const { onBeforeInput, onKeyDown } = useKeyHandlers({ + editorRef, + contentKey, + multiline, + onKeyDownProp, + beginEditSession, + endEditSession, + pushUndoSnapshot, + undo, + redo, + isComposingRef, + compositionCommitKeyRef, + suppressNextBeforeInputRef, + suppressNextKeydownCommitRef, + compositionJustEndedAtRef, + setValue, + valueRef, + getActiveToken: () => + activeTokenRef.current + ? { + start: activeTokenRef.current.start, + end: activeTokenRef.current.end + } + : null + }) + const { onSelect } = useSelectionSnap({ + editorRef, + setSelection, + lastAnchorRectRef, + suppressNextSelectionAdjustRef, + lastArrowDirectionRef, + lastShiftRef, + isComposingRef + }) + const { onCopy, onCut, onPaste } = useClipboard({ + editorRef, + getValue: () => value, + setValue, + pushUndoSnapshot, + isComposingRef + }) + useVirtualKeyboard({ + editorRef, + onVirtualKeyboardChange + }) + const { onTouchStart, onTouchMove, onTouchEnd } = useTouchSelection({ + editorRef, + handleSelectionChange, + isComposingRef + }) + // Wrap onKeyDown to route through portal keyboard handler first + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + // If portal has a keyboard handler registered and it handles the event, stop + if ( + isPopoverOpen && + portalKeyboardHandlerRef.current && + portalKeyboardHandlerRef.current(event) + ) { + return + } + // Otherwise, use the default key handler + onKeyDown(event) + }, + [isPopoverOpen, onKeyDown] + ) + useImperativeHandle(forwardedRef, () => ({ + root: editorRef.current, + setSelection: (start: number, end?: number) => { + if (editorRef.current) { + setSelectionImperative(start, end) + } + } + })) + return ( + + + + + + {/* First pass: renders children to populate registry */} + + {React.Children.map(children, (child) => + annotateWithAncestor(child, null) + )} + + + {/* Second pass: renders weaved content */} + + + {isRegistered ? weavedChildren : children} + + {value.length === 0 && placeholder && !isComposing && ( + + {placeholder} + + )} + + ({ getRect: () => lastAnchorRectRef.current }), + [] + )} + > + + {popoverPortal} + + + + + + + ) +}) + +type PortalRenderProps = (context: PublicInlayContextValue) => React.ReactNode + +type PortalProps = ScopedProps< + Omit & { + children: PortalRenderProps + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: `data-${string}`]: any + } +> + +const Portal = (props: PortalProps) => { + const { + __scope, + children, + side = 'bottom', + sideOffset = 0, + alignOffset = 0, + ...contentProps + } = props + const context = usePublicInlayContext(COMPONENT_NAME, __scope) + const popoverControl = useContext(PopoverControlContext) + const keyboardContext = useContext(PortalKeyboardContext) + const anchorRectContext = useContext(AnchorRectContext) + const contentRef = useRef(null) + + const content = children(context) + const hasContent = !!content + + // Track last content to avoid flashing during timing gaps between render and effects + const lastContentRef = useRef(null) + const closeTimeoutRef = useRef(null) + + if (hasContent) { + lastContentRef.current = content + if (closeTimeoutRef.current !== null) { + clearTimeout(closeTimeoutRef.current) + closeTimeoutRef.current = null + } + } + + useLayoutEffect(() => { + if (hasContent) { + popoverControl?.setOpen(true) + } else { + // Defer close to allow effects to catch up + if (closeTimeoutRef.current !== null) + clearTimeout(closeTimeoutRef.current) + closeTimeoutRef.current = window.setTimeout(() => { + closeTimeoutRef.current = null + popoverControl?.setOpen(false) + lastContentRef.current = null + }, 50) + } + return () => { + if (closeTimeoutRef.current !== null) { + clearTimeout(closeTimeoutRef.current) + closeTimeoutRef.current = null + } + } + }, [hasContent, popoverControl]) + + // Position manually via DOM to follow caret on iOS and avoid re-render loops + useLayoutEffect(() => { + const el = contentRef.current + if (!el || !anchorRectContext) return + + const rect = anchorRectContext.getRect() + if (rect.x === 0 && rect.y === 0 && rect.width === 0 && rect.height === 0) + return + + let top: number, left: number + if (side === 'bottom') { + top = rect.bottom + sideOffset + left = rect.left + alignOffset + } else if (side === 'top') { + top = rect.top - el.offsetHeight - sideOffset + left = rect.left + alignOffset + } else if (side === 'left') { + top = rect.top + alignOffset + left = rect.left - el.offsetWidth - sideOffset + } else { + top = rect.top + alignOffset + left = rect.right + sideOffset + } + + el.style.top = `${top}px` + el.style.left = `${left}px` + el.style.visibility = 'visible' + }) + + const displayContent = content || lastContentRef.current + + if (!displayContent) return null + + return ( + + e.preventDefault()} + {...contentProps} + style={{ + ...contentProps.style, + position: 'fixed', + top: 0, + left: 0, + zIndex: 50, + visibility: 'hidden' // Made visible by useLayoutEffect after positioning + }} + > + + {displayContent} + + + + ) +} +Portal.displayName = 'Inlay.Portal' + +// Attach List and Item to Portal for compound component API +const PortalWithList = Object.assign(Portal, { + List: PortalList, + Item: PortalItem +}) + +type TokenProps = ScopedProps<{ + value: string + children: React.ReactNode +}> & + React.HTMLAttributes + +const Token = React.forwardRef((props, ref) => { + const { __scope, value, children, ...textProps } = props + const internalContext = useInternalInlayContext(TEXT_COMPONENT_NAME, __scope) + const ancestor = useContext(AncestorContext) + + const nodeToRegister = ( + + {children} + + ) + + // Register the token with its text value and the React node itself. + // If an ancestor is found in context, register that instead of the immediate span. + internalContext.registerToken({ + text: value, + node: ancestor || nodeToRegister + }) + + return nodeToRegister +}) + +Token.displayName = TEXT_COMPONENT_NAME + +export { Inlay as Root, Token, PortalWithList as Portal } diff --git a/src/inlay/internal/dom-utils.test.ts b/src/inlay/internal/dom-utils.test.ts new file mode 100644 index 0000000..38dcedc --- /dev/null +++ b/src/inlay/internal/dom-utils.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'vitest' +import { getAbsoluteOffset, getTextNodeAtOffset } from './dom-utils' + +// Helper to build a minimal editor DOM tree +function createEditor(html: string): HTMLElement { + const root = document.createElement('div') + root.innerHTML = html + return root +} + +describe('dom-utils', () => { + it('getAbsoluteOffset on plain text accumulates correctly', () => { + const root = createEditor('hello world') + const firstText = root.querySelector('span')!.firstChild as Text + const secondText = root.querySelectorAll('span')[1].firstChild as Text + + expect(getAbsoluteOffset(root, firstText, 0)).toBe(0) // h| + expect(getAbsoluteOffset(root, firstText, 5)).toBe(5) // hello| + expect(getAbsoluteOffset(root, secondText, 1)).toBe(6) // hello␠| + }) + + it('getTextNodeAtOffset on plain text maps back to nodes/offsets', () => { + const root = createEditor('hello world') + const [n0, o0] = getTextNodeAtOffset(root, 0) + expect((n0 as Text).data.slice(0, o0)).toBe('') + + const [n5, o5] = getTextNodeAtOffset(root, 5) + expect((n5 as Text).data.slice(0, o5)).toBe('hello') + + const [n7, o7] = getTextNodeAtOffset(root, 7) + expect((n7 as Text).data.slice(0, o7)).toBe(' w') + }) + + it('diverged token: absolute offset snaps to token edges when inside token', () => { + const root = createEditor( + 'hi ' + + 'Alex' + + '!' + ) + const token = root.querySelector('[data-token-text]') as HTMLElement + const tokenInner = token.querySelector('span')!.firstChild as Text // "Alex" + + // Inside first char of rendered token should snap to start of raw token (offset at start of token = 3) + expect(getAbsoluteOffset(root, tokenInner, 1)).toBe(3) + + // Inside last char of rendered token should snap to end of raw token (raw len = 5, base start = 3, so end = 8) + expect(getAbsoluteOffset(root, tokenInner, 4)).toBe(8) + }) + + it('round-trip sweep over all offsets in mixed content (snap inside diverged token)', () => { + const root = createEditor( + 'X' + + 'Alex' + + 'Y' + ) + // raw: "X@alexY" -> token starts at 1, rawLen=5, tokenEnd=6, total len=7 + const tokenStart = 1 + const tokenRawLen = 5 + const tokenEnd = tokenStart + tokenRawLen + for (let i = 0; i <= 7; i++) { + const [n, o] = getTextNodeAtOffset(root, i) + const roundTrip = getAbsoluteOffset(root, n as Node, o) + const expected = + i < tokenStart || i > tokenEnd + ? i + : i - tokenStart <= tokenRawLen / 2 + ? tokenStart + : tokenEnd + expect(roundTrip).toBe(expected) + } + }) + + it('fallbacks: offsets past end clamp to last text node end', () => { + const root = createEditor('abc') + const [n, o] = getTextNodeAtOffset(root, 999) + expect((n as Text).data.length).toBe(3) + expect(o).toBe(3) + }) +}) diff --git a/src/inlay/internal/dom-utils.ts b/src/inlay/internal/dom-utils.ts new file mode 100644 index 0000000..8146bde --- /dev/null +++ b/src/inlay/internal/dom-utils.ts @@ -0,0 +1,381 @@ +const isTokenElement = (el: Element): boolean => + el.hasAttribute('data-token-text') + +const getTokenRawLength = (el: Element): number => + (el.getAttribute('data-token-text') || '').length + +const getRenderedTextLength = (el: Element): number => { + const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null) + let total = 0 + let n: Node | null + while ((n = walker.nextNode())) total += (n.textContent || '').length + return total +} + +const findFirstTextNode = (el: Element): ChildNode | null => { + const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null) + return walker.nextNode() as ChildNode | null +} + +const findLastTextNode = (el: Element): ChildNode | null => { + const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null) + let last: Node | null = null + let n: Node | null + while ((n = walker.nextNode())) last = n + return last as ChildNode | null +} + +export function getClosestTokenEl(node: Node | null): HTMLElement | null { + let curr: Node | null = node + while (curr) { + if (curr.nodeType === Node.ELEMENT_NODE) { + const el = curr as HTMLElement + if (el.hasAttribute('data-token-text')) return el + } + curr = curr.parentNode + } + return null +} + +export function getTokenRawRange( + root: HTMLElement, + tokenEl: HTMLElement +): { start: number; end: number } | null { + const rawText = tokenEl.getAttribute('data-token-text') + if (!rawText) return null + + const walker = document.createTreeWalker(tokenEl, NodeFilter.SHOW_TEXT, null) + const firstText = walker.nextNode() + if (!firstText) return null + + const start = getAbsoluteOffset(root, firstText, 0) + return { start, end: start + rawText.length } +} + +export const getTextNodeAtOffset = ( + root: HTMLElement, + offset: number +): [ChildNode | null, number] => { + const traverse = ( + container: Node, + remaining: { value: number } + ): [ChildNode | null, number] | null => { + const children = container.childNodes + for (let i = 0; i < children.length; i++) { + const child = children[i] + + if (child.nodeType === Node.ELEMENT_NODE) { + const el = child as Element + + // represents a newline character - count as 1 + // If we're at the position right after a , return the next text node + if (el.tagName === 'BR') { + if (remaining.value === 0) { + // Position is right at the - find next text node + // Look for the next sibling text node or continue traversal + for (let j = i + 1; j < children.length; j++) { + const next = children[j] + if (next.nodeType === Node.TEXT_NODE) { + return [next as ChildNode, 0] + } + if (next.nodeType === Node.ELEMENT_NODE) { + const nextEl = next as Element + const first = findFirstTextNode(nextEl) + if (first) return [first, 0] + } + } + // No next text node found - return null + return null + } + remaining.value -= 1 + continue + } + + if (isTokenElement(el)) { + const rawLen = getTokenRawLength(el) + const renderedLen = getRenderedTextLength(el) + const isDiverged = renderedLen !== rawLen + + if (isDiverged) { + if (remaining.value <= rawLen) { + const first = findFirstTextNode(el) + const last = findLastTextNode(el) + if (!first && !last) return null + + const snapToStart = remaining.value <= rawLen / 2 + if (snapToStart) { + if (first) return [first, 0] + } else { + if (last) return [last, (last.textContent || '').length] + } + if (first) return [first, 0] + if (last) return [last, (last.textContent || '').length] + return null + } + remaining.value -= rawLen + continue + } + + const found = traverse(el, remaining) + if (found) return found + continue + } + + const found = traverse(el, remaining) + if (found) return found + continue + } + + if (child.nodeType === Node.TEXT_NODE) { + const text = child.textContent || '' + if (remaining.value <= text.length) { + return [child as ChildNode, remaining.value] + } + remaining.value -= text.length + } + } + + return null + } + + const result = traverse(root, { value: Math.max(0, offset) }) + if (result) return result + + // Fallback: place at end of last text node + const allTokenTextNodes = Array.from( + root.querySelectorAll('[data-token-text]') + ) + .map((el) => findLastTextNode(el)) + .filter(Boolean) as ChildNode[] + + if (allTokenTextNodes.length > 0) { + const lastNode = allTokenTextNodes[allTokenTextNodes.length - 1] + return [lastNode, (lastNode.textContent || '').length] + } + + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null) + let last: Node | null = null + let n: Node | null + while ((n = walker.nextNode())) last = n + if (last) return [last as ChildNode, (last.textContent || '').length] + + return [null, 0] +} + +export const getAbsoluteOffset = ( + root: HTMLElement, + node: Node, + offset: number +): number => { + const getOffsetWithinElement = ( + el: Element, + target: Node, + targetOffset: number + ): number => { + const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null) + let total = 0 + let n: Node | null + while ((n = walker.nextNode())) { + if (n === target) { + total += Math.min(targetOffset, (n.textContent || '').length) + break + } + total += (n.textContent || '').length + } + return total + } + + // Handle element node containers (e.g. Firefox Ctrl+a sets selection on element, not text node) + if (node.nodeType === Node.ELEMENT_NODE) { + const el = node as Element + const children = el.childNodes + + let total = 0 + const measure = (n: Node): number => { + if (n.nodeType === Node.TEXT_NODE) { + return (n.textContent || '').length + } + if (n.nodeType === Node.ELEMENT_NODE) { + const e = n as Element + // represents a newline character + if (e.tagName === 'BR') return 1 + if (isTokenElement(e)) return getTokenRawLength(e) + let sum = 0 + for (let i = 0; i < e.childNodes.length; i++) { + sum += measure(e.childNodes[i]) + } + return sum + } + return 0 + } + + for (let i = 0; i < offset && i < children.length; i++) { + total += measure(children[i]) + } + return total + } + + const traverse = (container: Node, acc: { value: number }): number | null => { + const children = container.childNodes + for (let i = 0; i < children.length; i++) { + const child = children[i] + + if (child.nodeType === Node.ELEMENT_NODE) { + const el = child as Element + + if (isTokenElement(el)) { + const rawLen = getTokenRawLength(el) + const renderedLen = getRenderedTextLength(el) + const isDiverged = renderedLen !== rawLen + + if (isDiverged) { + if (el.contains(node)) { + const withinRendered = getOffsetWithinElement(el, node, offset) + const snapToStart = withinRendered <= renderedLen / 2 + return acc.value + (snapToStart ? 0 : rawLen) + } + acc.value += rawLen + continue + } + + if (el.contains(node)) { + const inner = traverse(el, acc) + if (inner != null) return inner + } else { + acc.value += renderedLen + } + continue + } + + // represents a newline character - count as 1 and continue + if (el.tagName === 'BR') { + acc.value += 1 + continue + } + + if (el.contains(node)) { + const inner = traverse(el, acc) + if (inner != null) return inner + } else { + const measureSubtree = (e: Element): number => { + // represents a newline character + if (e.tagName === 'BR') return 1 + let total = 0 + const cn = e.childNodes + for (let j = 0; j < cn.length; j++) { + const c = cn[j] + if (c.nodeType === Node.TEXT_NODE) { + total += (c.textContent || '').length + } else if (c.nodeType === Node.ELEMENT_NODE) { + const ce = c as Element + if (isTokenElement(ce)) { + const rl = getTokenRawLength(ce) + const rr = getRenderedTextLength(ce) + total += rr === rl ? rr : rl + } else { + total += measureSubtree(ce) + } + } + } + return total + } + acc.value += measureSubtree(el) + } + continue + } + + if (child.nodeType === Node.TEXT_NODE) { + if (child === node) { + return acc.value + Math.min(offset, (child.textContent || '').length) + } else { + acc.value += (child.textContent || '').length + } + } + } + return null + } + + const result = traverse(root, { value: 0 }) + if (result != null) return result + + // Fallback: total length + let total = 0 + const stack: Node[] = [root] + while (stack.length) { + const n = stack.pop()! + if (n.nodeType === Node.ELEMENT_NODE) { + const el = n as Element + if (isTokenElement(el)) { + const rl = getTokenRawLength(el) + const rr = getRenderedTextLength(el) + total += rr === rl ? rr : rl + continue + } + const cn = el.childNodes + for (let i = cn.length - 1; i >= 0; i--) stack.push(cn[i]) + } else if (n.nodeType === Node.TEXT_NODE) { + total += (n.textContent || '').length + } + } + return total +} + +export const setDomSelection = ( + root: HTMLElement, + start: number, + end?: number +) => { + const [startNode, startOffset] = getTextNodeAtOffset(root, start) + const [endNode, endOffset] = end + ? getTextNodeAtOffset(root, end) + : [startNode, startOffset] + + if (startNode && endNode) { + // Guard against NotFoundError on iOS: nodes may have been removed + // by React re-renders between scheduling and execution + if (!startNode.isConnected || !endNode.isConnected) { + return + } + + const range = document.createRange() + range.setStart(startNode, startOffset) + range.setEnd(endNode, endOffset) + const selection = window.getSelection() + if (selection) { + selection.removeAllRanges() + selection.addRange(range) + } + } +} + +export const serializeRawFromDom = (root: HTMLElement): string => { + const clone = root.cloneNode(true) as HTMLElement + const tokenEls = clone.querySelectorAll('[data-token-text]') + tokenEls.forEach((el) => { + const raw = el.getAttribute('data-token-text') || '' + const renderedLen = getRenderedTextLength(el) + if (renderedLen !== raw.length) { + ;(el as HTMLElement).textContent = raw + } + }) + let result = (clone as HTMLElement).innerText + // iOS contentEditable often has trailing newlines, zero-width spaces, or other + // invisible characters when "empty". Strip these from the end. + // Also handle the case where the entire content is just whitespace/invisible chars. + result = result.replace(/[\u200B\uFEFF]+/g, '') // Remove zero-width spaces throughout + + // Handle empty content (just newlines or whitespace) + if (result === '\n' || result.trim() === '') { + return '' + } + + // ContentEditable often adds ONE extra trailing newline. Remove it if present. + // But preserve intentional newlines in the content (e.g., "hello\nworld\n" → "hello\nworld") + // This is tricky: we can't know if the final newline is intentional or added by browser. + // Heuristic: if it ends with double newline, remove one. Single trailing newline stays. + if (result.endsWith('\n\n')) { + result = result.slice(0, -1) + } + + return result +} diff --git a/src/inlay/internal/string-utils.test.ts b/src/inlay/internal/string-utils.test.ts new file mode 100644 index 0000000..b323ae4 --- /dev/null +++ b/src/inlay/internal/string-utils.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect } from 'vitest' +import { + createRegexMatcher, + filterMatchesByMatcher, + groupMatchesByMatcher, + scan +} from './string-utils' + +describe('string-utils', () => { + it('createRegexMatcher + scan: finds and sorts matches with names', () => { + const mention = createRegexMatcher<{ mention: string }, 'mention'>( + 'mention', + { + regex: /@\w+/g, + transform: (m) => ({ mention: m[0] }) + } + ) + + const text = 'hi @alex and @bob' + const matches = scan(text, [mention]) + + expect(matches.map((m) => m.raw)).toEqual(['@alex', '@bob']) + expect(matches.map((m) => m.start)).toEqual([3, 13]) + expect(matches.every((m) => m.matcher === 'mention')).toBe(true) + }) + + it('createRegexMatcher resets lastIndex between scans', () => { + const word = createRegexMatcher<{ w: string }, 'word'>('word', { + regex: /\w+/g, + transform: (m) => ({ w: m[0] }) + }) + + const a = scan('alpha beta', [word]) + const b = scan('gamma', [word]) + + expect(a.map((m) => m.raw)).toEqual(['alpha', 'beta']) + expect(b.map((m) => m.raw)).toEqual(['gamma']) + }) + + it('filterMatchesByMatcher returns only requested matcher type', () => { + const mention = createRegexMatcher<{ mention: string }, 'mention'>( + 'mention', + { regex: /@\w+/g, transform: (m) => ({ mention: m[0] }) } + ) + const hash = createRegexMatcher<{ tag: string }, 'hashtag'>('hashtag', { + regex: /#\w+/g, + transform: (m) => ({ tag: m[0] }) + }) + + const matches = scan('say @alex and #music', [mention, hash]) + + const onlyMentions = filterMatchesByMatcher(matches, 'mention') + expect(onlyMentions.map((m) => m.raw)).toEqual(['@alex']) + }) + + it('groupMatchesByMatcher groups by matcher name', () => { + const m1 = createRegexMatcher<{ v: string }, 'a'>('a', { + regex: /a/g, + transform: (m) => ({ v: m[0] }) + }) + const m2 = createRegexMatcher<{ v: string }, 'b'>('b', { + regex: /b/g, + transform: (m) => ({ v: m[0] }) + }) + + const matches = scan('ababa', [m1, m2]) + const grouped = groupMatchesByMatcher(matches) + + expect(grouped.a?.map((x) => x.raw)).toEqual(['a', 'a', 'a']) + expect(grouped.b?.map((x) => x.raw)).toEqual(['b', 'b']) + }) + + it('createRegexMatcher throws when regex lacks global flag', () => { + expect(() => + // @ts-expect-error intentional invalid regex config for test + createRegexMatcher('bad', { regex: /@\w+/, transform: (m) => m[0] }) + ).toThrow() + }) + + it('createRegexMatcher throws when regex uses sticky flag', () => { + expect(() => + // @ts-expect-error intentional invalid regex config for test + createRegexMatcher('bad', { regex: /@\w+/gy, transform: (m) => m[0] }) + ).toThrow() + }) + + it('scan filters overlapping matches - longer match wins', () => { + const atWord = createRegexMatcher<{ v: string }, 'at'>('at', { + regex: /@\w+/g, + transform: (m) => ({ v: m[0] }) + }) + const word = createRegexMatcher<{ v: string }, 'word'>('word', { + regex: /\w+/g, + transform: (m) => ({ v: m[0] }) + }) + + // @alex (5 chars starting at 0) vs alex (4 chars starting at 1) + // They overlap, and @alex is longer, so only @alex should be returned + const matches = scan('@alex', [atWord, word]) + expect(matches.map((m) => `${m.matcher}:${m.raw}`)).toEqual(['at:@alex']) + }) + + it('scan: longest match wins when two matchers start at same position', () => { + const short = createRegexMatcher<{ v: string }, 'short'>('short', { + regex: /@alice/g, + transform: (m) => ({ v: m[0] }) + }) + const long = createRegexMatcher<{ v: string }, 'long'>('long', { + regex: /@alice_vip/g, + transform: (m) => ({ v: m[0] }) + }) + + // Both match starting at position 0, but long is 10 chars vs short's 6 + const matches = scan('@alice_vip', [short, long]) + expect(matches.map((m) => `${m.matcher}:${m.raw}`)).toEqual([ + 'long:@alice_vip' + ]) + }) + + it('scan: first matcher wins when matches are exact same range', () => { + const pluginA = createRegexMatcher<{ v: string }, 'a'>('a', { + regex: /@test/g, + transform: (m) => ({ v: m[0] }) + }) + const pluginB = createRegexMatcher<{ v: string }, 'b'>('b', { + regex: /@test/g, + transform: (m) => ({ v: m[0] }) + }) + + // Both match @test at exact same range - first matcher (a) should win + const matches = scan('@test', [pluginA, pluginB]) + expect(matches.map((m) => `${m.matcher}:${m.raw}`)).toEqual(['a:@test']) + }) + + it('scan preserves non-overlapping matches from multiple matchers', () => { + const mention = createRegexMatcher<{ v: string }, 'mention'>('mention', { + regex: /@\w+/g, + transform: (m) => ({ v: m[0] }) + }) + const hashtag = createRegexMatcher<{ v: string }, 'hashtag'>('hashtag', { + regex: /#\w+/g, + transform: (m) => ({ v: m[0] }) + }) + + // @bob and #music don't overlap, both should be kept + const matches = scan('@bob loves #music', [mention, hashtag]) + expect(matches.map((m) => `${m.matcher}:${m.raw}`)).toEqual([ + 'mention:@bob', + 'hashtag:#music' + ]) + }) +}) diff --git a/src/inlay/internal/string-utils.ts b/src/inlay/internal/string-utils.ts new file mode 100644 index 0000000..b5de702 --- /dev/null +++ b/src/inlay/internal/string-utils.ts @@ -0,0 +1,248 @@ +/** + * Represents a single matched token within a larger string. + * @template T The type of the structured data associated with the match. + * @template N The literal string type of the matcher's name. + */ +export type Match = { + /** The raw matched text span from the original string. */ + raw: string + /** The start index of the match in the full string. */ + start: number + /** The end index of the match in the full string. */ + end: number + /** A structured object with parsed values defined by the matcher. */ + data: T + /** The unique `name` of the matcher that produced this match. */ + matcher: N +} + +/** + * A utility type that constructs a `Match` type from a `Matcher` type. + * It infers the data type `T` and the literal name `N` from the matcher. + * This is key to creating a discriminated union for the `scan` function's return type. + */ +export type MatchFromMatcher = + M extends Matcher ? Match : never + +type MatchData = Omit, 'matcher'> + +/** + * Defines a configuration for finding a specific kind of token. + * It's a generic interface that can be implemented using various strategies (e.g., regex, prefix, custom logic). + * @template T The type of the structured data this matcher will produce. + * @template N The literal string type of the matcher's name. + */ +export type Matcher = { + /** A unique name for the matcher, used to identify which matcher found a token. */ + name: N + /** + * The core matching logic. It scans the input text and returns all found tokens. + * @param text The full string to scan. + * @returns An array of match data, without the `matcher` property. + */ + match: (text: string) => MatchData[] +} + +/** + * Scans a string for tokens using a set of configured matchers. + * + * This function is the core of the token matching utility. It orchestrates the matching process + * by running all provided matchers over the input text and consolidating the results. + * Each matcher can produce a different type of structured data, and the return type will + * be a discriminated union of all possible `Match` types, inferred from the provided matchers. + * + * @param text The full string to scan. + * @param matchers An array of `Matcher` objects to run on the string. The use of `` ensures + * that the literal types of matcher names are preserved for type inference. + * @returns An array of all `Match` objects found, sorted by their start index. + * The type of each element is a discriminated union of all possible `Match` types. + * Consumers can switch on `match.matcher` to safely access `match.data`. + */ +export function scan[]>( + text: string, + matchers: M +): MatchFromMatcher[] { + const allMatches = matchers.flatMap((matcher) => + matcher.match(text).map((m) => ({ ...m, matcher: matcher.name })) + ) as MatchFromMatcher[] + + // Sort by start position ascending, then by length descending (longest first at same position) + // This ensures the longest match at each position is considered first + allMatches.sort((a, b) => { + if (a.start !== b.start) return a.start - b.start + return b.end - b.start - (a.end - a.start) + }) + + // Greedy algorithm: accept non-overlapping matches, preferring longer ones + // When two matches overlap, the one starting earlier (or longer at same position) wins + const accepted: typeof allMatches = [] + let lastEnd = -1 + for (const match of allMatches) { + if (match.start >= lastEnd) { + accepted.push(match) + lastEnd = match.end + } + } + + return accepted +} + +/** + * Options for creating a regex-based matcher. + * @template T The type of the structured data the matcher will produce. + */ +type RegexMatcherOptions = { + /** The regular expression to match against. Must include the 'g' flag for global matching. */ + regex: RegExp + /** A function to transform the raw regex match array into the desired structured data. */ + transform: (match: RegExpExecArray) => T +} + +/** + * A factory function to create a `Matcher` that uses a regular expression. + * This simplifies the creation of matchers for patterns that can be described with regex. + * + * @template T The type of the structured data the created matcher will produce. + * @param name The unique name for this matcher. + * @param options The configuration for the regex matcher. + * @returns A new `Matcher` object. + */ +export function createRegexMatcher( + name: N, + options: RegexMatcherOptions +): Matcher { + if (!options.regex.global) { + throw new Error('createRegexMatcher requires a global regex (e.g. /.../g)') + } + if (options.regex.sticky) { + throw new Error( + `createRegexMatcher does not support the sticky 'y' flag, as it interferes with scanning.` + ) + } + + return { + name, + match: (text: string): MatchData[] => { + const matches: MatchData[] = [] + let regexMatch: RegExpExecArray | null + + // Important: Reset lastIndex on the regex before each new scan. + options.regex.lastIndex = 0 + + while ((regexMatch = options.regex.exec(text)) !== null) { + // This check is necessary to avoid infinite loops with zero-width matches. + if (regexMatch.index === options.regex.lastIndex) { + options.regex.lastIndex++ + } + + matches.push({ + raw: regexMatch[0], + start: regexMatch.index, + end: regexMatch.index + regexMatch[0].length, + data: options.transform(regexMatch) + }) + } + return matches + } + } +} + +/** + * Filters an array of matches to only include those from a specific matcher. + * This is a type-safe alternative to `matches.filter(m => m.matcher === '...')`. + * + * @param matches The array of `Match` objects to filter. + * @param matcherName The name of the matcher to filter by. + * @returns A new array containing only the matches from the specified matcher, + * with the `data` property correctly typed. + */ +export function filterMatchesByMatcher< + M extends Match, + N extends M['matcher'] +>(matches: readonly M[], matcherName: N): Extract[] { + return matches.filter( + (match): match is Extract => + match.matcher === matcherName + ) +} + +/** + * Groups an array of matches by their matcher name. + * + * @param matches An array of `Match` objects, typically from `scan`. + * @returns A record where keys are matcher names and values are arrays of matches + * from that matcher, with each array correctly typed. + */ +export function groupMatchesByMatcher>( + matches: readonly M[] +): Partial<{ [N in M['matcher']]: Extract[] }> { + // We use a less specific type for `grouped` internally to work around a TypeScript + // limitation where it cannot correlate the matcher name (key) with the match + // object's type (value) within the loop. The final cast is safe because the + // logic guarantees the structure of the returned object. + const grouped: Record = {} + + for (const match of matches) { + const key = match.matcher + if (!grouped[key]) { + grouped[key] = [] + } + grouped[key].push(match) + } + + return grouped as unknown as Partial<{ + [N in M['matcher']]: Extract[] + }> +} + +// --- Grapheme segmentation helpers --- +// These utilities provide consistent grapheme cluster boundaries across engines. +// They are intentionally here since they operate purely on strings. +import Graphemer from 'graphemer' + +const graphemeSplitter = new Graphemer() + +export function prevGraphemeStart(text: string, index: number): number { + if (index <= 0) return 0 + let pos = 0 + for (const cluster of graphemeSplitter.iterateGraphemes(text)) { + const next = pos + cluster.length + if (next >= index) return pos + pos = next + } + return pos +} + +export function nextGraphemeEnd(text: string, index: number): number { + if (index >= text.length) return text.length + let pos = 0 + for (const cluster of graphemeSplitter.iterateGraphemes(text)) { + const next = pos + cluster.length + if (index < next) return next + pos = next + } + return text.length +} + +export function snapGraphemeStart(text: string, index: number): number { + if (index <= 0) return 0 + let pos = 0 + for (const cluster of graphemeSplitter.iterateGraphemes(text)) { + const next = pos + cluster.length + if (index < next) return pos + pos = next + } + return pos +} + +export function snapGraphemeEnd(text: string, index: number): number { + if (index >= text.length) return text.length + let pos = 0 + for (const cluster of graphemeSplitter.iterateGraphemes(text)) { + const next = pos + cluster.length + if (index <= pos) return pos + if (index <= next) return next + pos = next + } + return pos +} diff --git a/src/inlay/portal-list.tsx b/src/inlay/portal-list.tsx new file mode 100644 index 0000000..b30d3db --- /dev/null +++ b/src/inlay/portal-list.tsx @@ -0,0 +1,348 @@ +import React, { + createContext, + useCallback, + useContext, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState +} from 'react' + +// --- Portal List Navigation Context --- + +type PortalListContextValue = { + activeIndex: number + setActiveIndex: (index: number) => void + registerItem: (value: T, disabled?: boolean) => number + unregisterItem: (index: number) => void + selectItem: (index: number) => void + loop: boolean +} + +const PortalListContext = createContext(null) + +function usePortalListContext() { + const context = useContext(PortalListContext) + if (!context) { + throw new Error('Portal.Item must be used within Portal.List') + } + return context +} + +// --- Keyboard Handler Context --- +// This context allows the parent Inlay.Root to intercept keyboard events + +export type PortalKeyboardHandler = ( + event: React.KeyboardEvent +) => boolean + +// Import the context from inlay.tsx to avoid circular dependency issues +// The context is created in inlay.tsx and exported +import { PortalKeyboardContext } from './inlay' + +export function usePortalKeyboardContext() { + return useContext(PortalKeyboardContext) +} + +// --- Portal.List Component --- + +export type PortalListProps = { + children: React.ReactNode + onSelect: (item: T, index: number) => void + onDismiss?: () => void + loop?: boolean +} & Omit, 'onSelect'> + +function PortalListInner( + props: PortalListProps, + ref: React.ForwardedRef +) { + const { children, onSelect, onDismiss, loop = true, ...divProps } = props + + const [activeIndex, setActiveIndex] = useState(0) + const itemsRef = useRef>([]) + const [, forceUpdate] = useState(0) + const keyboardContext = usePortalKeyboardContext() + + const registerItem = useCallback((value: T, disabled?: boolean) => { + const index = itemsRef.current.length + itemsRef.current.push({ value, disabled }) + forceUpdate((n) => n + 1) + return index + }, []) + + const unregisterItem = useCallback((index: number) => { + itemsRef.current.splice(index, 1) + forceUpdate((n) => n + 1) + }, []) + + const selectItem = useCallback( + (index: number) => { + const item = itemsRef.current[index] + if (item && !item.disabled) { + onSelect(item.value, index) + } + }, + [onSelect] + ) + + const navigateUp = useCallback(() => { + const items = itemsRef.current + if (items.length === 0) return + + setActiveIndex((current) => { + let nextIndex = current - 1 + if (nextIndex < 0) { + nextIndex = loop ? items.length - 1 : 0 + } + + // Skip disabled items + let attempts = 0 + while (items[nextIndex]?.disabled && attempts < items.length) { + nextIndex = nextIndex - 1 + if (nextIndex < 0) { + nextIndex = loop ? items.length - 1 : 0 + } + attempts++ + } + + return nextIndex + }) + }, [loop]) + + const navigateDown = useCallback(() => { + const items = itemsRef.current + if (items.length === 0) return + + setActiveIndex((current) => { + let nextIndex = current + 1 + if (nextIndex >= items.length) { + nextIndex = loop ? 0 : items.length - 1 + } + + // Skip disabled items + let attempts = 0 + while (items[nextIndex]?.disabled && attempts < items.length) { + nextIndex = nextIndex + 1 + if (nextIndex >= items.length) { + nextIndex = loop ? 0 : items.length - 1 + } + attempts++ + } + + return nextIndex + }) + }, [loop]) + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent): boolean => { + const items = itemsRef.current + + switch (event.key) { + case 'ArrowUp': + event.preventDefault() + navigateUp() + return true + case 'ArrowDown': + event.preventDefault() + navigateDown() + return true + case 'Enter': { + const item = items[activeIndex] + if (item && !item.disabled) { + event.preventDefault() + onSelect(item.value, activeIndex) + return true + } + return false + } + case 'Escape': + event.preventDefault() + onDismiss?.() + return true + default: + return false + } + }, + [activeIndex, navigateUp, navigateDown, onSelect, onDismiss] + ) + + // Register keyboard handler with parent context + useLayoutEffect(() => { + keyboardContext?.setHandler(handleKeyDown) + return () => { + keyboardContext?.setHandler(null) + } + }, [keyboardContext, handleKeyDown]) + + // Reset active index when items change + useEffect(() => { + const items = itemsRef.current + if (activeIndex >= items.length && items.length > 0) { + setActiveIndex(items.length - 1) + } + }, [activeIndex]) + + const contextValue = useMemo( + (): PortalListContextValue => ({ + activeIndex, + setActiveIndex, + registerItem, + unregisterItem, + selectItem, + loop + }), + [activeIndex, registerItem, unregisterItem, selectItem, loop] + ) + + return ( + + { + // Prevent focus loss from editor + e.preventDefault() + divProps.onMouseDown?.(e) + }} + onTouchStart={(e) => { + // Prevent focus loss from editor on touch devices + e.preventDefault() + divProps.onTouchStart?.(e) + }} + > + {children} + + + ) +} + +export const PortalList = React.forwardRef(PortalListInner) as ( + props: PortalListProps & { ref?: React.ForwardedRef } +) => React.ReactElement + +// --- Portal.Item Component --- + +export type PortalItemProps = { + value: T + disabled?: boolean + children: React.ReactNode +} & Omit, 'value'> + +function PortalItemInner( + props: PortalItemProps, + ref: React.ForwardedRef +) { + const { value, disabled = false, children, ...divProps } = props + const context = usePortalListContext() + const [index, setIndex] = useState(-1) + const touchStartPosRef = useRef<{ x: number; y: number } | null>(null) + + // Register this item on mount + useLayoutEffect(() => { + const idx = context.registerItem(value, disabled) + setIndex(idx) + return () => { + context.unregisterItem(idx) + } + }, []) + + const isActive = context.activeIndex === index + + const handleClick = useCallback( + (e: React.MouseEvent) => { + if (!disabled && index >= 0) { + context.selectItem(index) + } + divProps.onClick?.(e) + }, + [disabled, context, divProps, index] + ) + + const handleMouseEnter = useCallback( + (e: React.MouseEvent) => { + if (!disabled && index >= 0) { + context.setActiveIndex(index) + } + divProps.onMouseEnter?.(e) + }, + [disabled, context, divProps, index] + ) + + // Touch handlers for mobile - activate on touch, select on release + const handleTouchStart = useCallback( + (e: React.TouchEvent) => { + const touch = e.touches[0] + if (touch) { + touchStartPosRef.current = { x: touch.clientX, y: touch.clientY } + } + // Activate item on touch start (like hover on desktop) + if (!disabled && index >= 0) { + context.setActiveIndex(index) + } + divProps.onTouchStart?.(e) + }, + [disabled, context, divProps, index] + ) + + const handleTouchEnd = useCallback( + (e: React.TouchEvent) => { + const touch = e.changedTouches[0] + const startPos = touchStartPosRef.current + + // Only select if touch didn't move significantly (not a scroll gesture) + if (touch && startPos) { + const dx = Math.abs(touch.clientX - startPos.x) + const dy = Math.abs(touch.clientY - startPos.y) + + // If movement was minimal, treat as a tap and select + if (dx < 10 && dy < 10) { + if (!disabled && index >= 0) { + // Prevent the subsequent click event + e.preventDefault() + context.selectItem(index) + } + } + } + + touchStartPosRef.current = null + divProps.onTouchEnd?.(e) + }, + [disabled, context, divProps, index] + ) + + return ( + + {children} + + ) +} + +export const PortalItem = React.forwardRef(PortalItemInner) as ( + props: PortalItemProps
= { + props: P // Plugin configuration + matcher: Matcher // How to find tokens in text + render: (ctx) => ReactNode // Token visual representation + portal: (ctx) => ReactNode // Optional popover content + onInsert: (value: T) => void + onKeyDown: (event) => boolean +} +``` + +Example: A mentions plugin matches `@username` patterns, renders styled chips, and shows a user card popover on focus. + +## Browser Compatibility + +- Handles Firefox's element-node selections (Ctrl+A sets selection on element, not text nodes) +- WebKit composition quirks (extra `beforeInput` events after `compositionend`) +- Cross-platform keyboard shortcuts via `ControlOrMeta` + +## Mobile Support + +Inlay provides full mobile device support with touch interactions, virtual keyboard handling, and platform-specific fixes. + +### Mobile Input Attributes + +The editor automatically sets mobile-friendly attributes: + +```tsx + +``` + +All attributes are configurable via props: + +```tsx +type InlayProps = { + inputMode?: 'text' | 'search' | 'email' | 'tel' | 'url' | 'numeric' | 'decimal' | 'none' + autoCapitalize?: 'off' | 'none' | 'on' | 'sentences' | 'words' | 'characters' + autoCorrect?: 'on' | 'off' + enterKeyHint?: 'enter' | 'done' | 'go' | 'next' | 'previous' | 'search' | 'send' + onVirtualKeyboardChange?: (open: boolean) => void +} +``` + +### Touch Event Handling + +The `useTouchSelection` hook handles touch-based interactions: + +- **Tap to focus:** Positions caret at touch location +- **Long press:** Triggers native selection mode +- **Token snapping:** Touch inside tokens snaps to token boundaries +- **Debouncing:** Prevents rapid touch event issues + +### Virtual Keyboard Detection + +The `useVirtualKeyboard` hook uses the `visualViewport` API to detect keyboard visibility: + +```tsx + { + console.log('Keyboard:', open ? 'open' : 'closed') + }} +/> +``` + +When the keyboard opens, the editor automatically scrolls into view. + +### Portal Touch Navigation + +`Portal.List` and `Portal.Item` support touch interactions: + +- **Touch start:** Activates item (like hover on desktop) +- **Touch end:** Selects item if touch didn't move (tap detection) +- **Scroll vs tap:** Movement >10px cancels selection + +```tsx +// Portal items work the same on touch and desktop + + + {item.label} + + +``` + +### iOS-Specific Handling + +- **Selection events:** iOS fires `selectionchange` on `document`, not the element. Added document-level listener in `useSelection`. +- **Anchor rect updates:** iOS can return stale caret rects after text changes. The `useSelection` hook listens for `input` and `visualViewport` events, using `requestAnimationFrame` to read the rect after layout stabilizes. This ensures popovers follow the caret correctly. +- **Composition data:** iOS Safari sometimes omits data in `compositionend`. Tracked via `compositionupdate` as fallback. +- **iPad detection:** Includes modern iPads that report as "MacIntel" with touch. +- **Multi-word suggestions prevention:** Calling `preventDefault()` on `beforeinput` for text insertions triggers iOS to show multi-word predictions (e.g., "I am going to the" as a single suggestion). However, iOS doesn't send usable event data for these predictions. By NOT calling `preventDefault()` on iOS for `insertText`, iOS shows only single-word suggestions which work correctly. The DOM is modified natively and synced to React state via the `input` event handler. +- **Token context exception:** When the cursor is inside a token, we use the controlled path (with `preventDefault`) even on iOS. This avoids issues where `data-token-text` attributes become stale after edits, causing `serializeRawFromDom` to return incorrect values. +- **Newline handling with React:** When content contains newlines, React renders `` elements. If iOS modifies the DOM directly around `` elements, React reconciliation fails with "NotFoundError". Solution: use a `valueRef` to detect newlines synchronously (before React re-renders) and call `preventDefault()` when newlines exist, handling the input via the controlled path. +- **Swipe-text after newlines:** iOS sends swipe data with a leading space even at the start of a line (after `\n`). This space is stripped. iOS may also send the space as a SEPARATE event before the word—single-space insertions at line start are skipped entirely. +- **Swipe-text word deletion:** When user swipe-types a word and presses backspace, iOS sends a single `deleteContentBackward` event with a targetRange covering only the last character. However, if we don't `preventDefault()`, iOS fires 5 rapid delete events and deletes the whole word natively. Since we need to `preventDefault()` to maintain controlled state, we track multi-char inserts and delete the entire chunk when backspace is pressed immediately after. +- **Swipe-text space preservation:** iOS auto-inserts a leading space when swipe-typing after existing text (e.g., "hello" + swipe "world" → "hello world"). When deleting, only the word is removed, preserving the auto-inserted space. +- **Autocomplete suggestions:** For `insertReplacementText` (autocomplete), iOS may not provide the replacement data when `preventDefault()` is called. On iOS, autocomplete is always handled via DOM sync regardless of newlines. +- **Autocomplete state reset:** After pressing Enter, the `autocomplete` attribute is briefly toggled to reset iOS's autocomplete context. This prevents iOS from suggesting merged words like "helloworld" when the actual text is "hello\nworld". + +### Android-Specific Handling + +- **GBoard predictions:** Handles `insertReplacementText` input type for word predictions. Replacement text is in `event.data`. +- **Delete variations:** Handles `deleteWordBackward`, `deleteWordForward`, `deleteSoftLineBackward`, `deleteSoftLineForward` input types. + +### iOS Safari Text Suggestions + +When a user taps a keyboard suggestion on iOS Safari: +1. iOS fires `insertReplacementText` with `data: null` and the replacement text in `event.dataTransfer.getData('text/plain')` +2. This differs from Android which puts the text in `event.data` +3. The handler checks both `data` and `dataTransfer` to support both platforms + +### Testing Mobile + +Mobile tests use Playwright with device emulation: + +```bash +# Run mobile-specific tests +bun run test:ct -- --project=mobile-chrome +bun run test:ct -- --project=mobile-safari +``` + +Test files in `__ct__/inlay.mobile.spec.tsx` cover: +- Touch-based caret positioning +- Mobile attribute presence +- Portal touch navigation +- Token interaction on touch + +## Accessibility + +Inlay provides baseline accessibility out of the box: + +- `role="textbox"` and `aria-multiline` are set automatically +- Default `aria-label="Text input"` — consumers should override with context-specific labels +- Placeholder is marked `aria-hidden="true"` to avoid duplicate announcements + +**Automated a11y testing:** Uses `@axe-core/playwright` to catch WCAG violations in CI. Tests cover empty state, with-tokens, and focused states. + +**Consumer responsibilities:** +- Provide meaningful `aria-label` or `aria-labelledby` for the editor context +- Ensure token visual styling meets contrast requirements +- Test with actual screen readers (VoiceOver, NVDA) for announcement quality + +## Testing + +- **`__ct__/`** — Playwright component tests (real browser, keyboard simulation) +- **`__tests__/`** — Vitest unit tests (JSDOM, faster iteration) + +Run with: +```bash +bun run test:ct -- src/inlay/__ct__/ # Playwright +bun run test -- src/inlay/ # Vitest +``` + +## Common Patterns + +### Adding a new keyboard shortcut +1. Add handler in `use-key-handlers.ts` `onKeyDown` +2. Check for modifier keys, prevent default, update value +3. Use `setDomSelection` to position cursor after state update + +### Adding clipboard behavior +1. Modify `use-clipboard.ts` +2. Use `getSelectionFromDom` for token-aware selection +3. Use `cfg.getValue()` to read raw value, `cfg.setValue()` to update + +### Creating a new token type +1. Define a `Matcher` in `string-utils.ts` format +2. Use with `Inlay.StructuredInlay` plugins or manually with `` + +## Known Limitations + +- Single-line by default (`multiline` prop enables multi-line) +- No rich formatting (bold, italic) — tokens only +- No nested tokens +- IME composition with tokens at boundaries can be tricky +- Mobile autocorrect is disabled by default (would interfere with tokens) +- Samsung keyboard may have composition quirks (test thoroughly) + diff --git a/src/inlay/ISSUE-ios-multiword-suggestions.md b/src/inlay/ISSUE-ios-multiword-suggestions.md new file mode 100644 index 0000000..d0a8823 --- /dev/null +++ b/src/inlay/ISSUE-ios-multiword-suggestions.md @@ -0,0 +1,184 @@ +# iOS Multi-Word Keyboard Suggestions Not Working + +## Problem Statement + +When using Inlay on iOS Safari, multi-word keyboard predictions (like "tell" → "tell them") don't work. Tapping a multi-word suggestion either does nothing or only inserts a space. + +Single-word suggestions (like "hel" → "hello") DO work correctly. + +## Root Cause Discovery + +After extensive debugging, we discovered: + +**Calling `preventDefault()` on `beforeinput` events triggers iOS to show multi-word predictions.** + +This is unexpected iOS behavior - when we prevent default, iOS interprets it as "this app wants special input handling" and switches to an advanced keyboard mode with multi-word predictions. + +### Test Results + +| ContentEditable Setup | Multi-Word Predictions? | +|----------------------|------------------------| +| Naked (no JS) | ❌ No | +| With `onInput` only | ❌ No | +| With `onBeforeInput` (no prevent) | ❌ No | +| With `onBeforeInput` + `preventDefault()` | ✅ Yes | +| Inlay.Root | ✅ Yes | +| StructuredInlay | ✅ Yes | + +### The Dilemma + +1. **We need `preventDefault()`** to control input, especially around structured tokens +2. **`preventDefault()` triggers multi-word predictions** on iOS +3. **iOS doesn't send usable events** for multi-word predictions - no `data`, no `dataTransfer`, nothing to intercept + +## What Works + +**Single-word suggestions** work correctly: +- iOS sends `inputType: "insertReplacementText"` with `data: null` +- The actual text is in `event.dataTransfer.getData('text/plain')` +- We handle this in `use-key-handlers.ts` around line 150 + +```typescript +// Get replacement text: try data first (Android), then dataTransfer (iOS Safari) +const replacementText = data ?? event.dataTransfer?.getData('text/plain') +``` + +## What Doesn't Work + +**Multi-word predictions** fail because: +1. When user taps a multi-word suggestion, iOS sends `textInput` with just `data: " "` (space) +2. No actual suggestion text is provided anywhere +3. We can't handle what we can't detect + +## Potential Solutions + +### 1. Allow + Resync on Input +Don't `preventDefault()`. Let iOS modify the DOM, then on `input`: +- Parse the DOM content +- Reconstruct token state +- Sync to React + +**Challenge**: Token integrity - if iOS types into a token span, we need to correctly parse it back. + +### 2. Invisible Input Overlay (iOS only) +Mount a real `` or `` on iOS that: +- Is invisible but captures all native input +- Gets all the proper iOS events +- Syncs to the visible contentEditable display + +**Advantage**: Clean separation - iOS talks to native input, we control display. + +### 3. Hybrid preventDefault +Only call `preventDefault()` when cursor is in/near tokens. Let iOS handle plain text areas normally. + +**Challenge**: Detecting "near tokens" reliably, edge cases. + +### 4. Parse-Based Reconciliation +After any DOM mutation: +- Diff the DOM against expected state +- Reconcile differences +- Update React state accordingly + +**Similar to #1** but more focused on diffing. + +## Relevant Code Locations + +- `src/inlay/hooks/use-key-handlers.ts` - Main input handling, `preventDefault()` calls +- `src/inlay/inlay.tsx` - ContentEditable element, attributes +- `src/inlay/stories/structured.stories.tsx` - Test stories including `NakedContentEditable` + +## Test Stories Created + +In `structured.stories.tsx`: + +### `MinimalInlay` +- Minimal Inlay.Root (no plugins) +- StructuredInlay (no plugins) +- Both show multi-word predictions (confirms it's core Inlay, not plugins) + +### `NakedContentEditable` +Key test cases to isolate the cause: + +1. **Naked (no JS)** - Single-word only ✅ +2. **With preventDefault on beforeinput** - Multi-word predictions ❌ +3. **With React controlled state** - Single-word only ✅ +4. **Handle on input instead of beforeinput** - Single-word only ✅ + +The 4th test case is the key insight: if you add a `beforeinput` handler but DON'T call `preventDefault()`, you still get single-word only. It's specifically the `preventDefault()` call that triggers multi-word. + +## Debug Logging (Currently Active) + +Extensive logging is currently in `use-key-handlers.ts` for iOS debugging. These are useful for testing on real devices: + +- `[beforeinput]` - Logs all beforeinput events with inputType, data, dataTransfer, cancelable, etc. +- `[insertText]` - Logs text insertions with position info +- `[insertReplacementText]` - Logs iOS/Android word predictions +- `[MutationObserver]` - Logs DOM changes not caught by events +- `[textInput]` - Logs native textInput events with DOM content +- `[input]` - Logs post-input events +- `[document textInput/input/beforeinput]` - Document-level listeners + +There are also document-level event listeners added in the `useEffect` for catching events iOS might send elsewhere. + +**Note**: These should be removed or made conditional before shipping to production. + +## Current State + +The code has: +1. ✅ **Single-word `insertReplacementText` fix** - Working! Checks `dataTransfer` when `data` is null +2. ✅ **Debug logging** - Active, useful for iOS testing on real devices +3. ⚠️ **Space-handling experiment** - Code at ~line 355 that doesn't `preventDefault()` for space insertions (was testing if iOS sends more events after space) +4. ⚠️ **MutationObserver with debouncing** - Syncs DOM to React state when we don't prevent default +5. ⚠️ **`lastPreventedTimeRef` tracking** - Tracks when we prevented vs didn't, so MutationObserver knows when to sync +6. ⚠️ **`preventAndMark()` helper** - Wrapper around `preventDefault()` that also updates the ref + +## Test Status + +Some tests in `inlay.ios-swipe-text.spec.tsx` are **currently failing** due to experimental changes: + +``` +3 failed: +- backspace after swipe + trailing space should delete swiped word +- backspace after multiple swipes should delete most recent word +- swipe after trailing space should not create double space +11 passed +``` + +The failures are because of the space-handling experiment (line ~355) that doesn't `preventDefault()` for spaces. This breaks the controlled input flow. + +**To fix**: Either revert the space experiment or update the tests. + +## Recommended Next Steps + +1. **Choose an approach** from the solutions above +2. **Prototype** the chosen approach +3. **Test on real iOS device** (critical - simulators may differ) +4. **Fix or update failing tests** +5. **Clean up debug logging** before shipping +6. **Update ARCHITECTURE.md** with final solution + +## iOS Keyboard Behavior Notes + +- `autocorrect="on"` vs omitted behaves the same (both show multi-word when preventDefault is used) +- `spellcheck`, `autocapitalize`, `role`, `inputMode` don't affect multi-word prediction appearance +- The trigger is specifically `preventDefault()` on `beforeinput` events +- iOS System Settings → Keyboard → Predictive controls this at OS level, but we can't control it from web + +## Code Changes Made + +### `inlay.tsx` +- Changed `autoCorrect` default from `'off'` to `undefined` (omit attribute, let iOS decide) + +### `use-key-handlers.ts` +- Added `lastPreventedTimeRef` to track when we prevent default +- Added `preventAndMark()` helper function +- Added extensive debug logging +- Added MutationObserver that syncs DOM to state when we don't prevent +- Added document-level event listeners for debugging +- Added space-handling experiment (line ~355) + +### `structured.stories.tsx` +- Removed `autoCorrect="on"` from main story (using default now) +- Added `MinimalInlay` story +- Added `NakedContentEditable` story with 4 test cases + diff --git a/src/inlay/__ct__/fixtures/auto-update-inlay.tsx b/src/inlay/__ct__/fixtures/auto-update-inlay.tsx new file mode 100644 index 0000000..fe2033b --- /dev/null +++ b/src/inlay/__ct__/fixtures/auto-update-inlay.tsx @@ -0,0 +1,78 @@ +import React from 'react' +import { StructuredInlay } from '../../structured/structured-inlay' +import { createRegexMatcher } from '../../internal/string-utils' +import type { Plugin } from '../../structured/plugins/plugin' + +type TokenData = { raw: string; name?: string } + +/** + * Token component that auto-updates after mount (like the storybook MentionToken). + * This creates divergence: raw "@user" → display "User Name" + */ +function MentionToken({ + token, + update +}: { + token: TokenData + update: (data: Partial) => void +}) { + const ref = React.useRef(null) + + React.useEffect(() => { + // Only run in the visible render (not the hidden registration pass) + // Check if we're inside a display:none container + if (ref.current && ref.current.offsetParent === null) { + return + } + + // If no name yet, "fetch" and update (simulates async user lookup) + if (!token.name && token.raw.startsWith('@')) { + const timeout = setTimeout(() => { + update({ name: 'User Name' }) + }, 10) + return () => clearTimeout(timeout) + } + }, [token.raw, token.name, update]) + + return ( + + {token.name ?? token.raw} + + ) +} + +/** + * Test fixture with auto-updating diverged tokens. + * Each @mention triggers an update() call after mount. + */ +export function AutoUpdateInlay({ initial }: { initial: string }) { + const [value, setValue] = React.useState(initial) + + const plugins: Plugin[] = React.useMemo( + () => [ + { + props: {}, + matcher: createRegexMatcher('mention', { + regex: /@\w+/g, + transform: (m) => ({ raw: m[0] }) + }), + render: ({ token, update }) => ( + + ), + portal: () => null, + onInsert: () => {}, + onKeyDown: () => false + } + ], + [] + ) + + return ( + + ) +} diff --git a/src/inlay/__ct__/fixtures/controlled-token-inlay.tsx b/src/inlay/__ct__/fixtures/controlled-token-inlay.tsx new file mode 100644 index 0000000..9c5877a --- /dev/null +++ b/src/inlay/__ct__/fixtures/controlled-token-inlay.tsx @@ -0,0 +1,17 @@ +import React from 'react' +import { Inlay } from '../..' + +export function ControlledTokenInlay({ initial }: { initial: string }) { + const [value, setValue] = React.useState(initial) + return ( + + {value.includes('@x') ? ( + + @x + + ) : ( + + )} + + ) +} diff --git a/src/inlay/__ct__/fixtures/diverged-token-inlay.tsx b/src/inlay/__ct__/fixtures/diverged-token-inlay.tsx new file mode 100644 index 0000000..aa0b5f7 --- /dev/null +++ b/src/inlay/__ct__/fixtures/diverged-token-inlay.tsx @@ -0,0 +1,35 @@ +import React from 'react' +import { Inlay } from '../..' + +/** + * A fixture with a "diverged" token where the visual display + * differs from the raw value: + * - Raw value: "@alice" (7 chars) + * - Visual display: "Alice" (5 chars) + */ +export function DivergedTokenInlay({ + initial, + onValueChange +}: { + initial: string + onValueChange?: (value: string) => void +}) { + const [value, setValue] = React.useState(initial) + + const handleChange = (newValue: string) => { + setValue(newValue) + onValueChange?.(newValue) + } + + return ( + + {value.includes('@alice') ? ( + + Alice + + ) : ( + + )} + + ) +} diff --git a/src/inlay/__ct__/fixtures/mobile-inlay.tsx b/src/inlay/__ct__/fixtures/mobile-inlay.tsx new file mode 100644 index 0000000..7fb76c5 --- /dev/null +++ b/src/inlay/__ct__/fixtures/mobile-inlay.tsx @@ -0,0 +1,137 @@ +import React from 'react' +import { Inlay } from '../../index' +import { createRegexMatcher } from '../../internal/string-utils' +import type { Plugin } from '../../structured/plugins/plugin' + +const ITEMS = [ + { id: '1', label: 'Apple' }, + { id: '2', label: 'Banana' }, + { id: '3', label: 'Cherry' } +] + +type TokenData = { mention: string } + +const mentionMatcher = createRegexMatcher('mention', { + regex: /@\w+/g, + transform: (match) => ({ mention: match[0] }) +}) + +/** + * A fixture for testing mobile interactions with Inlay. + * Includes portal for testing touch-based portal navigation. + */ +export function MobileInlay({ + initial = '', + onSelect, + onKeyboardChange +}: { + initial?: string + onSelect?: (item: (typeof ITEMS)[number]) => void + onKeyboardChange?: (open: boolean) => void +}) { + const [selectedItem, setSelectedItem] = React.useState(null) + const [rawValue, setRawValue] = React.useState(initial) + const [keyboardOpen, setKeyboardOpen] = React.useState(false) + + const handleKeyboardChange = React.useCallback( + (open: boolean) => { + setKeyboardOpen(open) + onKeyboardChange?.(open) + }, + [onKeyboardChange] + ) + + const plugin: Plugin< + Record, + TokenData, + 'mention' + > = React.useMemo( + () => ({ + props: {}, + matcher: mentionMatcher, + render: ({ token }) => ( + + {token.mention} + + ), + portal: ({ replace }) => { + return ( + { + replace(`@${item.id} `) + setSelectedItem(item.label) + onSelect?.(item) + }} + data-testid="portal-list" + className="portal-list" + > + {ITEMS.map((item) => ( + + {item.label} + + ))} + + ) + }, + onInsert: () => {}, + onKeyDown: () => false + }), + [onSelect] + ) + + return ( + + + {selectedItem || 'none'} + {rawValue} + {keyboardOpen ? 'open' : 'closed'} + + ) +} + +/** + * Simple fixture for basic mobile touch tests without plugins. + */ +export function SimpleMobileInlay({ initial = '' }: { initial?: string }) { + const [value, setValue] = React.useState(initial) + + return ( + + + + User + + + {value} + + ) +} diff --git a/src/inlay/__ct__/fixtures/overlapping-plugins-inlay.tsx b/src/inlay/__ct__/fixtures/overlapping-plugins-inlay.tsx new file mode 100644 index 0000000..24f7beb --- /dev/null +++ b/src/inlay/__ct__/fixtures/overlapping-plugins-inlay.tsx @@ -0,0 +1,191 @@ +import React from 'react' +import { StructuredInlay } from '../../structured/structured-inlay' +import { createRegexMatcher } from '../../internal/string-utils' +import type { Plugin } from '../../structured/plugins/plugin' + +/** + * Plugin A: matches @test exactly + * Uses DIVERGED rendering: raw "@test" → visual "[A:test]" + */ +type PluginAData = { raw: string } + +function createPluginA(): Plugin { + return { + props: {}, + matcher: createRegexMatcher('pluginA', { + regex: /@test(?!\w)/g, + transform: (match) => ({ raw: match[0] }) + }), + render: ({ token }) => ( + + [A:test] + + ), + portal: () => null, + onInsert: () => {}, + onKeyDown: () => false + } +} + +/** + * Plugin B: also matches @test exactly (same range as Plugin A) + * Uses DIVERGED rendering: raw "@test" → visual "[B:test]" + * For exact-same-substring test: we can tell which plugin won by the visual + */ +type PluginBData = { raw: string } + +function createPluginB(): Plugin { + return { + props: {}, + matcher: createRegexMatcher('pluginB', { + regex: /@test(?!\w)/g, + transform: (match) => ({ raw: match[0] }) + }), + render: ({ token }) => ( + + [B:test] + + ), + portal: () => null, + onInsert: () => {}, + onKeyDown: () => false + } +} + +/** + * Plugin Short: matches @alice (shorter pattern) + * This creates a genuine overlap scenario with Plugin Long + * Raw "@alice" → visual "[short:alice]" + */ +type ShortData = { raw: string } + +function createShortPlugin(): Plugin { + return { + props: {}, + matcher: createRegexMatcher('short', { + // Matches @alice even when followed by more characters + regex: /@alice/g, + transform: (match) => ({ raw: match[0] }) + }), + render: ({ token }) => ( + + [short:alice] + + ), + portal: () => null, + onInsert: () => {}, + onKeyDown: () => false + } +} + +/** + * Plugin Long: matches @alice_vip (longer pattern, same start position) + * This should WIN over Short plugin when both match starting at same position + * Raw "@alice_vip" → visual "[long:alice_vip]" + */ +type LongData = { raw: string } + +function createLongPlugin(): Plugin { + return { + props: {}, + matcher: createRegexMatcher('long', { + regex: /@alice_vip/g, + transform: (match) => ({ raw: match[0] }) + }), + render: ({ token }) => ( + + [long:alice_vip] + + ), + portal: () => null, + onInsert: () => {}, + onKeyDown: () => false + } +} + +/** + * Plugin Hashtag: matches #hashtag pattern (for non-overlapping scenarios) + */ +type HashtagData = { raw: string } + +function createHashtagPlugin(): Plugin { + return { + props: {}, + matcher: createRegexMatcher('hashtag', { + regex: /#\w+/g, + transform: (match) => ({ raw: match[0] }) + }), + render: ({ token }) => ( + + {token.raw} + + ), + portal: () => null, + onInsert: () => {}, + onKeyDown: () => false + } +} + +/** + * Plugin Mention: matches @username (for non-overlapping scenarios) + */ +type MentionData = { raw: string } + +function createMentionPlugin(): Plugin { + return { + props: {}, + matcher: createRegexMatcher('mention', { + regex: /@\w+/g, + transform: (match) => ({ raw: match[0] }) + }), + render: ({ token }) => ( + + {token.raw} + + ), + portal: () => null, + onInsert: () => {}, + onKeyDown: () => false + } +} + +export type OverlapScenario = + | 'exact-same' // Two plugins match exact same @test - diverged renders let us see which won + | 'longer-wins' // @alice vs @alice_vip at same start position - longer should win + | 'non-overlapping' // @bob and #hashtag - both should render + +type Props = { + initial: string + scenario: OverlapScenario +} + +export function OverlappingPluginsInlay({ initial, scenario }: Props) { + const [value, setValue] = React.useState(initial) + + const plugins = React.useMemo(() => { + switch (scenario) { + case 'exact-same': + // Both plugins match @test at the same range + // Plugin A renders "[A:test]", Plugin B renders "[B:test]" + // We can tell which won by the visual output + return [createPluginA(), createPluginB()] as const + case 'longer-wins': + // Short matches "@alice" (6 chars), Long matches "@alice_vip" (10 chars) + // Both start at the same position, but Long is longer + // Long should win per longest-match-wins + return [createShortPlugin(), createLongPlugin()] as const + case 'non-overlapping': + // Different patterns that don't overlap + return [createMentionPlugin(), createHashtagPlugin()] as const + } + }, [scenario]) + + return ( + + ) +} diff --git a/src/inlay/__ct__/fixtures/portal-navigation-inlay.tsx b/src/inlay/__ct__/fixtures/portal-navigation-inlay.tsx new file mode 100644 index 0000000..eabd7b3 --- /dev/null +++ b/src/inlay/__ct__/fixtures/portal-navigation-inlay.tsx @@ -0,0 +1,92 @@ +import React from 'react' +import { Inlay } from '../../index' +import { createRegexMatcher } from '../../internal/string-utils' +import type { Plugin } from '../../structured/plugins/plugin' + +const ITEMS = [ + { id: '1', label: 'Apple' }, + { id: '2', label: 'Banana' }, + { id: '3', label: 'Cherry' } +] + +type TokenData = { mention: string; resolved?: boolean } + +const mentionMatcher = createRegexMatcher('mention', { + regex: /@\w+/g, // Use + to require at least one char after @ + transform: (match) => ({ mention: match[0] }) +}) + +/** + * A fixture for testing Inlay.Portal.List keyboard navigation. + * Uses Inlay.StructuredInlay with a mention-style plugin. + */ +export function PortalNavigationInlay({ + initial = '', + onSelect +}: { + initial?: string + onSelect?: (item: (typeof ITEMS)[number]) => void +}) { + const [selectedItem, setSelectedItem] = React.useState(null) + const [rawValue, setRawValue] = React.useState(initial) + + const plugin: Plugin< + Record, + TokenData, + 'mention' + > = React.useMemo( + () => ({ + props: {}, + matcher: mentionMatcher, + render: ({ token }) => ( + + {token.mention} + + ), + portal: ({ replace }) => { + // Always show portal for testing + return ( + { + replace(`@${item.id} `) + setSelectedItem(item.label) + setRawValue((prev) => prev.replace(/@\w*$/, `@${item.id} `)) + onSelect?.(item) + }} + data-testid="portal-list" + className="portal-list" + > + {ITEMS.map((item) => ( + + {item.label} + + ))} + + ) + }, + onInsert: () => {}, + onKeyDown: () => false + }), + [onSelect] + ) + + return ( + + + {selectedItem || 'none'} + {rawValue} + + ) +} diff --git a/src/inlay/__ct__/fixtures/structured-actions-inlay.tsx b/src/inlay/__ct__/fixtures/structured-actions-inlay.tsx new file mode 100644 index 0000000..54f3549 --- /dev/null +++ b/src/inlay/__ct__/fixtures/structured-actions-inlay.tsx @@ -0,0 +1,61 @@ +import React from 'react' +import { StructuredInlay } from '../../structured/structured-inlay' +import { createRegexMatcher } from '../../internal/string-utils' +import type { Plugin } from '../../structured/plugins/plugin' + +type TokenData = { raw: string; label?: string } + +/** + * Test fixture that exposes replace/update via buttons in the portal for easy testing + */ +export function StructuredActionsInlay({ initial }: { initial: string }) { + const [value, setValue] = React.useState(initial) + + const plugins: Plugin[] = React.useMemo( + () => [ + { + props: {}, + matcher: createRegexMatcher('mention', { + regex: /@\w+/g, + transform: (m) => ({ raw: m[0] }) + }), + render: ({ token }) => ( + {token.label ?? token.raw} + ), + portal: ({ replace, update }) => { + return ( + e.preventDefault()}> + update({ label: 'UpdatedLabel' })} + > + Update + + replace('@replaced')} + > + Replace + + + ) + }, + onInsert: () => {}, + onKeyDown: () => false + } + ], + [] + ) + + return ( + + + {value} + + ) +} diff --git a/src/inlay/__ct__/inlay.a11y.spec.tsx b/src/inlay/__ct__/inlay.a11y.spec.tsx new file mode 100644 index 0000000..06f5fcc --- /dev/null +++ b/src/inlay/__ct__/inlay.a11y.spec.tsx @@ -0,0 +1,51 @@ +import { test, expect } from '@playwright/experimental-ct-react' +import AxeBuilder from '@axe-core/playwright' +import { DivergedTokenInlay } from './fixtures/diverged-token-inlay' +import { Inlay } from '../..' + +test.describe('Inlay accessibility', () => { + test('empty state has no a11y violations', async ({ mount, page }) => { + await mount() + const results = await new AxeBuilder({ page }) + .include('[role="textbox"]') + .analyze() + expect(results.violations).toEqual([]) + }) + + test('with tokens has no a11y violations', async ({ mount, page }) => { + await mount() + const results = await new AxeBuilder({ page }) + .include('[role="textbox"]') + .analyze() + expect(results.violations).toEqual([]) + }) + + test('focused state has no a11y violations', async ({ mount, page }) => { + await mount() + await page.getByRole('textbox').focus() + const results = await new AxeBuilder({ page }) + .include('[role="textbox"]') + .analyze() + expect(results.violations).toEqual([]) + }) + + test('has default aria-label', async ({ mount, page }) => { + await mount( + + + + ) + const editor = page.getByRole('textbox') + await expect(editor).toHaveAttribute('aria-label', 'Text input') + }) + + test('aria-label can be overridden', async ({ mount, page }) => { + await mount( + + + + ) + const editor = page.getByRole('textbox') + await expect(editor).toHaveAttribute('aria-label', 'Message composer') + }) +}) diff --git a/src/inlay/__ct__/inlay.basic.spec.tsx b/src/inlay/__ct__/inlay.basic.spec.tsx new file mode 100644 index 0000000..b623d29 --- /dev/null +++ b/src/inlay/__ct__/inlay.basic.spec.tsx @@ -0,0 +1,23 @@ +import { test, expect } from '@playwright/experimental-ct-react' +import { Inlay } from '../' + +test('basic typing/backspace', async ({ mount, page }) => { + await mount( + + + + ) + + const root = page.getByTestId('root') + await expect(root).toHaveCount(1) + + const ed = page.getByRole('textbox') + await ed.click() + await page.keyboard.press('ArrowRight') + await page.keyboard.press('ArrowRight') + await page.keyboard.type('c') + await expect(ed).toHaveText('abc') + for (let i = 0; i < 10; i++) await page.keyboard.press('ArrowRight') + await page.keyboard.press('Backspace') + await expect(ed).toHaveText('ab') +}) diff --git a/src/inlay/__ct__/inlay.clipboard.spec.tsx b/src/inlay/__ct__/inlay.clipboard.spec.tsx new file mode 100644 index 0000000..8f03867 --- /dev/null +++ b/src/inlay/__ct__/inlay.clipboard.spec.tsx @@ -0,0 +1,216 @@ +import { test, expect } from '@playwright/experimental-ct-react' +import { DivergedTokenInlay } from './fixtures/diverged-token-inlay' +import { AutoUpdateInlay } from './fixtures/auto-update-inlay' + +// Run clipboard tests serially to avoid clipboard state pollution between parallel tests +test.describe.serial('Clipboard operations with diverged tokens (CT)', () => { + // Skip on mobile-safari: WebKit's mobile emulation has different clipboard behavior + // that causes copy/paste operations to behave unexpectedly. Desktop webkit passes. + test.beforeEach(async ({}, testInfo) => { + test.skip( + testInfo.project.name === 'mobile-safari', + 'Skipped on mobile-safari: clipboard behavior differs in mobile WebKit emulation' + ) + }) + test('Select all (Ctrl/Cmd+a) selects entire content', async ({ + mount, + page + }) => { + await mount() + + const ed = page.getByRole('textbox') + await ed.click() + await expect(ed).toHaveText('Hello World') + + await page.keyboard.press('ControlOrMeta+a') + await page.keyboard.press('Backspace') + await expect(ed).toHaveText('') + }) + + test('Copy and paste a diverged token preserves raw value', async ({ + mount, + page + }) => { + await mount() + + const ed = page.getByRole('textbox') + await ed.click() + await expect(ed).toHaveText('Hello Alice!') + + // Navigate to token: start → right 6 (past "Hello ") → select token + for (let i = 0; i < 20; i++) await page.keyboard.press('ArrowLeft') + for (let i = 0; i < 6; i++) await page.keyboard.press('ArrowRight') + await page.keyboard.press('Shift+ArrowRight') + + await page.keyboard.press('ControlOrMeta+c') + + // Move to end and paste + for (let i = 0; i < 20; i++) await page.keyboard.press('ArrowRight') + await page.keyboard.press('ControlOrMeta+v') + + await expect(ed).toHaveText('Hello Alice!Alice') + await expect(ed.locator('[data-token-text="@alice"]')).toHaveCount(2) + }) + + test('Copy text containing diverged token includes raw value', async ({ + mount, + page + }) => { + await mount() + + const ed = page.getByRole('textbox') + await ed.click() + await expect(ed).toHaveText('AAliceB') + + await page.keyboard.press('ControlOrMeta+a') + await page.keyboard.press('ControlOrMeta+c') + + // Clear and type X + await page.keyboard.press('Backspace') + await expect(ed).toHaveText('') + await page.keyboard.type('X') + await expect(ed).toHaveText('X') + + // Paste at end + await page.keyboard.press('ArrowRight') + await page.keyboard.press('ControlOrMeta+v') + + await expect(ed).toHaveText('XAAliceB') + await expect(ed.locator('[data-token-text="@alice"]')).toHaveCount(1) + }) + + test('Cut diverged token removes it and pastes raw value', async ({ + mount, + page + }) => { + await mount() + + const ed = page.getByRole('textbox') + await ed.click() + await expect(ed).toHaveText('XAliceY') + + // Navigate past X and select token + for (let i = 0; i < 20; i++) await page.keyboard.press('ArrowLeft') + await page.keyboard.press('ArrowRight') + await page.keyboard.press('Shift+ArrowRight') + + await page.keyboard.press('ControlOrMeta+x') + + await expect(ed).toHaveText('XY') + await expect(ed.locator('[data-token-text="@alice"]')).toHaveCount(0) + + // Paste at end + for (let i = 0; i < 20; i++) await page.keyboard.press('ArrowRight') + await page.keyboard.press('ControlOrMeta+v') + + await expect(ed).toHaveText('XYAlice') + await expect(ed.locator('[data-token-text="@alice"]')).toHaveCount(1) + }) + + test('Paste plain text into editor works correctly', async ({ + mount, + page + }) => { + await mount() + + const ed = page.getByRole('textbox') + await ed.click() + + // Move to end + for (let i = 0; i < 20; i++) await page.keyboard.press('ArrowRight') + + // Write to clipboard and paste using real keyboard shortcut + await page.evaluate(() => navigator.clipboard.writeText('world')) + await page.keyboard.press('ControlOrMeta+v') + + await expect(ed).toHaveText('Hello world') + }) + + test('Paste text that matches token pattern creates new token', async ({ + mount, + page + }) => { + await mount() + + const ed = page.getByRole('textbox') + await ed.click() + + // Move to end + for (let i = 0; i < 20; i++) await page.keyboard.press('ArrowRight') + + // Write to clipboard and paste using real keyboard shortcut + await page.evaluate(() => navigator.clipboard.writeText('@alice')) + await page.keyboard.press('ControlOrMeta+v') + + await expect(ed.locator('[data-token-text="@alice"]')).toHaveCount(1) + await expect(ed).toHaveText('Hi Alice') + }) + + test('Rapid paste maintains caret at end of inserted text', async ({ + mount, + page + }) => { + await mount() + + const ed = page.getByRole('textbox') + await ed.click() + + // Write text to clipboard + await page.evaluate(() => navigator.clipboard.writeText('abc')) + + // Simulate rapid pasting (like holding Ctrl+V) + // Fire multiple paste events in quick succession without waiting + const pasteCount = 5 + for (let i = 0; i < pasteCount; i++) { + await page.keyboard.press('ControlOrMeta+v', { delay: 0 }) + } + + // Expected: "abcabcabcabcabc" with caret at position 15 (end) + await expect(ed).toHaveText('abcabcabcabcabc') + + // Type a character to verify caret position - should appear at the end + await page.keyboard.type('X') + await expect(ed).toHaveText('abcabcabcabcabcX') + }) + + test('Pasting many auto-updating tokens does not cause infinite loop', async ({ + mount, + page + }) => { + // Capture React errors (error #185 = Maximum update depth exceeded) + const errors: string[] = [] + page.on('pageerror', (err) => errors.push(err.message)) + + await mount() + + const ed = page.getByRole('textbox') + await ed.click() + + // Paste many tokens that will each trigger an update() call + const manyTokens = Array(100).fill('@test').join(' ') + ' ' + await page.evaluate( + (text) => navigator.clipboard.writeText(text), + manyTokens + ) + await page.keyboard.press('ControlOrMeta+v') + + // Wait for all updates to process + await page.waitForTimeout(500) + + // Should not have crashed with "Maximum update depth exceeded" + expect(errors).toHaveLength(0) + + // Should render exactly 100 tokens (no duplicates) in the visible editor + // Note: 2-pass rendering means tokens exist in both hidden and visible divs + const editor = page.getByRole('textbox') + await expect(editor.locator('[data-testid="token-render"]')).toHaveCount( + 100, + { timeout: 5000 } + ) + + // Caret should be at end after all updates, not position 0 + await page.keyboard.type('X') + const text = await editor.textContent() + expect(text?.endsWith('X')).toBe(true) + }) +}) diff --git a/src/inlay/__ct__/inlay.composition.cdp.spec.tsx b/src/inlay/__ct__/inlay.composition.cdp.spec.tsx new file mode 100644 index 0000000..63fffcb --- /dev/null +++ b/src/inlay/__ct__/inlay.composition.cdp.spec.tsx @@ -0,0 +1,78 @@ +import { test, expect, type Locator } from '@playwright/experimental-ct-react' +import { Inlay } from '../' + +// Chromium-only: use CDP to simulate IME composition end-to-end +const composeWithCDP = async ( + page: import('@playwright/test').Page, + text: string +) => { + const client = await page.context().newCDPSession(page) + await client.send('Input.imeSetComposition', { + text, + selectionStart: text.length, + selectionEnd: text.length + }) + await client.send('Input.insertText', { text }) + await client.send('Input.imeSetComposition', { + text: '', + selectionStart: 0, + selectionEnd: 0 + }) +} + +// Check that composition didn't leave broken DOM structure (multiple text nodes, etc.) +async function assertCleanTextContent(ed: Locator): Promise { + return ed.evaluate((el: HTMLElement) => { + // The editor should not have stray elements or deeply nested structures + // after composition. Text content check is the primary validation. + const brs = el.querySelectorAll('br') + return brs.length === 0 + }) +} + +test.describe.serial('IME composition via CDP (Chromium)', () => { + test.skip( + ({ browserName }) => browserName !== 'chromium', + 'CDP IME APIs are Chromium-only' + ) + + test('Space commit produces composed text and a trailing space', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + const ed = page.getByRole('textbox') + await ed.click() + + await composeWithCDP(page, 'にほん') + await page.keyboard.press('Space') + + await expect(ed).toHaveText('にほん ') + const ok = await assertCleanTextContent(ed) + expect(ok).toBe(true) + }) + + test('Enter commit composes text and does not add a stray newline immediately', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + const ed = page.getByRole('textbox') + await ed.click() + + await composeWithCDP(page, 'テスト') + await page.keyboard.press('Enter') + + await expect(ed).toHaveText('テスト') + const ok = await assertCleanTextContent(ed) + expect(ok).toBe(true) + }) +}) diff --git a/src/inlay/__ct__/inlay.grapheme.spec.tsx b/src/inlay/__ct__/inlay.grapheme.spec.tsx new file mode 100644 index 0000000..7e73627 --- /dev/null +++ b/src/inlay/__ct__/inlay.grapheme.spec.tsx @@ -0,0 +1,149 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { test, expect } from '@playwright/experimental-ct-react' +import { Inlay } from '../' + +test.describe('Grapheme handling (CT)', () => { + test('Backspace deletes an entire emoji grapheme cluster', async ({ + mount, + page + }) => { + const cluster = '👍🏼' + await mount( + + + + ) + + page.on('console', (msg) => console.log('EMOJI TEST LOG:', msg.text())) + + const ed = page.getByRole('textbox') + await ed.click() + // Normalize caret to start by overshooting ArrowLeft + for (let i = 0; i < 10; i++) await page.keyboard.press('ArrowLeft') + // Move caret to end using ArrowRight (cluster is one grapheme) + await page.keyboard.press('ArrowRight') + + await page.keyboard.press('Backspace') + await expect(ed).toHaveText('') + }) + + test('Backspace with selection slicing through a grapheme removes the whole grapheme', async ({ + mount, + page + }) => { + const cluster = '👍🏼' + const text = `a${cluster}b` + await mount( + + + + ) + + const ed = page.getByRole('textbox') + await ed.click() + // Normalize caret to start by overshooting ArrowLeft + for (let i = 0; i < 10; i++) await page.keyboard.press('ArrowLeft') + // Move to just before the grapheme (after the initial 'a') + await page.keyboard.press('ArrowRight') + // Extend selection across exactly one grapheme + await page.keyboard.press('Shift+ArrowRight') + + await page.keyboard.press('Backspace') + await expect(ed).toHaveText('ab') + }) + + test('Backspace deletes entire flag grapheme (regional indicators) before caret', async ({ + mount, + page + }) => { + const flag = '🇺🇸' + await mount( + + + + ) + + const ed = page.getByRole('textbox') + await ed.click() + // Normalize caret to start by overshooting ArrowLeft + for (let i = 0; i < 10; i++) await page.keyboard.press('ArrowLeft') + // Move to after 'a' and then after the flag grapheme + await page.keyboard.press('ArrowRight') + await page.keyboard.press('ArrowRight') + + await page.keyboard.press('Backspace') + await expect(ed).toHaveText('ab') + }) + + test('Backspace deletes composed character with combining mark as a single grapheme', async ({ + mount, + page + }) => { + const composed = 'e\u0301' + await mount( + + + + ) + + const ed = page.getByRole('textbox') + await ed.click() + // Move cursor to end + await page.keyboard.press('End') + + // Capture page console logs + page.on('console', (msg) => console.log('PAGE:', msg.text())) + + // Test log to verify capturing works + await page.evaluate(() => console.log('TEST LOG FROM PAGE')) + + // Check if React's onBeforeInput is attached + await page.evaluate(() => { + const editor = document.querySelector('[role="textbox"]') as HTMLElement + const reactKey = Object.keys(editor).find((k) => + k.startsWith('__reactProps$') + ) + if (reactKey) { + const props = (editor as any)[reactKey] + console.log( + 'React props keys:', + Object.keys(props || {}).filter((k) => + k.toLowerCase().includes('input') + ) + ) + console.log('Has onBeforeInput:', typeof props?.onBeforeInput) + } else { + console.log('No React props found') + } + }) + + // Check value before backspace + const valueBefore = await page.evaluate(() => { + const editor = document.querySelector('[role="textbox"]') as HTMLElement + return { + textContent: editor.textContent, + innerHTML: editor.innerHTML, + charCodes: Array.from(editor.textContent || '').map((c) => + c.charCodeAt(0) + ) + } + }) + console.log('Before backspace:', JSON.stringify(valueBefore)) + + await page.keyboard.press('Backspace') + + // Check what the actual text content is + const valueAfter = await page.evaluate(() => { + const editor = document.querySelector('[role="textbox"]') as HTMLElement + return { + textContent: editor.textContent, + charCodes: Array.from(editor.textContent || '').map((c) => + c.charCodeAt(0) + ) + } + }) + console.log('After backspace:', JSON.stringify(valueAfter)) + + await expect(ed).toHaveText('') + }) +}) diff --git a/src/inlay/__ct__/inlay.ios-swipe-text.spec.tsx b/src/inlay/__ct__/inlay.ios-swipe-text.spec.tsx new file mode 100644 index 0000000..d7901dd --- /dev/null +++ b/src/inlay/__ct__/inlay.ios-swipe-text.spec.tsx @@ -0,0 +1,1394 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { test, expect } from '@playwright/experimental-ct-react' +import { Inlay } from '../' + +/** + * iOS Swipe-Text Bug Reproduction + * + * THE BUG (from real iOS device testing): + * When user swipe-types "hello" and presses backspace ONCE: + * 1. iOS fires a SINGLE insertText event with data="hello" (whole word) + * 2. iOS fires a SINGLE deleteContentBackward event with targetRanges=4-5 (last char only!) + * 3. If we DON'T preventDefault, iOS fires 5 rapid deleteContentBackward events + * and natively deletes the whole word + * 4. But if we DO preventDefault on the first event, iOS stops sending the + * remaining events, so we only see the range for the last char + * + * EXPECTED: Entire swipe-typed word deleted + * ACTUAL BUG: Only last char deleted, "hello" -> "hell" + * + * THE FIX: Track multi-char inserts, and if deleteContentBackward happens + * immediately after at the end of the inserted chunk, delete the whole chunk. + * + * SPACE PRESERVATION: When swipe-typing after existing text, iOS inserts + * " word" (with leading space). On backspace, only "word" should be deleted, + * preserving the auto-inserted space. + * + * NOTE: These tests use synthetic events to simulate iOS/Android native IME behavior. + * They pass on desktop browsers but fail on Playwright's mobile-safari emulation + * because WebKit in mobile touch mode has different event handling. The mobile-safari + * project is NOT real iOS Safari - it's desktop WebKit with iPhone viewport/user-agent. + * Real iOS testing requires actual devices or cloud device farms. + */ + +test.describe('iOS swipe-text bug', () => { + // Skip on mobile-safari: Playwright's mobile-safari is desktop WebKit with mobile viewport, + // not real iOS Safari. It has bugs with keyboard input (text reversal) and synthetic events. + test.beforeEach(async ({}, testInfo) => { + test.skip( + testInfo.project.name === 'mobile-safari', + 'Skipped on mobile-safari: cannot emulate iOS native IME behavior' + ) + }) + + /** + * Test that input works after deleting content. + * Verifies no crash when typing after deletion. + */ + test('input after deleting swipe-typed word should not crash', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + await page.keyboard.press('End') + await expect(ed).toHaveText('hello') + + // Delete all characters with delay between presses + for (let i = 0; i < 5; i++) { + await page.keyboard.press('Backspace') + await page.waitForTimeout(30) + } + + // Should be empty (or have zero-width space) + await expect(ed).toHaveText(/^[\u200B]?$/) + + // Wait for selection to settle + await page.waitForTimeout(50) + + // Type new text - should not crash + await page.keyboard.type('world', { delay: 30 }) + + await expect(ed).toHaveText('world') + }) + + /** + * Test that selection updates work correctly even after DOM nodes are replaced. + * This tests the robustness of setDomSelection when React re-renders. + */ + test('setDomSelection should not crash when nodes are replaced', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + await page.keyboard.press('End') + await expect(ed).toHaveText('hello') + + // Delete all characters - this causes DOM nodes to be replaced + for (let i = 0; i < 5; i++) { + await page.keyboard.press('Backspace') + await page.waitForTimeout(30) + } + + // Should be empty + await expect(ed).toHaveText(/^[\u200B]?$/) + + // Wait for selection to settle + await page.waitForTimeout(50) + + // Insert new text - should work without crash + await page.keyboard.type('x') + + await expect(ed).toHaveText('x') + }) + + /** + * CRASH BUG VARIANT: Selection points outside editor after deletion. + * On iOS, after rapid deletions the selection may end up pointing to + * a node that's no longer in the editor, causing getAbsoluteOffset to fail. + */ + test('insert should handle selection outside editor gracefully', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + await page.keyboard.press('End') + + const result = await page.evaluate(async () => { + const editor = document.querySelector('[role="textbox"]') as HTMLElement + + const reactPropsKey = Object.keys(editor).find((k) => + k.startsWith('__reactProps$') + ) + if (!reactPropsKey) return { error: 'No React props' } + + const reactProps = (editor as any)[reactPropsKey] + const onBeforeInput = reactProps?.onBeforeInput + if (!onBeforeInput) return { error: 'No onBeforeInput' } + + // Force selection to be outside the editor (simulating iOS bug) + const bodyTextNode = document.createTextNode('outside') + document.body.appendChild(bodyTextNode) + const sel = window.getSelection() + sel?.removeAllRanges() + const badRange = document.createRange() + badRange.setStart(bodyTextNode, 0) + badRange.setEnd(bodyTextNode, 0) + sel?.addRange(badRange) + + let insertError: string | undefined + + const insertEvent = { + type: 'beforeinput', + inputType: 'insertText', + data: 'x', + preventDefault: () => {}, + stopPropagation: () => {}, + nativeEvent: { + type: 'beforeinput', + inputType: 'insertText', + data: 'x', + getTargetRanges: () => [] + } + } + + try { + onBeforeInput(insertEvent) + } catch (e) { + insertError = String(e) + } + + // Cleanup + document.body.removeChild(bodyTextNode) + + await new Promise((r) => setTimeout(r, 50)) + + return { + insertError, + finalText: editor.textContent + } + }) + + console.log('Result:', JSON.stringify(result, null, 2)) + + // Should NOT crash - should handle gracefully + expect(result.insertError).toBeUndefined() + }) + + /** + * iOS VALUE DIVERGENCE BUG: + * After swipe-typing and pressing backspace, the React value state + * must stay in sync with the DOM. + * + * This test verifies that backspace via native beforeinput correctly + * updates the value even when coming from swipe-text scenarios. + */ + test('backspace via beforeinput after insert should update value', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + + // Ensure we're at the end of the content + await page.keyboard.press('End') + await expect(ed).toHaveText('hello') + + // Press backspace - this fires native beforeinput which our handler catches + await page.keyboard.press('Backspace') + + // Value should update from "hello" to "hell" + await expect(ed).toHaveText('hell') + }) + + /** + * iOS Safari crash: backspace then swipe-type causes "The object can not be found here" + * + * This test verifies that pressing backspace then typing doesn't crash + * after DOM updates from deletion. + */ + test('iOS Safari: backspace then textInput should not crash', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + await page.keyboard.press('End') + + // Step 1: Backspace to delete "o" + await page.keyboard.press('Backspace') + await expect(ed).toHaveText('hell') + + // Wait for selection to settle after React re-render + await page.waitForTimeout(50) + + // Step 2: Type new text (one char at a time with delay to avoid race) + await page.keyboard.type('world', { delay: 30 }) + + // Should have backspaced then typed + await expect(ed).toHaveText('hellworld') + }) + + /** + * Test that text insertion works correctly via real browser events. + * Note: iOS Safari's legacy textInput event is handled by the native + * beforeinput listener on real iOS devices. + */ + test('iOS Safari textInput event should insert text', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + + // Type text using real browser events + await page.keyboard.type('hello') + + // Text should be inserted + await expect(ed).toHaveText('hello') + }) + + test('swipe-text after backspace should not crash', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + await page.keyboard.press('End') + await expect(ed).toHaveText('hello') + + // Delete all characters with backspace (with delay to avoid race conditions) + for (let i = 0; i < 5; i++) { + await page.keyboard.press('Backspace') + await page.waitForTimeout(30) + } + + // Wait for deletions to complete + await expect(ed).toHaveText(/^[\u200B]?$/) // empty or zero-width space + + // Wait for selection to settle + await page.waitForTimeout(50) + + // Now type new text + await page.keyboard.type('world', { delay: 30 }) + + // Text should be "world" after inserting + await expect(ed).toHaveText('world') + }) + + /** + * iOS swipe-text word deletion - ACTUAL BEHAVIOR (from real device testing): + * + * When user swipe-types "hello" and presses backspace ONCE: + * 1. iOS fires a SINGLE insertText event with data="hello" (whole word) + * 2. iOS fires a SINGLE deleteContentBackward event with targetRanges=4-5 (last char only!) + * 3. BUT if we don't preventDefault, iOS natively deletes the whole word + * + * The bug: When we preventDefault and handle it ourselves, we only see the + * targetRange for the last character and delete only that character. + * + * The fix (Option 2): Track when a multi-char insert happens, and if + * deleteContentBackward is pressed immediately after at the end of that + * inserted chunk, delete the whole chunk. + */ + test('iOS swipe-text: backspace after swipe-typed word should delete entire word', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + + // Simulate iOS swipe-text behavior + const result = await page.evaluate(async () => { + const editor = document.querySelector('[role="textbox"]') as HTMLElement + + // Step 1: Simulate swipe-typing "hello" - iOS sends a single insertText with the whole word + const insertEvent = new InputEvent('beforeinput', { + inputType: 'insertText', + data: 'hello', + bubbles: true, + cancelable: true + }) + + // Set up a collapsed range at position 0 for the insertion point + const sel = window.getSelection() + if (sel && sel.rangeCount > 0) { + const range = sel.getRangeAt(0) + ;(insertEvent as any).getTargetRanges = () => [ + { + startContainer: range.startContainer, + startOffset: range.startOffset, + endContainer: range.endContainer, + endOffset: range.endOffset, + collapsed: true + } + ] + } else { + ;(insertEvent as any).getTargetRanges = () => [] + } + + editor.dispatchEvent(insertEvent) + + // Wait for React to process the insert + await new Promise((r) => setTimeout(r, 50)) + + const afterInsert = editor.textContent?.replace(/\u200B/g, '') + + // Step 2: Simulate pressing backspace - iOS sends ONE deleteContentBackward + // with targetRange covering only the LAST character (4-5), NOT the whole word + const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT) + let textNode: Text | null = null + while (walker.nextNode()) { + const node = walker.currentNode as Text + if (node.textContent && node.textContent.length > 0) { + textNode = node + break + } + } + + if (!textNode) { + return { error: 'No text node found after insert', afterInsert } + } + + const deleteEvent = new InputEvent('beforeinput', { + inputType: 'deleteContentBackward', + bubbles: true, + cancelable: true + }) + + // iOS only provides targetRange for the LAST character, not the whole word! + // This is the key part of the bug - even though iOS would natively delete + // the whole word, the beforeinput event only reports the last char range. + const textLen = textNode.textContent?.length || 0 + ;(deleteEvent as any).getTargetRanges = () => [ + { + startContainer: textNode, + startOffset: textLen - 1, // e.g., 4 for "hello" + endContainer: textNode, + endOffset: textLen, // e.g., 5 for "hello" + collapsed: false + } + ] + + editor.dispatchEvent(deleteEvent) + + // Wait for React to process the delete + await new Promise((r) => setTimeout(r, 50)) + + return { + afterInsert, + finalText: editor.textContent, + finalLength: editor.textContent?.replace(/\u200B/g, '').length + } + }) + + console.log('Result:', JSON.stringify(result, null, 2)) + + // Verify the insert worked + expect(result.afterInsert).toBe('hello') + + // EXPECTED: Entire swipe-typed word should be deleted + // ACTUAL BUG: Only last char deleted, "hello" -> "hell" + expect(result.finalLength).toBe(0) + }) + + /** + * iOS swipe-text space preservation: + * + * When user has "hello|" and swipe-types "world", iOS inserts " world" (with leading space). + * When backspace is pressed, iOS native deletes only "world" and preserves the space. + * Result should be "hello " (with trailing space), not "hello" (no space). + */ + test('iOS swipe-text: backspace after swipe-typed word should preserve auto-inserted space', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + await page.keyboard.press('End') + await expect(ed).toHaveText('hello') + + // Simulate iOS swipe-text behavior with leading space + const result = await page.evaluate(async () => { + const editor = document.querySelector('[role="textbox"]') as HTMLElement + + // Step 1: Simulate swipe-typing " world" (with leading space) after "hello" + // iOS automatically adds a space when swipe-typing after existing text + const insertEvent = new InputEvent('beforeinput', { + inputType: 'insertText', + data: ' world', // Note the leading space! + bubbles: true, + cancelable: true + }) + + const sel = window.getSelection() + if (sel && sel.rangeCount > 0) { + const range = sel.getRangeAt(0) + ;(insertEvent as any).getTargetRanges = () => [ + { + startContainer: range.startContainer, + startOffset: range.startOffset, + endContainer: range.endContainer, + endOffset: range.endOffset, + collapsed: true + } + ] + } else { + ;(insertEvent as any).getTargetRanges = () => [] + } + + editor.dispatchEvent(insertEvent) + + // Wait for React to process the insert + await new Promise((r) => setTimeout(r, 50)) + + const afterInsert = editor.textContent?.replace(/\u200B/g, '') + + // Step 2: Simulate pressing backspace + const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT) + let textNode: Text | null = null + while (walker.nextNode()) { + const node = walker.currentNode as Text + if (node.textContent && node.textContent.length > 0) { + textNode = node + break + } + } + + if (!textNode) { + return { error: 'No text node found after insert', afterInsert } + } + + const deleteEvent = new InputEvent('beforeinput', { + inputType: 'deleteContentBackward', + bubbles: true, + cancelable: true + }) + + // iOS only provides targetRange for the last character + const textLen = textNode.textContent?.length || 0 + ;(deleteEvent as any).getTargetRanges = () => [ + { + startContainer: textNode, + startOffset: textLen - 1, + endContainer: textNode, + endOffset: textLen, + collapsed: false + } + ] + + editor.dispatchEvent(deleteEvent) + + // Wait for React to process the delete + await new Promise((r) => setTimeout(r, 50)) + + return { + afterInsert, + finalText: editor.textContent?.replace(/\u200B/g, ''), + // Check if trailing space is preserved + hasTrailingSpace: editor.textContent + ?.replace(/\u200B/g, '') + .endsWith(' ') + } + }) + + console.log('Result:', JSON.stringify(result, null, 2)) + + // Verify the insert worked (including the space) + expect(result.afterInsert).toBe('hello world') + + // EXPECTED: "world" deleted but space preserved -> "hello " + // ACTUAL BUG: Both space and word deleted -> "hello" + expect(result.finalText).toBe('hello ') + expect(result.hasTrailingSpace).toBe(true) + }) +}) + +/** + * iOS Safari Text Suggestion Bug + * + * THE BUG (from real iOS device testing): + * When user types "hel" and taps a keyboard suggestion "hello": + * 1. iOS fires insertReplacementText with data=null but dataTransfer="hello" + * 2. Our code checks `if (!data) return` and bails out + * 3. iOS then fires insertText with data=" " to insert a space + * 4. Result: "hel" becomes " " instead of "hello" + * + * EXPECTED: "hel" → "hello" (the suggested word) + * ACTUAL BUG: "hel" → " " (just a space) + * + * THE FIX: Check event.dataTransfer.getData('text/plain') as fallback when data is null + */ +test.describe('iOS Safari text suggestion', () => { + // Skip on mobile-safari: cannot emulate iOS native IME behavior + test.beforeEach(async ({}, testInfo) => { + test.skip( + testInfo.project.name === 'mobile-safari', + 'Skipped on mobile-safari: cannot emulate iOS native IME behavior' + ) + }) + + /** + * iOS Safari sends insertReplacementText with the replacement text in + * dataTransfer instead of data (unlike Android GBoard which uses data). + * + * Note: This test is skipped on webkit/mobile-safari because the test environment + * doesn't support passing DataTransfer to InputEvent constructor. The fix has been + * verified on real iOS Safari devices via console logging. + */ + test('tapping a keyboard suggestion should replace in-progress word', async ({ + mount, + page, + browserName + }) => { + // Skip webkit in test environment - DataTransfer constructor doesn't work the same way + // Real iOS Safari behavior verified via console logging on actual device + test.skip( + browserName === 'webkit', + 'webkit test env does not support DataTransfer in InputEvent constructor' + ) + + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + + // Step 1: Type "hel" (simulating user typing before suggestion) + await page.keyboard.type('hel') + await expect(ed).toHaveText('hel') + + // Step 2: Simulate iOS Safari text suggestion tap + // iOS sends insertReplacementText with data=null and replacement in dataTransfer + const result = await page.evaluate(async () => { + const editor = document.querySelector('[role="textbox"]') as HTMLElement + + // Find the text node containing "hel" + const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT) + let textNode: Text | null = null + while (walker.nextNode()) { + const node = walker.currentNode as Text + if (node.textContent && node.textContent.includes('hel')) { + textNode = node + break + } + } + + if (!textNode) { + return { + error: 'No text node found', + editorContent: editor.textContent + } + } + + // Create a mock DataTransfer with the suggestion text + const dataTransfer = new DataTransfer() + dataTransfer.setData('text/plain', 'hello') + + // Create the insertReplacementText event (iOS Safari style) + const replaceEvent = new InputEvent('beforeinput', { + inputType: 'insertReplacementText', + data: null, // iOS Safari puts null here! + dataTransfer: dataTransfer, + bubbles: true, + cancelable: true + }) + + // Set up getTargetRanges to return the range to replace ("hel" at 0-3) + const textContent = textNode.textContent || '' + const helStart = textContent.indexOf('hel') + ;(replaceEvent as any).getTargetRanges = () => [ + { + startContainer: textNode, + startOffset: helStart >= 0 ? helStart : 0, + endContainer: textNode, + endOffset: helStart >= 0 ? helStart + 3 : 3, + collapsed: false + } + ] + + editor.dispatchEvent(replaceEvent) + + // Wait for React to process + await new Promise((r) => setTimeout(r, 50)) + + return { + finalText: editor.textContent?.replace(/\u200B/g, ''), + wasReplaced: editor.textContent?.includes('hello') + } + }) + + console.log('iOS text suggestion result:', JSON.stringify(result, null, 2)) + + // EXPECTED: "hel" should be replaced with "hello" + // ACTUAL BUG: "hel" remains unchanged because we bail out when data is null + expect(result.error).toBeUndefined() + expect(result.finalText).toBe('hello') + expect(result.wasReplaced).toBe(true) + }) + + /** + * Verify that Android-style insertReplacementText (with data in event.data) + * still works after the iOS fix. + */ + test('Android GBoard style suggestion should still work', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + + // Type "hel" + await page.keyboard.type('hel') + await expect(ed).toHaveText('hel') + + // Simulate Android GBoard style - data is in event.data (not dataTransfer) + const result = await page.evaluate(async () => { + const editor = document.querySelector('[role="textbox"]') as HTMLElement + + const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT) + let textNode: Text | null = null + while (walker.nextNode()) { + const node = walker.currentNode as Text + if (node.textContent && node.textContent.includes('hel')) { + textNode = node + break + } + } + + if (!textNode) { + return { error: 'No text node found' } + } + + // Android style: data is in event.data + const replaceEvent = new InputEvent('beforeinput', { + inputType: 'insertReplacementText', + data: 'hello', // Android puts the replacement here + bubbles: true, + cancelable: true + }) + + const textContent = textNode.textContent || '' + const helStart = textContent.indexOf('hel') + ;(replaceEvent as any).getTargetRanges = () => [ + { + startContainer: textNode, + startOffset: helStart >= 0 ? helStart : 0, + endContainer: textNode, + endOffset: helStart >= 0 ? helStart + 3 : 3, + collapsed: false + } + ] + + editor.dispatchEvent(replaceEvent) + + await new Promise((r) => setTimeout(r, 50)) + + return { + finalText: editor.textContent?.replace(/\u200B/g, '') + } + }) + + expect(result.error).toBeUndefined() + expect(result.finalText).toBe('hello') + }) +}) + +/** + * iOS Swipe-Text Trailing Space Bug + * + * THE BUG (from real iOS device testing): + * When user swipe-types a word, iOS sends: + * 1. insertText with the swiped word (e.g., "hello") - multi-char, we track it + * 2. insertText with a single space " " - this CLEARS our tracking! + * 3. User presses backspace - tracking is null, so we only delete one char + * + * EXPECTED: Backspace after swipe+space should delete the swiped word + * ACTUAL BUG: Only deletes the trailing space because tracking was cleared + * + * THE FIX: When a single space follows a multi-char insert within a short + * time window, extend the tracking to include the space instead of clearing it. + */ +test.describe('iOS swipe-text trailing space', () => { + // Skip on mobile-safari: cannot emulate iOS native IME behavior + test.beforeEach(async ({}, testInfo) => { + test.skip( + testInfo.project.name === 'mobile-safari', + 'Skipped on mobile-safari: cannot emulate iOS native IME behavior' + ) + }) + + /** + * iOS sends a trailing space after swipe-typing, which clears our tracking. + * Backspace should still delete the whole swiped word. + */ + test('backspace after swipe + trailing space should delete swiped word', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + + const result = await page.evaluate(async () => { + const editor = document.querySelector('[role="textbox"]') as HTMLElement + + // Step 1: Simulate swipe-typing "hello" (multi-char insert) + const swipeEvent = new InputEvent('beforeinput', { + inputType: 'insertText', + data: 'hello', + bubbles: true, + cancelable: true + }) + + const sel = window.getSelection() + if (sel && sel.rangeCount > 0) { + const range = sel.getRangeAt(0) + ;(swipeEvent as any).getTargetRanges = () => [ + { + startContainer: range.startContainer, + startOffset: range.startOffset, + endContainer: range.endContainer, + endOffset: range.endOffset, + collapsed: true + } + ] + } else { + ;(swipeEvent as any).getTargetRanges = () => [] + } + + editor.dispatchEvent(swipeEvent) + await new Promise((r) => setTimeout(r, 30)) + + const afterSwipe = editor.textContent?.replace(/\u200B/g, '') + + // Step 2: iOS sends a trailing space as a SEPARATE single-char event + // This is the bug trigger - it clears our multi-char tracking! + const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT) + let textNode: Text | null = null + while (walker.nextNode()) { + const node = walker.currentNode as Text + if (node.textContent && node.textContent.length > 0) { + textNode = node + break + } + } + + if (!textNode) { + return { error: 'No text node found after swipe', afterSwipe } + } + + const spaceEvent = new InputEvent('beforeinput', { + inputType: 'insertText', + data: ' ', // Single space - this triggers the bug! + bubbles: true, + cancelable: true + }) + + const textLen = textNode.textContent?.length || 0 + ;(spaceEvent as any).getTargetRanges = () => [ + { + startContainer: textNode, + startOffset: textLen, + endContainer: textNode, + endOffset: textLen, + collapsed: true + } + ] + + editor.dispatchEvent(spaceEvent) + await new Promise((r) => setTimeout(r, 30)) + + const afterSpace = editor.textContent?.replace(/\u200B/g, '') + + // Step 3: Press backspace - should delete the whole swiped word + // Find the text node again after React re-render + const walker2 = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT) + let textNode2: Text | null = null + while (walker2.nextNode()) { + const node = walker2.currentNode as Text + if (node.textContent && node.textContent.length > 0) { + textNode2 = node + break + } + } + + if (!textNode2) { + return { + error: 'No text node found after space', + afterSwipe, + afterSpace + } + } + + const deleteEvent = new InputEvent('beforeinput', { + inputType: 'deleteContentBackward', + bubbles: true, + cancelable: true + }) + + const textLen2 = textNode2.textContent?.length || 0 + ;(deleteEvent as any).getTargetRanges = () => [ + { + startContainer: textNode2, + startOffset: textLen2 - 1, + endContainer: textNode2, + endOffset: textLen2, + collapsed: false + } + ] + + editor.dispatchEvent(deleteEvent) + await new Promise((r) => setTimeout(r, 50)) + + return { + afterSwipe, + afterSpace, + finalText: editor.textContent?.replace(/\u200B/g, ''), + // Check what was deleted + deletedCorrectly: + editor.textContent?.replace(/\u200B/g, '') === '' || + editor.textContent?.replace(/\u200B/g, '') === ' ' + } + }) + + console.log('Trailing space test result:', JSON.stringify(result, null, 2)) + + // Verify setup worked + expect(result.error).toBeUndefined() + expect(result.afterSwipe).toBe('hello') + expect(result.afterSpace).toBe('hello ') + + // EXPECTED: Entire swiped word deleted (leaving empty or just the space) + // ACTUAL BUG: Only one char deleted because tracking was cleared by space + // We expect the result to be empty string (whole word + space deleted) + // or just a space (word deleted, space preserved) + expect(result.finalText).toMatch(/^[ ]?$/) // Empty or single space + }) + + /** + * Multiple swipes in sequence - each swipe's trailing space should not + * break backspace behavior for the most recent swipe. + */ + test('backspace after multiple swipes should delete most recent word', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + + const result = await page.evaluate(async () => { + const editor = document.querySelector('[role="textbox"]') as HTMLElement + + const dispatchInsert = async (text: string) => { + const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT) + let textNode: Text | null = null + let textLen = 0 + while (walker.nextNode()) { + const node = walker.currentNode as Text + if (node.textContent) { + textNode = node + textLen = node.textContent.length + } + } + + const event = new InputEvent('beforeinput', { + inputType: 'insertText', + data: text, + bubbles: true, + cancelable: true + }) + + if (textNode) { + ;(event as any).getTargetRanges = () => [ + { + startContainer: textNode, + startOffset: textLen, + endContainer: textNode, + endOffset: textLen, + collapsed: true + } + ] + } else { + const sel = window.getSelection() + if (sel && sel.rangeCount > 0) { + const range = sel.getRangeAt(0) + ;(event as any).getTargetRanges = () => [ + { + startContainer: range.startContainer, + startOffset: range.startOffset, + endContainer: range.endContainer, + endOffset: range.endOffset, + collapsed: true + } + ] + } else { + ;(event as any).getTargetRanges = () => [] + } + } + + editor.dispatchEvent(event) + await new Promise((r) => setTimeout(r, 30)) + } + + // Swipe "hello" + trailing space + await dispatchInsert('hello') + await dispatchInsert(' ') + + // Swipe "world" + trailing space + await dispatchInsert('world') + await dispatchInsert(' ') + + const beforeDelete = editor.textContent?.replace(/\u200B/g, '') + + // Press backspace + const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT) + let textNode: Text | null = null + while (walker.nextNode()) { + const node = walker.currentNode as Text + if (node.textContent && node.textContent.length > 0) { + textNode = node + break + } + } + + if (!textNode) { + return { error: 'No text node', beforeDelete } + } + + const deleteEvent = new InputEvent('beforeinput', { + inputType: 'deleteContentBackward', + bubbles: true, + cancelable: true + }) + + const textLen = textNode.textContent?.length || 0 + ;(deleteEvent as any).getTargetRanges = () => [ + { + startContainer: textNode, + startOffset: textLen - 1, + endContainer: textNode, + endOffset: textLen, + collapsed: false + } + ] + + editor.dispatchEvent(deleteEvent) + await new Promise((r) => setTimeout(r, 50)) + + return { + beforeDelete, + finalText: editor.textContent?.replace(/\u200B/g, '') + } + }) + + console.log('Multiple swipes test result:', JSON.stringify(result, null, 2)) + + expect(result.error).toBeUndefined() + expect(result.beforeDelete).toBe('hello world ') + + // EXPECTED: "world " deleted, leaving "hello " or "hello" + // ACTUAL BUG: Only one char deleted, leaving "hello world" + expect(result.finalText).toMatch(/^hello ?$/) + }) +}) + +/** + * iOS Swipe-Text Double Space Prevention + * + * THE BUG (from real iOS device testing): + * When user swipes "hello", iOS auto-adds a trailing space → "hello " + * When user then swipes "world", iOS sends " world" (with leading space) + * Result: "hello world" with DOUBLE SPACE + * + * EXPECTED: "hello world" (single space between words) + * ACTUAL BUG: "hello world" (double space) + * + * THE FIX: When inserting a multi-char string that starts with a space, + * check if the character before is already a space. If so, strip the + * leading space from the insert to avoid double-spacing. + */ +test.describe('iOS swipe-text double space prevention', () => { + // Skip on mobile-safari: cannot emulate iOS native IME behavior + test.beforeEach(async ({}, testInfo) => { + test.skip( + testInfo.project.name === 'mobile-safari', + 'Skipped on mobile-safari: cannot emulate iOS native IME behavior' + ) + }) + + /** + * When swiping " world" after "hello " (which already has trailing space), + * the leading space should be stripped to avoid double-spacing. + */ + test('swipe after trailing space should not create double space', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + + const result = await page.evaluate(async () => { + const editor = document.querySelector('[role="textbox"]') as HTMLElement + + const dispatchInsert = async (text: string) => { + const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT) + let textNode: Text | null = null + let textLen = 0 + while (walker.nextNode()) { + const node = walker.currentNode as Text + if (node.textContent) { + textNode = node + textLen = node.textContent.length + } + } + + const event = new InputEvent('beforeinput', { + inputType: 'insertText', + data: text, + bubbles: true, + cancelable: true + }) + + if (textNode) { + ;(event as any).getTargetRanges = () => [ + { + startContainer: textNode, + startOffset: textLen, + endContainer: textNode, + endOffset: textLen, + collapsed: true + } + ] + } else { + const sel = window.getSelection() + if (sel && sel.rangeCount > 0) { + const range = sel.getRangeAt(0) + ;(event as any).getTargetRanges = () => [ + { + startContainer: range.startContainer, + startOffset: range.startOffset, + endContainer: range.endContainer, + endOffset: range.endOffset, + collapsed: true + } + ] + } else { + ;(event as any).getTargetRanges = () => [] + } + } + + editor.dispatchEvent(event) + await new Promise((r) => setTimeout(r, 30)) + } + + // Step 1: Swipe "hello" + await dispatchInsert('hello') + const afterHello = editor.textContent?.replace(/\u200B/g, '') + + // Step 2: iOS adds trailing space + await dispatchInsert(' ') + const afterSpace = editor.textContent?.replace(/\u200B/g, '') + + // Step 3: Swipe " world" (iOS sends with leading space) + await dispatchInsert(' world') + const afterWorld = editor.textContent?.replace(/\u200B/g, '') + + // Count spaces between hello and world + const match = afterWorld?.match(/hello( +)world/) + const spaceCount = match ? match[1].length : 0 + + return { + afterHello, + afterSpace, + afterWorld, + spaceCount, + hasDoubleSpace: afterWorld?.includes(' ') + } + }) + + console.log('Double space test result:', JSON.stringify(result, null, 2)) + + expect(result.afterHello).toBe('hello') + expect(result.afterSpace).toBe('hello ') + + // EXPECTED: "hello world" (single space) + // ACTUAL BUG: "hello world" (double space) + expect(result.afterWorld).toBe('hello world') + expect(result.spaceCount).toBe(1) + expect(result.hasDoubleSpace).toBe(false) + }) +}) + +/** + * iOS swipe after newline tests + * + * When swiping on a new line (after Enter), iOS often adds a leading space. + * This space should be stripped since it's at the start of a line. + */ +test.describe('iOS swipe-text after newline', () => { + // Skip on mobile-safari: cannot emulate iOS native IME behavior + test.beforeEach(async ({}, testInfo) => { + test.skip( + testInfo.project.name === 'mobile-safari', + 'Skipped on mobile-safari: cannot emulate iOS native IME behavior' + ) + }) + + /** + * When swiping "world" after "hello\n", the leading space should be stripped. + * iOS sends swipe data with leading space even at start of line. + */ + test('swipe after newline should not have leading space', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + + const result = await page.evaluate(async () => { + const editor = document.querySelector('[role="textbox"]') as HTMLElement + + // Helper to dispatch beforeinput and let it be handled + const dispatchBeforeInput = async ( + inputType: string, + data: string | null + ) => { + const event = new InputEvent('beforeinput', { + inputType, + data, + bubbles: true, + cancelable: true + }) + + const sel = window.getSelection() + if (sel && sel.rangeCount > 0) { + const range = sel.getRangeAt(0) + ;(event as any).getTargetRanges = () => [ + { + startContainer: range.startContainer, + startOffset: range.startOffset, + endContainer: range.endContainer, + endOffset: range.endOffset, + collapsed: range.collapsed + } + ] + } else { + ;(event as any).getTargetRanges = () => [] + } + + editor.dispatchEvent(event) + await new Promise((r) => setTimeout(r, 50)) + } + + // Step 1: Type "hello" (simulating char-by-char typing via swipe for simplicity) + await dispatchBeforeInput('insertText', 'hello') + + // Step 2: Press Enter (simulated via keydown) + const enterEvent = new KeyboardEvent('keydown', { + key: 'Enter', + bubbles: true, + cancelable: true + }) + editor.dispatchEvent(enterEvent) + await new Promise((r) => setTimeout(r, 50)) + + const afterEnter = editor.innerText?.replace(/\u200B/g, '').trim() + + // Step 3: Swipe " world" on the new line (iOS sends with leading space) + await dispatchBeforeInput('insertText', ' world') + + const finalValue = editor.innerText?.replace(/\u200B/g, '') + + return { + afterEnter, + finalValue, + startsWithSpaceOnLine2: finalValue?.split('\n')[1]?.startsWith(' ') + } + }) + + console.log('Swipe after newline result:', JSON.stringify(result, null, 2)) + + // The second line should NOT start with a space + expect(result.startsWithSpaceOnLine2).toBe(false) + // Final value should be "hello\nworld" not "hello\n world" + expect(result.finalValue).toMatch(/hello\n\s*world/) + expect(result.finalValue).not.toContain('\n ') + }) + + /** + * iOS sometimes sends a single space as a separate event BEFORE the swiped word. + * This space should be skipped entirely at the start of a line. + */ + test('single space before swipe word at start of line should be skipped', async ({ + mount, + page + }) => { + await mount( + + {null} + + ) + + const ed = page.getByRole('textbox') + await ed.click() + + const result = await page.evaluate(async () => { + const editor = document.querySelector('[role="textbox"]') as HTMLElement + + const dispatchBeforeInput = async ( + inputType: string, + data: string | null + ) => { + const event = new InputEvent('beforeinput', { + inputType, + data, + bubbles: true, + cancelable: true + }) + + const sel = window.getSelection() + if (sel && sel.rangeCount > 0) { + const range = sel.getRangeAt(0) + ;(event as any).getTargetRanges = () => [ + { + startContainer: range.startContainer, + startOffset: range.startOffset, + endContainer: range.endContainer, + endOffset: range.endOffset, + collapsed: range.collapsed + } + ] + } else { + ;(event as any).getTargetRanges = () => [] + } + + editor.dispatchEvent(event) + await new Promise((r) => setTimeout(r, 50)) + } + + // Step 1: Insert "hello" + await dispatchBeforeInput('insertText', 'hello') + + // Step 2: Press Enter + const enterEvent = new KeyboardEvent('keydown', { + key: 'Enter', + bubbles: true, + cancelable: true + }) + editor.dispatchEvent(enterEvent) + await new Promise((r) => setTimeout(r, 50)) + + // Step 3: iOS sends JUST a space first (separate event before the word) + await dispatchBeforeInput('insertText', ' ') + const afterSpace = editor.innerText?.replace(/\u200B/g, '') + + // Step 4: iOS sends the actual word + await dispatchBeforeInput('insertText', 'world') + const finalValue = editor.innerText?.replace(/\u200B/g, '') + + return { + afterSpace, + finalValue, + line2: finalValue?.split('\n')[1] + } + }) + + console.log( + 'Single space before swipe result:', + JSON.stringify(result, null, 2) + ) + + // The space should have been skipped, so line 2 should be "world" not " world" + expect(result.line2).toBe('world') + expect(result.finalValue).not.toContain('\n ') + }) +}) diff --git a/src/inlay/__ct__/inlay.mobile.spec.tsx b/src/inlay/__ct__/inlay.mobile.spec.tsx new file mode 100644 index 0000000..b6ec115 --- /dev/null +++ b/src/inlay/__ct__/inlay.mobile.spec.tsx @@ -0,0 +1,155 @@ +import { test, expect } from '@playwright/experimental-ct-react' +import { MobileInlay, SimpleMobileInlay } from './fixtures/mobile-inlay' + +// These tests run on mobile projects defined in playwright-ct.config.mts +// Run with: bun run test:ct -- --project=mobile-chrome +// or: bun run test:ct -- --project=mobile-safari + +test.describe('Mobile touch interaction', () => { + test('tap positions caret in editor', async ({ mount, page }) => { + await mount() + + const editor = page.getByRole('textbox') + await expect(editor).toHaveCount(1) + + // Click to focus (tap in mobile projects) + await editor.click() + await expect(editor).toBeFocused() + }) + + test('editor has correct mobile attributes', async ({ mount, page }) => { + await mount() + + const editor = page.getByRole('textbox') + await expect(editor).toHaveAttribute('inputmode', 'text') + await expect(editor).toHaveAttribute('autocapitalize', 'sentences') + // Note: autoCorrect is intentionally omitted (undefined) to let iOS use native behavior + // for keyboard suggestions. When not set, iOS defaults to system settings. + await expect(editor).toHaveAttribute('spellcheck', 'false') + }) + + test('editor has touch-action manipulation style', async ({ + mount, + page + }) => { + await mount() + + const editor = page.getByRole('textbox') + const touchAction = await editor.evaluate( + (el) => window.getComputedStyle(el).touchAction + ) + expect(touchAction).toBe('manipulation') + }) + + test('typing works after focus', async ({ mount, page }) => { + await mount() + + const editor = page.getByRole('textbox') + await editor.click() + + // Type one character at a time and verify it appears + await page.keyboard.type('a') + const rawValue = await page.getByTestId('raw-value').textContent() + expect(rawValue).toContain('a') + }) + + test('text editing works', async ({ mount, page }) => { + await mount() + + const editor = page.getByRole('textbox') + await editor.click() + await expect(editor).toBeFocused() + + // Type at caret position + await page.keyboard.type('!') + // Value should have changed + const rawValue = await page.getByTestId('raw-value').textContent() + expect(rawValue).toContain('!') + }) +}) + +test.describe('Portal interaction', () => { + test('portal items respond to click/tap', async ({ mount, page }) => { + await mount() + + const editor = page.getByRole('textbox') + await editor.click() + + // Portal should be visible when there's a mention token + const portal = page.getByTestId('portal') + await expect(portal).toBeVisible() + + // Click on a portal item + const item = page.getByTestId('item-1') + await expect(item).toBeVisible() + await item.click() + + // Should have selected the item + await expect(page.getByTestId('selected')).toHaveText('Apple') + }) + + test('portal item selection works', async ({ mount, page }) => { + await mount() + + const editor = page.getByRole('textbox') + await editor.click() + + const portal = page.getByTestId('portal') + await expect(portal).toBeVisible() + + // Click item 2 + const item2 = page.getByTestId('item-2') + await item2.click() + + // Should have selected item 2 + await expect(page.getByTestId('selected')).toHaveText('Banana') + }) + + test('portal has touch-action manipulation style', async ({ + mount, + page + }) => { + await mount() + + const editor = page.getByRole('textbox') + await editor.click() + + const portal = page.getByTestId('portal-list') + await expect(portal).toBeVisible() + + const touchAction = await portal.evaluate( + (el) => window.getComputedStyle(el).touchAction + ) + expect(touchAction).toBe('manipulation') + }) +}) + +test.describe('Token interaction', () => { + test('interacting near token works correctly', async ({ mount, page }) => { + await mount() + + const editor = page.getByRole('textbox') + await editor.click() + + // Should be able to type + await page.keyboard.type('!') + const rawValue = await page.getByTestId('raw-value').textContent() + expect(rawValue).toContain('!') + }) + + test('backspace near token works', async ({ mount, page }) => { + await mount() + + const editor = page.getByRole('textbox') + await editor.click() + + // Move to end + for (let i = 0; i < 10; i++) await page.keyboard.press('ArrowRight') + + // Backspace should delete token characters + await page.keyboard.press('Backspace') + const rawValue = await page.getByTestId('raw-value').textContent() + // Should have deleted something + expect(rawValue?.length).toBeLessThan('a@user'.length) + }) +}) diff --git a/src/inlay/__ct__/inlay.overlap.spec.tsx b/src/inlay/__ct__/inlay.overlap.spec.tsx new file mode 100644 index 0000000..4037fba --- /dev/null +++ b/src/inlay/__ct__/inlay.overlap.spec.tsx @@ -0,0 +1,134 @@ +import { test, expect } from '@playwright/experimental-ct-react' +import { OverlappingPluginsInlay } from './fixtures/overlapping-plugins-inlay' + +test.describe('Plugin overlap resolution (CT)', () => { + test.describe('Exact same substring - two plugins match @test at identical range', () => { + test('only one token renders and first plugin wins (diverged output confirms which)', async ({ + mount, + page + }) => { + // Input: "hello @test world" + // Plugin A matches @test → renders "[A:test]" + // Plugin B matches @test → renders "[B:test]" + // Both match the EXACT same range. Only one should render. + // With proper overlap resolution, first plugin (A) should win. + await mount( + + ) + + const ed = page.getByRole('textbox') + await expect(ed).toBeVisible() + + // Should have exactly one token, not two + const allTokens = ed.locator('[data-plugin]') + await expect(allTokens).toHaveCount(1) + + // The visual output should be from Plugin A (first in array) + // This confirms which plugin "won" via diverged rendering + await expect(ed).toContainText('[A:test]') + await expect(ed).not.toContainText('[B:test]') + + // Confirm it's Plugin A's token + await expect(allTokens.first()).toHaveAttribute('data-plugin', 'pluginA') + }) + }) + + test.describe('Longer match wins - @alice vs @alice_vip at same start', () => { + test('longer @alice_vip token wins over shorter @alice match', async ({ + mount, + page + }) => { + // Input: "hello @alice_vip world" + // Short plugin matches "@alice" at position 6-12 + // Long plugin matches "@alice_vip" at position 6-16 + // Both start at position 6, but Long is 10 chars vs Short's 6 chars + // Longest-match-wins: Long plugin should win + await mount( + + ) + + const ed = page.getByRole('textbox') + await expect(ed).toBeVisible() + + // Should have exactly one token (the longer match consumed the range) + const allTokens = ed.locator('[data-plugin]') + await expect(allTokens).toHaveCount(1) + + // The visual should be from Long plugin, not Short + await expect(ed).toContainText('[long:alice_vip]') + await expect(ed).not.toContainText('[short:alice]') + + // Confirm it's Long plugin's token + const token = allTokens.first() + await expect(token).toHaveAttribute('data-plugin', 'long') + await expect(token).toHaveAttribute('data-token-raw', '@alice_vip') + }) + + test('short match still works when long pattern does not apply', async ({ + mount, + page + }) => { + // Input: "hello @alice world" (no _vip suffix) + // Short plugin matches "@alice" at position 6-12 + // Long plugin does NOT match (no @alice_vip in text) + // Short plugin should render since there's no competition + await mount( + + ) + + const ed = page.getByRole('textbox') + await expect(ed).toBeVisible() + + // Should have exactly one token from Short plugin + const allTokens = ed.locator('[data-plugin]') + await expect(allTokens).toHaveCount(1) + + await expect(ed).toContainText('[short:alice]') + await expect(allTokens.first()).toHaveAttribute('data-plugin', 'short') + }) + }) + + test.describe('Non-overlapping tokens preserved', () => { + test('multiple non-overlapping tokens from different plugins are all rendered', async ({ + mount, + page + }) => { + await mount( + + ) + + const ed = page.getByRole('textbox') + await expect(ed).toBeVisible() + + // Should have 4 tokens total (2 mentions, 2 hashtags) + const allTokens = ed.locator('[data-plugin]') + await expect(allTokens).toHaveCount(4) + + // Two mentions + const mentionTokens = ed.locator('[data-plugin="mention"]') + await expect(mentionTokens).toHaveCount(2) + + // Two hashtags + const hashtagTokens = ed.locator('[data-plugin="hashtag"]') + await expect(hashtagTokens).toHaveCount(2) + + // Verify specific values exist + await expect(ed.locator('[data-token-raw="@alice"]')).toHaveCount(1) + await expect(ed.locator('[data-token-raw="@bob"]')).toHaveCount(1) + await expect(ed.locator('[data-token-raw="#react"]')).toHaveCount(1) + await expect(ed.locator('[data-token-raw="#vue"]')).toHaveCount(1) + }) + }) +}) diff --git a/src/inlay/__ct__/inlay.portal-navigation.spec.tsx b/src/inlay/__ct__/inlay.portal-navigation.spec.tsx new file mode 100644 index 0000000..a80dc98 --- /dev/null +++ b/src/inlay/__ct__/inlay.portal-navigation.spec.tsx @@ -0,0 +1,157 @@ +import { test, expect } from '@playwright/experimental-ct-react' +import type { Page } from '@playwright/test' +import type { MountResult } from '@playwright/experimental-ct-react' +import { PortalNavigationInlay } from './fixtures/portal-navigation-inlay' + +test.describe('Portal keyboard navigation (CT)', () => { + // Helper to set up the test with portal open + async function setupPortal( + mount: (component: React.ReactElement) => Promise, + page: Page + ) { + await mount() + + const editor = page.getByTestId('editor') + await expect(editor).toBeVisible() + + // Wait for token to be rendered - scope to editor to avoid hidden first-pass + const tokenRender = editor.getByTestId('token-render') + await expect(tokenRender).toBeVisible() + + // Click on the token to activate portal + await tokenRender.click() + + // Wait for portal to appear + const portal = page.getByTestId('portal') + await expect(portal).toBeVisible() + + return { editor, portal } + } + + test('ArrowDown navigates to next item', async ({ mount, page }) => { + await setupPortal(mount, page) + + // First item should be active by default + const item1 = page.getByTestId('item-1') + const item2 = page.getByTestId('item-2') + + await expect(item1).toHaveAttribute('data-active') + await expect(item2).not.toHaveAttribute('data-active') + + // Press ArrowDown + await page.keyboard.press('ArrowDown') + + await expect(item1).not.toHaveAttribute('data-active') + await expect(item2).toHaveAttribute('data-active') + }) + + test('ArrowUp navigates to previous item', async ({ mount, page }) => { + await setupPortal(mount, page) + + // Navigate down first + await page.keyboard.press('ArrowDown') + await page.keyboard.press('ArrowDown') + + const item3 = page.getByTestId('item-3') + await expect(item3).toHaveAttribute('data-active') + + // Press ArrowUp + await page.keyboard.press('ArrowUp') + + const item2 = page.getByTestId('item-2') + await expect(item2).toHaveAttribute('data-active') + }) + + test('ArrowDown wraps from last to first item', async ({ mount, page }) => { + await setupPortal(mount, page) + + // Navigate to last item + await page.keyboard.press('ArrowDown') + await page.keyboard.press('ArrowDown') + + const item3 = page.getByTestId('item-3') + await expect(item3).toHaveAttribute('data-active') + + // Press ArrowDown again - should wrap to first + await page.keyboard.press('ArrowDown') + + const item1 = page.getByTestId('item-1') + await expect(item1).toHaveAttribute('data-active') + }) + + test('ArrowUp wraps from first to last item', async ({ mount, page }) => { + await setupPortal(mount, page) + + // First item is active by default + const item1 = page.getByTestId('item-1') + await expect(item1).toHaveAttribute('data-active') + + // Press ArrowUp - should wrap to last + await page.keyboard.press('ArrowUp') + + const item3 = page.getByTestId('item-3') + await expect(item3).toHaveAttribute('data-active') + }) + + test('Enter selects the active item', async ({ mount, page }) => { + await setupPortal(mount, page) + + // Navigate to second item + await page.keyboard.press('ArrowDown') + + // Press Enter to select + await page.keyboard.press('Enter') + + // Check that the item was selected + const selected = page.getByTestId('selected') + await expect(selected).toHaveText('Banana') + + // Check that the raw value was updated + const rawValue = page.getByTestId('raw-value') + await expect(rawValue).toHaveText('hello @2 ') + }) + + test('Mouse hover changes active item', async ({ mount, page }) => { + await setupPortal(mount, page) + + const item1 = page.getByTestId('item-1') + const item3 = page.getByTestId('item-3') + + // First item is active by default + await expect(item1).toHaveAttribute('data-active') + + // Hover over third item + await item3.hover() + + // Third item should now be active + await expect(item3).toHaveAttribute('data-active') + await expect(item1).not.toHaveAttribute('data-active') + }) + + test('Click selects the item', async ({ mount, page }) => { + await setupPortal(mount, page) + + // Click on third item + const item3 = page.getByTestId('item-3') + await item3.click() + + // Check that the item was selected + const selected = page.getByTestId('selected') + await expect(selected).toHaveText('Cherry') + + // Check that the raw value was updated + const rawValue = page.getByTestId('raw-value') + await expect(rawValue).toHaveText('hello @3 ') + }) + + test('Focus stays in editor after navigation', async ({ mount, page }) => { + const { editor } = await setupPortal(mount, page) + + // Navigate with arrow keys + await page.keyboard.press('ArrowDown') + await page.keyboard.press('ArrowDown') + + // Focus should still be in editor + await expect(editor).toBeFocused() + }) +}) diff --git a/src/inlay/__ct__/inlay.structured-actions.spec.tsx b/src/inlay/__ct__/inlay.structured-actions.spec.tsx new file mode 100644 index 0000000..0d2d648 --- /dev/null +++ b/src/inlay/__ct__/inlay.structured-actions.spec.tsx @@ -0,0 +1,126 @@ +import { test, expect } from '@playwright/experimental-ct-react' +import { StructuredActionsInlay } from './fixtures/structured-actions-inlay' + +test.describe('StructuredInlay replace/update actions (CT)', () => { + test('update changes rendered label without changing raw value', async ({ + mount, + page + }) => { + await mount() + + const ed = page.getByTestId('editor') + await expect(ed).toBeVisible() + + // Wait for token to be weaved (scope to editor to avoid hidden first-pass element) + const tokenRender = ed.getByTestId('token-render') + await expect(tokenRender).toBeVisible() + await expect(tokenRender).toHaveText('@alice') + + // Click on the token to activate portal + await tokenRender.click() + + // Wait for portal to appear + const portal = page.getByTestId('portal') + await expect(portal).toBeVisible() + + // Click the update button + await page.getByTestId('btn-update').click() + + // Verify rendered label changed + await expect(tokenRender).toHaveText('UpdatedLabel') + + // Verify raw value is unchanged + const rawValue = page.getByTestId('raw-value') + await expect(rawValue).toHaveText('hello @alice world') + }) + + test('replace changes raw value', async ({ mount, page }) => { + await mount() + + const ed = page.getByTestId('editor') + await expect(ed).toBeVisible() + + // Wait for token to be weaved (scope to editor) + const tokenRender = ed.getByTestId('token-render') + await expect(tokenRender).toBeVisible() + + // Click on the token to activate portal + await tokenRender.click() + + // Wait for portal to appear + const portal = page.getByTestId('portal') + await expect(portal).toBeVisible() + + // Click the replace button (replaces @alice with @replaced) + await page.getByTestId('btn-replace').click() + + // Verify raw value changed + const rawValue = page.getByTestId('raw-value') + await expect(rawValue).toHaveText('hello @replaced world') + + // Verify the new token is rendered + await expect(tokenRender).toHaveText('@replaced') + }) + + test('replace positions caret at end of new text', async ({ + mount, + page + }) => { + await mount() + + const ed = page.getByTestId('editor') + await expect(ed).toBeVisible() + + // Wait for token (scope to editor) + const tokenRender = ed.getByTestId('token-render') + await expect(tokenRender).toBeVisible() + await expect(tokenRender).toHaveText('@a') + + // Click on token to activate portal + await tokenRender.click() + await expect(page.getByTestId('portal')).toBeVisible() + + // Replace @a with @replaced + // The portal has onMouseDown preventDefault, so focus stays in editor + await page.getByTestId('btn-replace').click() + + // Verify the value updated correctly + const rawValue = page.getByTestId('raw-value') + await expect(rawValue).toHaveText('hi @replaced bye') + + // Type to verify caret position (focus should still be in editor) + await page.keyboard.type('X') + + // Caret should be after @replaced + await expect(rawValue).toHaveText('hi @replacedX bye') + }) + + test('replace with longer text updates raw value correctly', async ({ + mount, + page + }) => { + await mount() + + const ed = page.getByTestId('editor') + await expect(ed).toBeVisible() + + // Wait for token (scope to editor) + const tokenRender = ed.getByTestId('token-render') + await expect(tokenRender).toBeVisible() + await expect(tokenRender).toHaveText('@a') + + // Click on token to activate portal + await tokenRender.click() + await expect(page.getByTestId('portal')).toBeVisible() + + // Replace @a with @replaced + await page.getByTestId('btn-replace').click() + + // Verify the value updated correctly + const rawValue = page.getByTestId('raw-value') + await expect(rawValue).toHaveText('hi @replaced bye') + + // Verify the token is re-rendered with new value + await expect(tokenRender).toHaveText('@replaced') + }) +}) diff --git a/src/inlay/__ct__/inlay.tokens.spec.tsx b/src/inlay/__ct__/inlay.tokens.spec.tsx new file mode 100644 index 0000000..3920f3e --- /dev/null +++ b/src/inlay/__ct__/inlay.tokens.spec.tsx @@ -0,0 +1,53 @@ +import { test, expect } from '@playwright/experimental-ct-react' +import { ControlledTokenInlay } from './fixtures/controlled-token-inlay' + +test.describe('Token-aware deletions (CT)', () => { + test('Backspace at end of token deletes last raw char', async ({ + mount, + page + }) => { + await mount() + + const ed = page.getByRole('textbox') + await ed.click() + // Ensure token has been weaved into the DOM and content is present + await expect(ed.locator('[data-token-text="@x"]').first()).toBeVisible() + await expect(ed).toHaveText('A@xB') + // overshoot to the right + for (let i = 0; i < 10; i++) await page.keyboard.press('ArrowRight') + await page.keyboard.press('ArrowLeft') + await page.keyboard.press('Backspace') + await expect(ed).toHaveText('A@B') + await expect(ed.locator('[data-token-text]')).toHaveCount(0) + }) + + test('Range delete across token removes the token completely', async ({ + mount, + page + }) => { + await mount() + + const ed = page.getByRole('textbox') + await ed.click() + // Ensure token has been weaved into the DOM and content is present + const token = ed.locator('[data-token-text="@x"]').first() + await expect(token).toBeVisible() + await expect(ed).toHaveText('A@xB') + const edBox = await ed.boundingBox() + const tokBox = await token.boundingBox() + if (!edBox || !tokBox) throw new Error('no boxes') + const y = edBox.y + edBox.height / 2 + const startX = Math.max(edBox.x + 2, tokBox.x - 4) + const endX = Math.min( + edBox.x + edBox.width - 2, + tokBox.x + tokBox.width + 2 + ) + await page.mouse.move(startX, y) + await page.mouse.down() + await page.mouse.move(endX, y, { steps: 5 }) + await page.mouse.up() + await page.keyboard.press('Backspace') + await expect(ed).toHaveText('AB') + await expect(ed.locator('[data-token-text]')).toHaveCount(0) + }) +}) diff --git a/src/inlay/__tests__/inlay-editor-behavior.test.tsx b/src/inlay/__tests__/inlay-editor-behavior.test.tsx new file mode 100644 index 0000000..870fde1 --- /dev/null +++ b/src/inlay/__tests__/inlay-editor-behavior.test.tsx @@ -0,0 +1,695 @@ +import React from 'react' +import { describe, it, expect } from 'vitest' +import { render, fireEvent, act, waitFor } from '@testing-library/react' +import * as Inlay from '../inlay' +import { getAbsoluteOffset, setDomSelection } from '../internal/dom-utils' + +function flush() { + return new Promise((r) => setTimeout(r, 0)) +} + +// Helper to dispatch native beforeinput event for backspace +// This is needed because our handler uses native event listeners +function fireBackspace(element: HTMLElement) { + const sel = window.getSelection() + // Range is captured for potential future use in getTargetRanges simulation + const _range = sel?.rangeCount ? sel.getRangeAt(0) : null + void _range // Acknowledge intentionally unused + + const event = new InputEvent('beforeinput', { + bubbles: true, + cancelable: true, + inputType: 'deleteContentBackward', + data: null + }) + + // jsdom doesn't support getTargetRanges, so we need to ensure selection is set + element.dispatchEvent(event) +} + +// Helper to dispatch native beforeinput for delete forward +function fireDelete(element: HTMLElement) { + const event = new InputEvent('beforeinput', { + bubbles: true, + cancelable: true, + inputType: 'deleteContentForward', + data: null + }) + element.dispatchEvent(event) +} + +// Empty state rendering +describe('Inlay empty state', () => { + it('renders zero-width space for consistent caret height', async () => { + const { getByTestId } = render( + + + + ) + const ed = getByTestId('editor') as HTMLElement + + await act(async () => { + await flush() + }) + + // Should contain a zero-width space (U+200B) to maintain caret baseline + expect(ed.textContent).toBe('\u200B') + }) +}) + +// Editing basics (Space/Enter) — Backspace covered in its own block below +describe('Inlay editing (Space/Enter)', () => { + it('Space and Enter modify value appropriately', async () => { + function Test() { + const [value, setValue] = React.useState('ab') + return ( + + + + ) + } + + const { getByTestId } = render() + const ed = getByTestId('editor') as HTMLElement + + await act(async () => { + ;(ed as HTMLElement).focus() + setDomSelection(ed as HTMLElement, 2) + await flush() + }) + + await act(async () => { + fireEvent.keyDown(ed, { key: ' ' }) + await flush() + }) + expect((ed as HTMLElement).textContent).toBe('ab ') + + await act(async () => { + fireEvent.keyDown(ed, { key: 'Enter' }) + await flush() + }) + expect((ed as HTMLElement).textContent).toBe('ab \n') + + // Backspace semantics are covered separately + }) +}) + +// Backspace semantics +describe('Inlay Backspace semantics', () => { + it('does nothing at start of content (collapsed at 0)', async () => { + function Test() { + const [value, setValue] = React.useState('ab') + return ( + + + + ) + } + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + await act(async () => { + ed.focus() + setDomSelection(ed, 0) + await flush() + }) + + await act(async () => { + fireBackspace(ed) + await flush() + }) + + expect(ed.textContent).toBe('ab') + const sel = window.getSelection()! + const caret = getAbsoluteOffset(ed, sel.focusNode!, sel.focusOffset) + expect(caret).toBe(0) + }) + + it('deletes previous char when collapsed (not at start)', async () => { + function Test() { + const [value, setValue] = React.useState('ab') + return ( + + + + ) + } + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + await act(async () => { + ed.focus() + setDomSelection(ed, 2) + await flush() + }) + + await act(async () => { + fireBackspace(ed) + await flush() + }) + + expect(ed.textContent).toBe('a') + const sel = window.getSelection()! + const caret = getAbsoluteOffset(ed, sel.focusNode!, sel.focusOffset) + expect(caret).toBe(1) + }) + + it('deletes selected range (within plain text)', async () => { + function Test() { + const [value, setValue] = React.useState('abcd') + return ( + + + + ) + } + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + await act(async () => { + ed.focus() + setDomSelection(ed, 1, 3) // select 'bc' + await flush() + }) + + await act(async () => { + fireBackspace(ed) + await flush() + }) + + expect(ed.textContent).toBe('ad') + const sel = window.getSelection()! + const caret = getAbsoluteOffset(ed, sel.focusNode!, sel.focusOffset) + expect(caret).toBe(1) + }) + + it('range delete across a token removes the token raw span', async () => { + function Test() { + const [value, setValue] = React.useState('A@xB') + return ( + + {/* Register token for '@x' so weaving recognizes it */} + + @x + + + ) + } + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + await waitFor(() => { + expect(ed.querySelector('[data-token-text="@x"]')).toBeTruthy() + }) + + await act(async () => { + ed.focus() + setDomSelection(ed, 1, 3) + await flush() + }) + + await act(async () => { + fireBackspace(ed) + await flush() + }) + + expect(ed.querySelector('[data-token-text]')).toBeFalsy() + }) + + it('collapsed backspace at end of token deletes last raw char of token', async () => { + function Test() { + const [value, setValue] = React.useState('A@xB') + return ( + + + @x + + + {({ value }) => } + + + ) + } + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + await waitFor(() => { + expect(ed.querySelector('[data-token-text]')).toBeTruthy() + }) + + await act(async () => { + ed.focus() + setDomSelection(ed, 3) // right after '@x' + await flush() + }) + + await act(async () => { + fireBackspace(ed) + await flush() + }) + + // Deletes 'x' -> raw becomes 'A@B'; token no longer matches + const ctxVal = getByTestId('ctx-val') + expect(ctxVal.getAttribute('data-value')).toBe('A@B') + expect(ed.querySelector('[data-token-text]')).toBeFalsy() + }) +}) + +// onKeyDown interception +describe('Inlay onKeyDown interception', () => { + it('returns true to prevent built-in handling (Space, Enter)', async () => { + const ref = React.createRef() + + function Test() { + const [value, setValue] = React.useState('ab') + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === ' ' || e.key === 'Enter') return true + return false + } + return ( + + + + ) + } + + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + await act(async () => { + ref.current!.setSelection(2) + await flush() + }) + + await act(async () => { + fireEvent.keyDown(ed, { key: ' ' }) + await flush() + }) + expect(ed.textContent).toBe('ab') + + await act(async () => { + fireEvent.keyDown(ed, { key: 'Enter' }) + await flush() + }) + expect(ed.textContent).toBe('ab') + }) +}) + +// Newline rendering +describe('Inlay trailing newline rendering', () => { + it('adds a when value ends with \n (when at least one token is present)', () => { + function Test({ value }: { value: string }) { + return ( + {}} data-testid="root"> + + X + + + ) + } + + const { getByTestId, rerender } = render() + const editor = getByTestId('root') as HTMLElement + expect(editor.querySelector('br')).toBeTruthy() + + rerender() + expect(editor.querySelector('br')).toBeFalsy() + }) + + it('first Enter on empty editor creates a visible newline (trailing )', async () => { + function Test() { + const [value, setValue] = React.useState('') + return ( + + + + ) + } + + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + await act(async () => { + ed.focus() + fireEvent.keyDown(ed, { key: 'Enter' }) + await flush() + }) + + // Expect a trailing to appear immediately after first Enter + expect(ed.querySelector('br')).toBeTruthy() + }) +}) + +// Grapheme cluster behavior +describe('Inlay Backspace with grapheme clusters', () => { + it('deletes full grapheme cluster (emoji + skin tone) when collapsed', async () => { + function Test() { + const [value, setValue] = React.useState('👍🏼') + return ( + + + + ) + } + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + await act(async () => { + ed.focus() + setDomSelection(ed, '👍🏼'.length) // caret after the cluster + await flush() + }) + + await act(async () => { + fireBackspace(ed) + await flush() + }) + + expect(ed.textContent).toBe('\u200B') + const sel = window.getSelection()! + const caret = getAbsoluteOffset(ed, sel.focusNode!, sel.focusOffset) + expect(caret).toBe(0) + }) + + it('deletes an entire flag grapheme (regional indicators) before caret', async () => { + const flag = '🇺🇸' + function Test() { + const [value, setValue] = React.useState(`a${flag}b`) + return ( + + + + ) + } + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + const value = `a${flag}b` + const posAfterFlag = value.indexOf(flag) + flag.length + + await act(async () => { + ed.focus() + setDomSelection(ed, posAfterFlag) + await flush() + }) + + await act(async () => { + fireBackspace(ed) + await flush() + }) + + expect(ed.textContent).toBe('ab') + }) + + it('deletes composed character with combining mark as a single grapheme', async () => { + const composed = 'e\u0301' // e + combining acute + function Test() { + const [value, setValue] = React.useState(composed) + return ( + + + + ) + } + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + await act(async () => { + ed.focus() + setDomSelection(ed, composed.length) + await flush() + }) + + await act(async () => { + fireBackspace(ed) + await flush() + }) + + expect(ed.textContent).toBe('\u200B') + }) +}) + +describe('Inlay selection deletion is grapheme-aware', () => { + it('Backspace with selection slicing through a grapheme deletes the whole grapheme', async () => { + const cluster = '👍🏼' + const text = `a${cluster}b` + function Test() { + const [value, setValue] = React.useState(text) + return ( + + + + ) + } + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + const start = 1 + 1 // into the grapheme + const end = 1 + cluster.length - 1 // still inside the grapheme + + await act(async () => { + ed.focus() + setDomSelection(ed, start, end) + await flush() + }) + + await act(async () => { + fireBackspace(ed) + await flush() + }) + + // Expect entire grapheme removed, leaving 'ab' + expect(ed.textContent).toBe('ab') + const sel = window.getSelection()! + const caret = getAbsoluteOffset(ed, sel.focusNode!, sel.focusOffset) + expect(caret).toBe(1) + }) + + it('Delete with selection slicing through a grapheme deletes the whole grapheme', async () => { + const cluster = '🇺🇸' + const text = `x${cluster}y` + function Test() { + const [value, setValue] = React.useState(text) + return ( + + + + ) + } + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + const start = 1 // start at grapheme start + const end = 1 + 1 // end in the middle of grapheme + + await act(async () => { + ed.focus() + setDomSelection(ed, start, end) + await flush() + }) + + await act(async () => { + fireDelete(ed) + await flush() + }) + + expect(ed.textContent).toBe('xy') + const sel = window.getSelection()! + const caret = getAbsoluteOffset(ed, sel.focusNode!, sel.focusOffset) + expect(caret).toBe(1) + }) +}) + +// ZWJ grapheme sequences and selection snapping +describe('Inlay grapheme advanced cases', () => { + it('Backspace/Delete remove whole ZWJ grapheme (family emoji)', async () => { + const family = '👨👩👧👦' + function Test() { + const [value, setValue] = React.useState(family) + return ( + + + + ) + } + const { getByTestId, rerender } = render() + const ed = getByTestId('ed') as HTMLElement + + await act(async () => { + ed.focus() + setDomSelection(ed, family.length) + await flush() + }) + await act(async () => { + fireBackspace(ed) + await flush() + }) + expect(ed.textContent).toBe('\u200B') + + // Reset and test Delete + rerender( + {}} data-testid="ed"> + + + ) + await act(async () => { + ed.focus() + setDomSelection(ed, 0) + await flush() + }) + await act(async () => { + fireDelete(ed) + await flush() + }) + expect(ed.textContent).toBe('\u200B') + }) + + it('setSelection snaps to grapheme boundaries', async () => { + const cluster = '👍🏼' + const text = `a${cluster}b` + const ref = React.createRef() + function Test() { + const [value, setValue] = React.useState(text) + return ( + + + {({ selection, getSelectionRange }) => { + const range = getSelectionRange() + let rawStart = -1 + let rawEnd = -1 + const root = ref.current?.root + if (root && range) { + rawStart = getAbsoluteOffset( + root, + range.startContainer, + range.startOffset + ) + rawEnd = getAbsoluteOffset( + root, + range.endContainer, + range.endOffset + ) + } + return ( + + ) + }} + + + + ) + } + const { getByTestId } = render() + + const mid = 1 + Math.floor(cluster.length / 2) + await act(async () => { + ref.current!.setSelection(mid) + await flush() + }) + + const ctx = getByTestId('ctx') + // Expect snapped to start of grapheme (offset 1) + expect(ctx.getAttribute('data-start')).toBe('1') + expect(ctx.getAttribute('data-end')).toBe('1') + expect(ctx.getAttribute('data-raw-start')).toBe('1') + expect(ctx.getAttribute('data-raw-end')).toBe('1') + }) +}) + +// Placeholder +describe('Inlay placeholder', () => { + it('shows placeholder only when value is empty', () => { + const placeholder = 'Type here...' + const { container, rerender } = render( + {}} placeholder={placeholder}> + + + ) + + expect(container.textContent).toContain(placeholder) + + rerender( + {}} placeholder={placeholder}> + + + ) + + expect(container.textContent).not.toContain(placeholder) + }) +}) + +// Multiline behavior +describe('Inlay multiline prop', () => { + it('multiline=false blocks Enter and Shift+Enter on empty editor', async () => { + function Test() { + const [value, setValue] = React.useState('') + return ( + + + + ) + } + + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + await act(async () => { + ed.focus() + fireEvent.keyDown(ed, { key: 'Enter' }) + await flush() + }) + expect(ed.querySelector('br')).toBeFalsy() + expect(ed.textContent).toBe('\u200B') + + await act(async () => { + fireEvent.keyDown(ed, { key: 'Enter', shiftKey: true }) + await flush() + }) + expect(ed.querySelector('br')).toBeFalsy() + expect(ed.textContent).toBe('\u200B') + }) + + it('multiline=false does not render trailing even if value ends with \n', async () => { + function Test() { + const [value, setValue] = React.useState('A\n') + return ( + + + + ) + } + + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + expect(ed.querySelector('br')).toBeFalsy() + }) +}) + +// (IME composition tests removed; to be covered in Playwright later) diff --git a/src/inlay/__tests__/inlay-selection-and-active-token.test.tsx b/src/inlay/__tests__/inlay-selection-and-active-token.test.tsx new file mode 100644 index 0000000..fd100a9 --- /dev/null +++ b/src/inlay/__tests__/inlay-selection-and-active-token.test.tsx @@ -0,0 +1,202 @@ +import React from 'react' +import { describe, it, expect } from 'vitest' +import { render, act, waitFor } from '@testing-library/react' +import * as Inlay from '../inlay' +import { getAbsoluteOffset } from '../internal/dom-utils' + +function flush() { + return new Promise((r) => setTimeout(r, 0)) +} + +describe('Inlay public selection API', () => { + it('setSelection updates public context and getSelectionRange round-trips to same raw offsets', async () => { + const ref = React.createRef() + + const Test = () => ( + {}} + data-testid="ed" + > + + {({ selection, getSelectionRange }) => { + const range = getSelectionRange() + let rawStart = -1 + let rawEnd = -1 + const root = ref.current?.root + if (root && range) { + rawStart = getAbsoluteOffset( + root, + range.startContainer, + range.startOffset + ) + rawEnd = getAbsoluteOffset( + root, + range.endContainer, + range.endOffset + ) + } + return ( + + ) + }} + + + ) + + const { getByTestId } = render() + + await act(async () => { + ref.current!.setSelection(1, 3) + await flush() + }) + + await waitFor(() => { + const ctx = getByTestId('ctx') + expect(ctx.getAttribute('data-start')).toBe('1') + expect(ctx.getAttribute('data-end')).toBe('3') + expect(ctx.getAttribute('data-range')).toBe('1') + expect(ctx.getAttribute('data-raw-start')).toBe('1') + expect(ctx.getAttribute('data-raw-end')).toBe('3') + }) + }) +}) + +describe('Inlay active token and state', () => { + it('middle/end for non-diverged token; start boundary is spacer', async () => { + const ref = React.createRef() + const { getByTestId } = render( + {}} data-testid="ed"> + + @x + + + {({ activeToken, activeTokenState }) => ( + + )} + + + ) + + const ed = getByTestId('ed') as HTMLElement + + await waitFor(() => { + expect(ed.querySelector('[data-token-text]')).toBeTruthy() + }) + + // Start boundary selects spacer [0,1] + await act(async () => { + ref.current!.setSelection(1) + await flush() + }) + await waitFor(() => { + const ctx = document.querySelector('[data-testid="ctx"]') as HTMLElement + expect(ctx.getAttribute('data-start')).toBe('0') + expect(ctx.getAttribute('data-end')).toBe('1') + expect(ctx.getAttribute('data-collapsed')).toBe('1') + expect(ctx.getAttribute('data-atstart')).toBe('0') + expect(ctx.getAttribute('data-atend')).toBe('1') + }) + + // Middle of token (offset 2) + await act(async () => { + ref.current!.setSelection(2) + await flush() + }) + await waitFor(() => { + const ctx = document.querySelector('[data-testid="ctx"]') as HTMLElement + expect(ctx.getAttribute('data-start')).toBe('1') + expect(ctx.getAttribute('data-end')).toBe('3') + expect(ctx.getAttribute('data-collapsed')).toBe('1') + expect(ctx.getAttribute('data-atstart')).toBe('0') + expect(ctx.getAttribute('data-atend')).toBe('0') + }) + + // End of token (offset 3) + await act(async () => { + ref.current!.setSelection(3) + await flush() + }) + await waitFor(() => { + const ctx = document.querySelector('[data-testid="ctx"]') as HTMLElement + expect(ctx.getAttribute('data-start')).toBe('1') + expect(ctx.getAttribute('data-end')).toBe('3') + expect(ctx.getAttribute('data-collapsed')).toBe('1') + expect(ctx.getAttribute('data-atstart')).toBe('0') + expect(ctx.getAttribute('data-atend')).toBe('1') + }) + }) + + it('diverged: start boundary is spacer; end-of-token reports atEnd', async () => { + const ref = React.createRef() + const { getByTestId } = render( + {}} + data-testid="ed" + > + + Alex + + + {({ activeToken, activeTokenState }) => ( + + )} + + + ) + + const ed = getByTestId('ed') as HTMLElement + + await waitFor(() => { + expect(ed.querySelector('[data-token-text]')).toBeTruthy() + }) + + // Start boundary is spacer [0,1] + await act(async () => { + ref.current!.setSelection(1) + await flush() + }) + await waitFor(() => { + const ctx = document.querySelector('[data-testid="ctx"]') as HTMLElement + expect(ctx.getAttribute('data-start')).toBe('0') + expect(ctx.getAttribute('data-end')).toBe('1') + expect(ctx.getAttribute('data-atstart')).toBe('0') + expect(ctx.getAttribute('data-atend')).toBe('1') + }) + + // End of token (offset 6) + await act(async () => { + ref.current!.setSelection(6) + await flush() + }) + await waitFor(() => { + const ctx = document.querySelector('[data-testid="ctx"]') as HTMLElement + expect(ctx.getAttribute('data-atstart')).toBe('0') + expect(ctx.getAttribute('data-atend')).toBe('1') + }) + }) +}) diff --git a/src/inlay/__tests__/inlay-tokens-and-registry.test.tsx b/src/inlay/__tests__/inlay-tokens-and-registry.test.tsx new file mode 100644 index 0000000..79b7164 --- /dev/null +++ b/src/inlay/__tests__/inlay-tokens-and-registry.test.tsx @@ -0,0 +1,264 @@ +import React from 'react' +import { describe, it, expect } from 'vitest' +import { render, waitFor, act } from '@testing-library/react' +import * as Inlay from '../inlay' + +function flush() { + return new Promise((r) => setTimeout(r, 0)) +} + +// Ancestor registration +describe('Inlay ancestor registration', () => { + it('wraps a token with its ancestor element in the visible weaved output', async () => { + const { getByTestId } = render( + {}} data-testid="ed"> + + + X + + + + ) + + const editor = getByTestId('ed') as HTMLElement + + await waitFor(() => { + const token = editor.querySelector('[data-token-text="X"]') + expect(token).toBeTruthy() + }) + + const wrapped = editor.querySelector( + '[data-anc="outer"] [data-token-text="X"]' + ) + expect(wrapped).toBeTruthy() + }) + + it('uses the top-most ancestor, not inner ones, as the captured node (outer wrapper present)', async () => { + const { getByTestId } = render( + {}} data-testid="ed"> + + + + X + + + + + ) + + const editor = getByTestId('ed') as HTMLElement + + await waitFor(() => { + const token = editor.querySelector('[data-token-text="X"]') + expect(token).toBeTruthy() + }) + + const outerWrapped = editor.querySelector( + '[data-anc="outer"] [data-token-text="X"]' + ) + expect(outerWrapped).toBeTruthy() + }) + + it('when no ancestor wrapper is present, token renders directly without ancestor', async () => { + const { getByTestId } = render( + {}} data-testid="ed"> + + X + + + ) + + const editor = getByTestId('ed') as HTMLElement + + await waitFor(() => { + const token = editor.querySelector('[data-token-text="X"]') + expect(token).toBeTruthy() + }) + + expect(editor.querySelector('[data-anc]')).toBeFalsy() + }) +}) + +// Adjacent tokens +describe('Inlay adjacent tokens (weaving)', () => { + it('renders back-to-back tokens without a spacer', () => { + const { getByTestId } = render( + {}} data-testid="ed"> + + @x + + + @x + + + ) + + const editor = getByTestId('ed') as HTMLElement + const tokens = editor.querySelectorAll('[data-token-text="@x"]') + expect(tokens.length).toBe(2) + }) +}) + +// Registry staleness +describe('Inlay token registry staleness', () => { + it('when value changes and token child is removed, no stale token renders; plain text is shown', async () => { + const Test = ({ + value, + withToken + }: { + value: string + withToken: boolean + }) => ( + {}} data-testid="ed"> + {withToken ? ( + + X + + ) : ( + + )} + + ) + + const { getByTestId, rerender } = render( + + ) + const ed = getByTestId('ed') as HTMLElement + + await waitFor(() => { + expect(ed.querySelector('[data-token-text="X"]')).toBeTruthy() + }) + + rerender() + + await waitFor(() => { + expect(ed.querySelector('[data-token-text]')).toBeFalsy() + expect(ed.textContent).toBe('Y') + }) + }) + + it('when value changes but a stale token child remains, it should not appear in the visible editor', async () => { + const { getByTestId, rerender } = render( + {}} data-testid="ed"> + + X + + + ) + const ed = getByTestId('ed') as HTMLElement + + await waitFor(() => { + expect(ed.querySelector('[data-token-text="X"]')).toBeTruthy() + }) + + rerender( + {}} data-testid="ed"> + + X + + + ) + + await waitFor(() => { + expect(ed.querySelector('[data-token-text]')).toBeFalsy() + }) + }) +}) + +// External controlled updates +describe('Inlay external controlled updates', () => { + it('re-weaves tokens when parent changes value (appears/disappears)', async () => { + function Test() { + const [value, setValue] = React.useState('A') + ;(window as any).__setVal = setValue + return ( + + + @x + + + ) + } + + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + await waitFor(() => { + expect(ed.querySelector('[data-token-text]')).toBeFalsy() + }) + + await act(async () => { + ;(window as any).__setVal('@x') + await flush() + }) + + await waitFor(() => { + expect(ed.querySelector('[data-token-text="@x"]')).toBeTruthy() + }) + + await act(async () => { + ;(window as any).__setVal('B') + await flush() + }) + + await waitFor(() => { + expect(ed.querySelector('[data-token-text]')).toBeFalsy() + }) + }) + + it('context remains usable after external value change (can set selection and get active token)', async () => { + const ref = React.createRef() + function Test() { + const [value, setValue] = React.useState('@x') + ;(window as any).__setVal2 = setValue + return ( + + + @x + + + {({ activeToken }) => ( + + )} + + + ) + } + + const { getByTestId } = render() + const ed = getByTestId('ed') as HTMLElement + + await waitFor(() => { + expect(ed.querySelector('[data-token-text]')).toBeTruthy() + }) + + await act(async () => { + ;(window as any).__setVal2('A@xB') + await flush() + }) + + await waitFor(() => { + expect(ed.querySelector('[data-token-text]')).toBeTruthy() + }) + + await act(async () => { + ref.current!.setSelection(2) + await flush() + }) + + await waitFor(() => { + const ctx = getByTestId('ctx') + expect(ctx.getAttribute('data-start')).toBe('1') + expect(ctx.getAttribute('data-end')).toBe('3') + }) + }) +}) diff --git a/src/inlay/hooks/use-clipboard.ts b/src/inlay/hooks/use-clipboard.ts new file mode 100644 index 0000000..c2b70d4 --- /dev/null +++ b/src/inlay/hooks/use-clipboard.ts @@ -0,0 +1,182 @@ +import { useCallback, useRef } from 'react' +import { flushSync } from 'react-dom' +import { + getAbsoluteOffset, + setDomSelection, + getClosestTokenEl, + getTokenRawRange +} from '../internal/dom-utils' + +export type ClipboardConfig = { + editorRef: React.RefObject + getValue: () => string + setValue: React.Dispatch> + pushUndoSnapshot?: () => void + isComposingRef: React.MutableRefObject +} + +function getSelectionFromDom( + root: HTMLElement +): { start: number; end: number } | null { + const domSelection = window.getSelection() + if (!domSelection || !domSelection.rangeCount) return null + + const range = domSelection.getRangeAt(0) + if (!root.contains(range.startContainer)) return null + + let start = getAbsoluteOffset(root, range.startContainer, range.startOffset) + let end = getAbsoluteOffset(root, range.endContainer, range.endOffset) + + // Handle snapped offsets when DOM selection exists but offsets collapsed + if (start === end && !range.collapsed) { + const startToken = getClosestTokenEl(range.startContainer) + const endToken = getClosestTokenEl(range.endContainer) + + if (startToken && startToken === endToken) { + const tokenRange = getTokenRawRange(root, startToken) + if (tokenRange) return tokenRange + } else if (startToken) { + const tokenRange = getTokenRawRange(root, startToken) + if (tokenRange) { + start = tokenRange.start + end = tokenRange.end + } + } else if (endToken) { + const tokenRange = getTokenRawRange(root, endToken) + if (tokenRange) { + start = tokenRange.start + end = tokenRange.end + } + } + } + + // Expand partial token selections to full token boundaries + const startToken = getClosestTokenEl(range.startContainer) + if (startToken) { + const tokenRange = getTokenRawRange(root, startToken) + if (tokenRange && start > tokenRange.start && start < tokenRange.end) { + start = tokenRange.start + } + } + + const endToken = getClosestTokenEl(range.endContainer) + if (endToken) { + const tokenRange = getTokenRawRange(root, endToken) + if (tokenRange && end > tokenRange.start && end < tokenRange.end) { + end = tokenRange.end + } + } + + return { start, end } +} + +export function useClipboard(cfg: ClipboardConfig) { + // Track pending selection for rapid operations where DOM hasn't caught up yet + const pendingSelectionRef = useRef<{ start: number; end: number } | null>( + null + ) + const pendingClearTimeoutRef = useRef(null) + + const onCopy = useCallback( + (event: React.ClipboardEvent) => { + event.preventDefault() + + const root = cfg.editorRef.current + if (!root) return + + const sel = getSelectionFromDom(root) + if (!sel || sel.start === sel.end) return + + const rawText = cfg.getValue().slice(sel.start, sel.end) + event.clipboardData.setData('text/plain', rawText) + }, + [cfg] + ) + + const onCut = useCallback( + (event: React.ClipboardEvent) => { + event.preventDefault() + + const root = cfg.editorRef.current + if (!root) return + + const sel = getSelectionFromDom(root) + if (!sel || sel.start === sel.end) return + + const rawText = cfg.getValue().slice(sel.start, sel.end) + event.clipboardData.setData('text/plain', rawText) + + cfg.pushUndoSnapshot?.() + + cfg.setValue((currentValue) => { + const before = currentValue.slice(0, sel.start) + const after = currentValue.slice(sel.end) + + requestAnimationFrame(() => { + const root = cfg.editorRef.current + if (root?.isConnected) setDomSelection(root, sel.start) + }) + + return before + after + }) + }, + [cfg] + ) + + const onPaste = useCallback( + (event: React.ClipboardEvent) => { + if (cfg.isComposingRef.current) return + event.preventDefault() + + const pastedText = event.clipboardData.getData('text/plain') + if (!pastedText) return + + const root = cfg.editorRef.current + if (!root) return + + // Use pending selection if available (from rapid paste), otherwise read from DOM + const sel = pendingSelectionRef.current ?? getSelectionFromDom(root) + if (!sel) return + + cfg.pushUndoSnapshot?.() + + const len = cfg.getValue().length + const safeStart = Math.max(0, Math.min(sel.start, len)) + const newSelection = safeStart + pastedText.length + + // Update pending selection synchronously BEFORE state update + // so subsequent rapid pastes use the correct position + pendingSelectionRef.current = { start: newSelection, end: newSelection } + + // Use flushSync to ensure DOM is updated synchronously + flushSync(() => { + cfg.setValue((currentValue) => { + const len = currentValue.length + const safeStart = Math.max(0, Math.min(sel.start, len)) + const safeEnd = Math.max(0, Math.min(sel.end, len)) + const before = currentValue.slice(0, safeStart) + const after = currentValue.slice(safeEnd) + return before + pastedText + after + }) + }) + + // Set selection immediately after flushSync (DOM is now updated) + if (root.isConnected) { + setDomSelection(root, newSelection) + } + + // Clear pending selection after a short delay, giving time for + // rapid paste events to complete before falling back to DOM + if (pendingClearTimeoutRef.current !== null) { + clearTimeout(pendingClearTimeoutRef.current) + } + pendingClearTimeoutRef.current = window.setTimeout(() => { + pendingClearTimeoutRef.current = null + pendingSelectionRef.current = null + }, 100) + }, + [cfg] + ) + + return { onCopy, onCut, onPaste } +} diff --git a/src/inlay/hooks/use-composition.ts b/src/inlay/hooks/use-composition.ts new file mode 100644 index 0000000..309f6f1 --- /dev/null +++ b/src/inlay/hooks/use-composition.ts @@ -0,0 +1,164 @@ +import { useCallback, useRef, useState } from 'react' +import { getAbsoluteOffset, setDomSelection } from '../internal/dom-utils' + +// Platform detection for iOS-specific handling +const isIOS = + typeof navigator !== 'undefined' && + (/iPad|iPhone|iPod/.test(navigator.userAgent) || + (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)) + +export function useComposition( + editorRef: React.RefObject, + serializeRawFromDom: () => string, + handleSelectionChange: () => void, + setValue: (updater: (prev: string) => string) => void, + getCurrentValue: () => string +) { + const [isComposing, setIsComposing] = useState(false) + const [contentKey, setContentKey] = useState(0) + const isComposingRef = useRef(false) + const compositionStartSelectionRef = useRef<{ + start: number + end: number + } | null>(null) + const compositionInitialValueRef = useRef(null) + const suppressNextBeforeInputRef = useRef(false) + const suppressNextKeydownCommitRef = useRef(null) + const compositionCommitKeyRef = useRef<'enter' | 'space' | null>(null) + const compositionJustEndedAtRef = useRef(0) + // Track last composition data for iOS workaround + const lastCompositionDataRef = useRef('') + + const onCompositionStart = useCallback( + (event: React.CompositionEvent) => { + console.log('[compositionstart]', { + data: event.data + }) + if (!editorRef.current) return + isComposingRef.current = true + setIsComposing(true) + const sel = window.getSelection() + if (sel && sel.rangeCount > 0) { + const r = sel.getRangeAt(0) + const start = getAbsoluteOffset( + editorRef.current, + r.startContainer, + r.startOffset + ) + const end = getAbsoluteOffset( + editorRef.current, + r.endContainer, + r.endOffset + ) + compositionStartSelectionRef.current = { start, end } + } + compositionInitialValueRef.current = getCurrentValue() + }, + [editorRef, getCurrentValue] + ) + + const onCompositionUpdate = useCallback( + (event: React.CompositionEvent) => { + // iOS Safari sometimes doesn't include data in compositionend, + // so we track the last composition data during updates + const data = (event as unknown as { data?: string }).data + if (data) { + lastCompositionDataRef.current = data + } + }, + [] + ) + + const onCompositionEnd = useCallback( + (event: React.CompositionEvent) => { + const eventData = (event as unknown as { data?: string }).data + console.log('[compositionend]', { + data: eventData, + lastCompositionData: lastCompositionDataRef.current + }) + + const root = editorRef.current + if (!root) { + isComposingRef.current = false + setIsComposing(false) + return + } + suppressNextBeforeInputRef.current = true + console.log('[compositionend] setting suppressNextBeforeInput = true') + + // Build committed value + let committed = (event as unknown as { data?: string }).data || '' + + // iOS Safari workaround: compositionend may not include data, + // fall back to last tracked composition data from updates + if (!committed && isIOS && lastCompositionDataRef.current) { + committed = lastCompositionDataRef.current + } + + const baseValue = compositionInitialValueRef.current ?? getCurrentValue() + const range = compositionStartSelectionRef.current ?? { start: 0, end: 0 } + const len = baseValue.length + const safeStart = Math.max(0, Math.min(range.start, len)) + const safeEnd = Math.max(0, Math.min(range.end, len)) + const before = baseValue.slice(0, safeStart) + const after = baseValue.slice(safeEnd) + + if (!committed) { + const domText = serializeRawFromDom() + const replacedLen = safeEnd - safeStart + const insertedLen = Math.max( + 0, + domText.length - (baseValue.length - replacedLen) + ) + if (insertedLen > 0 && safeStart + insertedLen <= domText.length) { + committed = domText.slice(safeStart, safeStart + insertedLen) + } + } + + setValue(() => before + committed + after) + + // Force a remount to purge any transient IME DOM artifacts left behind + setContentKey((k) => k + 1) + + // One-shot suppress the immediate commit keydown regardless of engine + // Prefer the key recorded during composition; default to 'enter' + suppressNextKeydownCommitRef.current = + compositionCommitKeyRef.current ?? 'enter' + compositionJustEndedAtRef.current = Date.now() + + requestAnimationFrame(() => { + const r = editorRef.current + if (!r) return + setDomSelection(r, safeStart + committed.length) + handleSelectionChange() + }) + + isComposingRef.current = false + setIsComposing(false) + compositionInitialValueRef.current = null + compositionStartSelectionRef.current = null + compositionCommitKeyRef.current = null + lastCompositionDataRef.current = '' + }, + [ + editorRef, + getCurrentValue, + handleSelectionChange, + serializeRawFromDom, + setValue + ] + ) + + return { + isComposing, + isComposingRef, + suppressNextBeforeInputRef, + suppressNextKeydownCommitRef, + compositionCommitKeyRef, + compositionJustEndedAtRef, + contentKey, + onCompositionStart, + onCompositionUpdate, + onCompositionEnd + } +} diff --git a/src/inlay/hooks/use-history.ts b/src/inlay/hooks/use-history.ts new file mode 100644 index 0000000..74961bb --- /dev/null +++ b/src/inlay/hooks/use-history.ts @@ -0,0 +1,98 @@ +import { useCallback, useRef } from 'react' + +export type Snapshot = { + value: string + selection: { start: number; end: number } +} + +export function useHistory( + getCurrentSnapshot: () => Snapshot, + applySnapshot: (snap: Snapshot) => void, + maxHistory: number = 200 +) { + const undoStackRef = useRef([]) + const redoStackRef = useRef([]) + + const pushUndoSnapshot = useCallback(() => { + const snap = getCurrentSnapshot() + const stack = undoStackRef.current + if (stack.length >= maxHistory) stack.shift() + stack.push(snap) + // New edits invalidate redo history + redoStackRef.current = [] + }, [getCurrentSnapshot, maxHistory]) + + const editSessionRef = useRef<{ + type: 'insert' | 'delete' | null + timer: number | null + }>({ + type: null, + timer: null + }) + + const endEditSession = useCallback(() => { + const s = editSessionRef.current + if (s.timer != null) { + clearTimeout(s.timer) + } + editSessionRef.current = { type: null, timer: null } + }, []) + + const beginEditSession = useCallback( + (type: 'insert' | 'delete') => { + const s = editSessionRef.current + if (s.type !== type) { + // Different kind resets session + endEditSession() + } + if (editSessionRef.current.type === null) { + // Start of a new coalesced chunk: push snapshot + pushUndoSnapshot() + } + // Refresh session + const timer = window.setTimeout(() => { + endEditSession() + }, 800) + editSessionRef.current = { type, timer } + }, + [endEditSession, pushUndoSnapshot] + ) + + const undo = useCallback(() => { + const stack = undoStackRef.current + if (stack.length > 0) { + const current = getCurrentSnapshot() + const last = stack.pop()! + const redoStack = redoStackRef.current + if (redoStack.length >= maxHistory) redoStack.shift() + redoStack.push(current) + applySnapshot(last) + return true + } + return false + }, [applySnapshot, getCurrentSnapshot, maxHistory]) + + const redo = useCallback(() => { + const redoStack = redoStackRef.current + if (redoStack.length > 0) { + const current = getCurrentSnapshot() + const next = redoStack.pop()! + const undoStack = undoStackRef.current + if (undoStack.length >= maxHistory) undoStack.shift() + undoStack.push(current) + applySnapshot(next) + return true + } + return false + }, [applySnapshot, getCurrentSnapshot, maxHistory]) + + return { + undoStackRef, + redoStackRef, + pushUndoSnapshot, + beginEditSession, + endEditSession, + undo, + redo + } +} diff --git a/src/inlay/hooks/use-key-handlers.ts b/src/inlay/hooks/use-key-handlers.ts new file mode 100644 index 0000000..8b489dc --- /dev/null +++ b/src/inlay/hooks/use-key-handlers.ts @@ -0,0 +1,1013 @@ +import React, { useCallback, useEffect, useRef } from 'react' +import { + getAbsoluteOffset, + getClosestTokenEl, + setDomSelection, + serializeRawFromDom +} from '../internal/dom-utils' +import { + nextGraphemeEnd, + prevGraphemeStart, + snapGraphemeEnd, + snapGraphemeStart +} from '../internal/string-utils' + +const isJsdom = + typeof navigator !== 'undefined' && /jsdom/i.test(navigator.userAgent || '') + +// Platform detection for iOS-specific handling +const isIOS = + typeof navigator !== 'undefined' && + (/iPad|iPhone|iPod/.test(navigator.userAgent) || + (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)) + +function scheduleSelection(cb: () => void) { + if (isJsdom) { + setTimeout(cb, 0) + } else if (typeof requestAnimationFrame === 'function') { + requestAnimationFrame(cb) + } else { + setTimeout(cb, 0) + } +} + +// Timeout for pending selection validity (ms) +const PENDING_SELECTION_TIMEOUT = 100 + +// Timeout for iOS swipe-text word deletion detection (ms) +// When backspace is pressed at the end of a recent multi-char insert, delete the whole chunk. +// iOS has no timeout - we use a generous value to avoid false negatives while still +// clearing stale tracking eventually. The main protection is position matching. +const SWIPE_TEXT_DELETE_TIMEOUT = 30000 // 30 seconds + +export type KeyHandlersConfig = { + editorRef: React.RefObject + contentKey: number // Changes when editor element is recreated (e.g., after IME composition) + multiline: boolean + onKeyDownProp?: (event: React.KeyboardEvent) => boolean | void + beginEditSession: (type: 'insert' | 'delete') => void + endEditSession: () => void + pushUndoSnapshot: () => void + undo: () => boolean + redo: () => boolean + isComposingRef: React.MutableRefObject + compositionCommitKeyRef: React.MutableRefObject<'enter' | 'space' | null> + suppressNextBeforeInputRef: React.MutableRefObject + suppressNextKeydownCommitRef: React.MutableRefObject + compositionJustEndedAtRef: React.MutableRefObject + setValue: React.Dispatch> + valueRef: React.MutableRefObject // Current value for sync checks + getActiveToken: () => { start: number; end: number } | null +} + +function selectionIntersectsToken(editor: HTMLElement): boolean { + const sel = window.getSelection() + if (!sel || sel.rangeCount === 0) return false + const rng = sel.getRangeAt(0) + const tokens = editor.querySelectorAll('[data-token-text]') + for (let i = 0; i < tokens.length; i++) { + const el = tokens[i] + const rangeIntersects = rng.intersectsNode + if (typeof rangeIntersects === 'function') { + if (rangeIntersects.call(rng, el)) return true + } else { + const tr = document.createRange() + tr.selectNode(el) + const overlap = + rng.compareBoundaryPoints(Range.END_TO_START, tr) === 1 && + rng.compareBoundaryPoints(Range.START_TO_END, tr) === -1 + if (overlap) return true + } + } + return false +} + +export function useKeyHandlers(cfg: KeyHandlersConfig) { + // Track pending selection to avoid stale window.getSelection() during rapid input + const pendingSelectionRef = useRef<{ pos: number; time: number } | null>(null) + + // Track last multi-char insert for iOS swipe-text word deletion detection + // When user swipe-types a word and presses backspace, iOS only sends one + // deleteContentBackward event for the last char, but expects the whole word deleted + const lastMultiCharInsertRef = useRef<{ + start: number + end: number + data: string // Track the actual inserted text for space preservation + time: number + } | null>(null) + + // Track when we actually prevented a beforeinput event (for MutationObserver to know) + const lastPreventedTimeRef = useRef(0) + + // Handle beforeinput via native event listener (React's synthetic event is unreliable) + const handleBeforeInput = useCallback( + (event: InputEvent) => { + const { editorRef } = cfg + if (!editorRef.current) return + + // Helper to mark that we're handling this event (for input handler to know) + const preventAndMark = () => { + event.preventDefault() + lastPreventedTimeRef.current = Date.now() + } + + const data: string | null | undefined = event.data + const inputType: string | undefined = event.inputType + + if (cfg.suppressNextBeforeInputRef.current) { + cfg.suppressNextBeforeInputRef.current = false + preventAndMark() + return + } + + // Immediately after compositionend, WebKit may fire an extra beforeinput inserting a newline. + // Block it within a short window regardless of current composition state. + if ( + cfg.compositionJustEndedAtRef.current && + Date.now() - cfg.compositionJustEndedAtRef.current < 50 && + (inputType === 'insertParagraph' || inputType === 'insertLineBreak') + ) { + preventAndMark() + return + } + + if (cfg.isComposingRef.current) { + if ( + inputType === 'insertParagraph' || + inputType === 'insertLineBreak' + ) { + preventAndMark() + } + return + } + + // Handle text insertions (insertText and insertReplacementText) + if (inputType === 'insertText' || inputType === 'insertReplacementText') { + // iOS handling: Let iOS modify DOM directly (prevents multi-word suggestions) + // EXCEPT when: has newlines (React crash) or in token context (stale DOM attributes) + const hasNewlines = cfg.valueRef.current.includes('\n') + const domSelection = window.getSelection() + const range = domSelection?.rangeCount + ? domSelection.getRangeAt(0) + : null + const isInTokenContext = range + ? !!getClosestTokenEl(range.startContainer) + : false + + const insertData = + inputType === 'insertReplacementText' + ? (data ?? event.dataTransfer?.getData('text/plain')) + : data + + if (isIOS && !hasNewlines && !isInTokenContext) { + return + } + + if (isIOS && hasNewlines && !insertData) { + // iOS with newlines but no data available - can't handle safely + // This shouldn't normally happen, but if it does, prevent and skip + preventAndMark() + return + } + + preventAndMark() + + // insertData already obtained above + if (!insertData) return + + // For insertReplacementText, use target ranges + let start: number + let end: number + + if (inputType === 'insertReplacementText') { + const targetRanges = event.getTargetRanges?.() + if (!targetRanges || targetRanges.length === 0) return + const targetRange = targetRanges[0] + start = getAbsoluteOffset( + editorRef.current, + targetRange.startContainer, + targetRange.startOffset + ) + end = getAbsoluteOffset( + editorRef.current, + targetRange.endContainer, + targetRange.endOffset + ) + } else { + // insertText: use current selection + const pending = pendingSelectionRef.current + if ( + pending && + Date.now() - pending.time < PENDING_SELECTION_TIMEOUT + ) { + start = pending.pos + end = pending.pos + } else { + const domSelection = window.getSelection() + if (!domSelection || !domSelection.rangeCount) return + const range = domSelection.getRangeAt(0) + start = getAbsoluteOffset( + editorRef.current, + range.startContainer, + range.startOffset + ) + end = getAbsoluteOffset( + editorRef.current, + range.endContainer, + range.endOffset + ) + } + } + + cfg.beginEditSession('insert') + cfg.setValue((currentValue) => { + const len = currentValue.length + const safeStart = Math.max(0, Math.min(start, len)) + const safeEnd = Math.max(0, Math.min(end, len)) + const before = currentValue.slice(0, safeStart) + const after = currentValue.slice(safeEnd) + + // Strip leading space when: + // 1. Inserting after a space (prevent double-space) + // 2. Inserting at start of line (after newline) - iOS swipe often adds leading space + // 3. Inserting at start of content (empty before) + // + // iOS sometimes sends the space as a SEPARATE event before the word, + // so we need to handle both single-space and space-prefixed insertions. + let finalInsertData = insertData + const shouldStripSpace = + insertData.startsWith(' ') && + (before.endsWith(' ') || + before.endsWith('\n') || + before.length === 0) + + if (shouldStripSpace) { + if (insertData === ' ') { + // Single space at start of line - skip entirely + return currentValue + } + // Multi-char with leading space - strip the space + finalInsertData = insertData.slice(1) + } + + const newValue = before + finalInsertData + after + const newSelection = safeStart + finalInsertData.length + + // Track as multi-char insert for swipe-text backspace detection + // ONLY track insertText (swipe-typing), NOT insertReplacementText (autocomplete) + // When user types "hel" and taps "hello" suggestion, backspace should delete char-by-char + const isSwipeText = inputType === 'insertText' + + if (isSwipeText && finalInsertData.length > 1) { + lastMultiCharInsertRef.current = { + start: safeStart, + end: newSelection, + data: finalInsertData, + time: Date.now() + } + } else if ( + isSwipeText && + finalInsertData === ' ' && + lastMultiCharInsertRef.current + ) { + // Extend tracking if adding trailing space to multi-char insert + const lastInsert = lastMultiCharInsertRef.current + if (safeStart === lastInsert.end) { + lastMultiCharInsertRef.current = { + start: lastInsert.start, + end: newSelection, + data: lastInsert.data + ' ', + time: lastInsert.time + } + } else { + lastMultiCharInsertRef.current = null + } + } else { + // Autocomplete (insertReplacementText) or single char - clear swipe tracking + lastMultiCharInsertRef.current = null + } + + pendingSelectionRef.current = { pos: newSelection, time: Date.now() } + + scheduleSelection(() => { + const root = editorRef.current + if (!root || !root.isConnected) return + setDomSelection(root, newSelection) + }) + return newValue + }) + return + } + + // Handle various delete input types (including mobile-specific ones) + if ( + inputType === 'deleteContentBackward' || + inputType === 'deleteContentForward' || + inputType === 'deleteWordBackward' || + inputType === 'deleteWordForward' || + inputType === 'deleteSoftLineBackward' || + inputType === 'deleteSoftLineForward' + ) { + preventAndMark() + + // iOS swipe-text fix: Use targetRanges when available AND the nodes are still connected. + // After rapid beforeinput events (e.g., iOS word deletion), React re-renders replace + // text nodes, making subsequent targetRanges point to orphaned nodes. + // In that case, use pending selection which tracks the expected cursor position. + const targetRanges = event.getTargetRanges?.() + + let start: number + let end: number + + // Check if targetRanges point to nodes still in the DOM + const targetRange = targetRanges?.[0] + const targetNodesConnected = + targetRange && + editorRef.current?.contains(targetRange.startContainer) && + editorRef.current?.contains(targetRange.endContainer) + + if (targetRange && targetNodesConnected) { + start = getAbsoluteOffset( + editorRef.current, + targetRange.startContainer, + targetRange.startOffset + ) + end = getAbsoluteOffset( + editorRef.current, + targetRange.endContainer, + targetRange.endOffset + ) + } else { + // Fallback: Use pending selection (for rapid deletes) or window.getSelection() + const pending = pendingSelectionRef.current + if ( + pending && + Date.now() - pending.time < PENDING_SELECTION_TIMEOUT + ) { + // Use pending selection for rapid iOS word deletion + start = pending.pos + end = pending.pos + } else { + const domSelection = window.getSelection() + if (!domSelection || !domSelection.rangeCount) return + const range = domSelection.getRangeAt(0) + start = getAbsoluteOffset( + editorRef.current, + range.startContainer, + range.startOffset + ) + end = getAbsoluteOffset( + editorRef.current, + range.endContainer, + range.endOffset + ) + } + } + + // iOS swipe-text word deletion detection: + // When user swipe-types a word and presses backspace, iOS sends ONE + // deleteContentBackward event with targetRange for just the last char. + // But the user expects the whole word to be deleted. + // Detect this by checking if we're deleting at the end of a recent multi-char insert. + const lastInsert = lastMultiCharInsertRef.current + const timeSinceInsert = lastInsert ? Date.now() - lastInsert.time : null + const matchesEnd = lastInsert ? end === lastInsert.end : false + const withinTimeout = + timeSinceInsert !== null && + timeSinceInsert < SWIPE_TEXT_DELETE_TIMEOUT + + if ( + inputType === 'deleteContentBackward' && + lastInsert && + withinTimeout && + matchesEnd + ) { + // This looks like iOS swipe-text deletion - delete the whole chunk + // But if the insert started with a space (iOS auto-inserts space before swipe words), + // preserve that space - only delete the word part + // EXCEPT: if the character before the insert is also a space (double-space scenario), + // delete the leading space too to avoid leaving a double space + const startsWithSpace = lastInsert.data.startsWith(' ') + + cfg.setValue((currentValue) => { + const charBefore = + lastInsert.start > 0 + ? currentValue.charAt(lastInsert.start - 1) + : '' + const prevIsSpace = charBefore === ' ' + + // If starts with space AND prev char is NOT a space, preserve the space + // Otherwise, delete from the start (including the leading space) + const preserveLeadingSpace = startsWithSpace && !prevIsSpace + const deleteStart = preserveLeadingSpace + ? lastInsert.start + 1 + : lastInsert.start + + // Actually perform the deletion here since we need currentValue + const before = currentValue.slice(0, deleteStart) + const after = currentValue.slice(end) + const newValue = before + after + + pendingSelectionRef.current = { + pos: deleteStart, + time: Date.now() + } + + scheduleSelection(() => { + const root = editorRef.current + if (!root || !root.isConnected) return + setDomSelection(root, deleteStart) + }) + + return newValue + }) + + lastMultiCharInsertRef.current = null // Clear tracking + return // Early return since we handled the deletion + } + + cfg.beginEditSession('delete') + cfg.setValue((currentValue) => { + const len = currentValue.length + const safeStart = Math.max(0, Math.min(start, len)) + const safeEnd = Math.max(0, Math.min(end, len)) + let newSelection = safeStart + let before = '' + let after = '' + + // For deleteContentBackward: + // - If there's a range selection (safeStart !== safeEnd), delete the entire selection + // - If it's a collapsed cursor, delete one grapheme backward + if (inputType === 'deleteContentBackward') { + // Handle range selection (delete entire selected range) + if (safeStart !== safeEnd) { + if (selectionIntersectsToken(editorRef.current!)) { + before = currentValue.slice(0, safeStart) + after = currentValue.slice(safeEnd) + newSelection = safeStart + } else { + const adjStart = snapGraphemeStart(currentValue, safeStart) + const adjEnd = snapGraphemeEnd(currentValue, safeEnd) + before = currentValue.slice(0, adjStart) + after = currentValue.slice(adjEnd) + newSelection = adjStart + } + } else { + // Collapsed cursor - delete one char/grapheme backward + const cursorPos = safeEnd + if (cursorPos === 0) return currentValue + const active = cfg.getActiveToken() + const insideToken = !!( + active && + cursorPos > active.start && + cursorPos <= active.end + ) + // Clear swipe-text tracking when near a token + if (active) lastMultiCharInsertRef.current = null + if (insideToken) { + const delStart = cursorPos - 1 + before = currentValue.slice(0, delStart) + after = currentValue.slice(cursorPos) + newSelection = delStart + } else { + const clusterStart = prevGraphemeStart(currentValue, cursorPos) + before = currentValue.slice(0, clusterStart) + after = currentValue.slice(cursorPos) + newSelection = clusterStart + } + } + } else if (inputType === 'deleteContentForward') { + const cursorPos = safeStart + if (cursorPos === len) return currentValue + const active = cfg.getActiveToken() + const insideToken = !!( + active && + cursorPos >= active.start && + cursorPos < active.end + ) + if (insideToken) { + const delEnd = cursorPos + 1 + before = currentValue.slice(0, cursorPos) + after = currentValue.slice(delEnd) + newSelection = cursorPos + } else { + const clusterEnd = nextGraphemeEnd(currentValue, cursorPos) + before = currentValue.slice(0, cursorPos) + after = currentValue.slice(clusterEnd) + newSelection = cursorPos + } + } else { + // Other delete types (word, line) - use the provided range + if (selectionIntersectsToken(editorRef.current!)) { + before = currentValue.slice(0, safeStart) + after = currentValue.slice(safeEnd) + newSelection = safeStart + } else { + const adjStart = snapGraphemeStart(currentValue, safeStart) + const adjEnd = snapGraphemeEnd(currentValue, safeEnd) + before = currentValue.slice(0, adjStart) + after = currentValue.slice(adjEnd) + newSelection = adjStart + } + } + // Store pending selection for rapid input handling + pendingSelectionRef.current = { pos: newSelection, time: Date.now() } + + scheduleSelection(() => { + const root = editorRef.current + if (!root || !root.isConnected) return + setDomSelection(root, newSelection) + }) + return before + after + }) + return + } + }, + [cfg] + ) + + // Attach native beforeinput listener (React's synthetic event is unreliable for beforeinput) + // Re-attach when contentKey changes (editor element is recreated after IME composition) + useEffect(() => { + const editor = cfg.editorRef.current + if (!editor) return + + const beforeInputListener = (e: Event) => handleBeforeInput(e as InputEvent) + editor.addEventListener('beforeinput', beforeInputListener) + + // iOS DOM → React state sync: + // When we don't preventDefault on iOS text insertions, iOS modifies the DOM directly. + // We use the input event to sync the DOM content back to React state. + // This uses serializeRawFromDom to correctly handle token elements. + const inputListener = (e: Event) => { + if (!isIOS) return // Only needed for iOS + + const ie = e as InputEvent + const inputType = ie.inputType + + // Handle text insertions (we didn't preventDefault, iOS modified DOM) + if ( + inputType === 'insertText' || + inputType === 'insertReplacementText' || + inputType === 'insertFromPaste' || + inputType === 'insertFromDrop' + ) { + // Skip if we just prevented a beforeinput event (handled it ourselves) + const now = Date.now() + if (now - lastPreventedTimeRef.current < 50) { + return + } + + // Get the raw DOM content (before stripping invisible chars) + const rawDomContent = editor.innerText || '' + + // Serialize DOM to get the correct text value (respecting token data-attributes) + // This strips zero-width spaces and other invisible characters + const newValue = serializeRawFromDom(editor) + + // Get current cursor position from DOM BEFORE React re-renders + const domSelection = window.getSelection() + let cursorPos = newValue.length + if (domSelection && domSelection.rangeCount > 0) { + const range = domSelection.getRangeAt(0) + const rawCursorPos = getAbsoluteOffset( + editor, + range.startContainer, + range.startOffset + ) + + // Adjust cursor position for any invisible characters that were stripped + // Count how many invisible chars exist before the cursor in raw content + const beforeCursor = rawDomContent.slice(0, rawCursorPos) + const invisibleCharsBeforeCursor = ( + beforeCursor.match(/[\u200B\uFEFF]/g) || [] + ).length + cursorPos = Math.max( + 0, + Math.min(rawCursorPos - invisibleCharsBeforeCursor, newValue.length) + ) + } + + // Track multi-char inserts for iOS swipe-text word deletion + // We need to distinguish between: + // - Swipe-typing: User swipes across keyboard to type a whole word at once + // → Backspace should delete the whole word + // - Autocomplete/suggestion: User types partial word, taps suggestion + // → Backspace should delete char-by-char + // + // iOS may send insertText for both! So we can't rely on inputType alone. + // Better heuristic: swipe-typing inserts AFTER a space or at start. + // Autocomplete typically replaces text mid-word. + const isSwipeText = inputType === 'insertText' + + // Check if cursor is in a token - skip swipe-text tracking if so + const range = domSelection?.rangeCount + ? domSelection.getRangeAt(0) + : null + const isInTokenContext = range + ? !!getClosestTokenEl(range.startContainer) + : false + + // We need to track if we added a space so we can adjust cursor position + let addedSpace = false + + cfg.setValue((oldValue) => { + let finalValue = newValue + let adjustedCursorPos = cursorPos + const insertedLength = newValue.length - oldValue.length + const insertStart = cursorPos - insertedLength + + // iOS often adds a leading space to swipe-typed words. + // Strip it when inserting after a newline or at the start of content. + if (insertedLength > 1 && insertStart >= 0) { + const charBeforeInsert = + insertStart > 0 ? oldValue.charAt(insertStart - 1) : '' + const insertedData = newValue.slice(insertStart, cursorPos) + + // Strip leading space if: + // 1. The inserted text starts with a space + // 2. AND we're at start of content OR after a newline OR after an existing space + if ( + insertedData.startsWith(' ') && + (insertStart === 0 || + charBeforeInsert === '\n' || + charBeforeInsert === ' ') + ) { + // Remove the leading space from the inserted text + finalValue = + oldValue.slice(0, insertStart) + + insertedData.slice(1) + + oldValue.slice(insertStart) + adjustedCursorPos = cursorPos - 1 + } + } + + // Recalculate for the adjusted value + const adjustedInsertedLength = finalValue.length - oldValue.length + const adjustedInsertStart = adjustedCursorPos - adjustedInsertedLength + + // Swipe-text vs autocomplete detection: + // - Swipe-typing: after space/start → track for whole-word deletion + // - Autocomplete: mid-word extension → char-by-char deletion + // - Token context: always char-by-char deletion + if (isInTokenContext) { + lastMultiCharInsertRef.current = null + } else if ( + isSwipeText && + adjustedInsertedLength > 1 && + adjustedInsertStart >= 0 + ) { + const lastCharOfOld = + oldValue.length > 0 ? oldValue.charAt(oldValue.length - 1) : '' + const endsWithWordChar = + lastCharOfOld && lastCharOfOld !== ' ' && lastCharOfOld !== '\n' + + // If oldValue ends mid-word, this is likely autocomplete + // (user typed "hel" and tapped "hello" to extend it) + if (endsWithWordChar) { + // Autocomplete - don't track for whole-word deletion + lastMultiCharInsertRef.current = null + } else { + // Swipe-typing (oldValue ends with space, newline, or is empty) + const charBeforeAdjustedInsert = + adjustedInsertStart > 0 + ? oldValue.charAt(adjustedInsertStart - 1) + : '' + const adjustedInsertedData = finalValue.slice( + adjustedInsertStart, + adjustedCursorPos + ) + + if ( + adjustedInsertStart > 0 && + charBeforeAdjustedInsert && + charBeforeAdjustedInsert !== ' ' && + charBeforeAdjustedInsert !== '\n' && + !adjustedInsertedData.startsWith(' ') + ) { + // Need to add a space before the inserted word + finalValue = + oldValue.slice(0, adjustedInsertStart) + + ' ' + + adjustedInsertedData + + oldValue.slice(adjustedInsertStart) + addedSpace = true + + // Track with the added space + lastMultiCharInsertRef.current = { + start: adjustedInsertStart, + end: adjustedCursorPos + 1, + data: ' ' + adjustedInsertedData, + time: Date.now() + } + } else { + // Normal swipe - track for whole-word deletion + lastMultiCharInsertRef.current = { + start: adjustedInsertStart, + end: adjustedCursorPos, + data: adjustedInsertedData, + time: Date.now() + } + } + } + } else if (isSwipeText && adjustedInsertedLength === 1) { + // Single char from swipe - check if it's a trailing space after a swipe + const insertedChar = finalValue.charAt(adjustedCursorPos - 1) + const lastInsert = lastMultiCharInsertRef.current + if ( + insertedChar === ' ' && + lastInsert && + adjustedInsertStart === lastInsert.end + ) { + // Extend the tracking to include the trailing space + lastMultiCharInsertRef.current = { + start: lastInsert.start, + end: adjustedCursorPos, + data: lastInsert.data + ' ', + time: lastInsert.time + } + } else { + // Regular single char, clear tracking + lastMultiCharInsertRef.current = null + } + } else if (!isSwipeText) { + // Autocomplete/suggestion (insertReplacementText) - clear swipe tracking + // User should be able to backspace char-by-char + lastMultiCharInsertRef.current = null + } + + // Update pending selection for rapid input handling + const finalCursorPos = addedSpace + ? adjustedCursorPos + 1 + : adjustedCursorPos + pendingSelectionRef.current = { + pos: finalCursorPos, + time: Date.now() + } + + return finalValue + }) + + // Restore caret position after React re-renders + // Use pendingSelectionRef since the actual position is calculated inside setValue + scheduleSelection(() => { + const root = cfg.editorRef.current + if (!root || !root.isConnected) return + const pending = pendingSelectionRef.current + const pos = pending ? pending.pos : cursorPos + setDomSelection(root, pos) + }) + } + + // After deletions, sync DOM → React to ensure they stay in sync + // This catches any cases where our deletion logic didn't perfectly match iOS's DOM state + if ( + inputType === 'deleteContentBackward' || + inputType === 'deleteContentForward' || + inputType === 'deleteWordBackward' || + inputType === 'deleteWordForward' || + inputType === 'deleteSoftLineBackward' || + inputType === 'deleteSoftLineForward' + ) { + // Small delay to let our beforeinput handler complete first + setTimeout(() => { + const root = cfg.editorRef.current + if (!root || !root.isConnected) return + + const domValue = serializeRawFromDom(root) + cfg.setValue((currentValue) => { + // Only sync if they're different (our handler might have already set it correctly) + if (currentValue !== domValue) { + return domValue + } + return currentValue + }) + }, 0) + } + } + editor.addEventListener('input', inputListener) + + return () => { + editor.removeEventListener('beforeinput', beforeInputListener) + editor.removeEventListener('input', inputListener) + } + }, [cfg.editorRef, cfg.contentKey, handleBeforeInput, cfg.setValue]) + + // Keep onBeforeInput as a no-op for backward compatibility (actual handling is via native listener) + const onBeforeInput = useCallback(() => { + // No-op: beforeinput is handled via native event listener + }, []) + + const onKeyDown = useCallback( + (event: React.KeyboardEvent) => { + // One-shot suppression for the immediate keydown fired after compositionend (WebKit bug) + if (cfg.suppressNextKeydownCommitRef.current) { + const sup = cfg.suppressNextKeydownCommitRef.current + const isEnter = event.key === 'Enter' || event.key === 'Return' + const isSpace = event.key === ' ' + if ((sup === 'enter' && isEnter) || (sup === 'space' && isSpace)) { + event.preventDefault() + event.stopPropagation() + cfg.suppressNextKeydownCommitRef.current = null + return + } + // Clear suppression if next key is different + cfg.suppressNextKeydownCommitRef.current = null + } + + if (cfg.onKeyDownProp?.(event)) return + + if (!cfg.multiline && event.key === 'Enter') { + event.preventDefault() + return + } + + if ( + event.key.startsWith('Arrow') || + event.metaKey || + event.ctrlKey || + event.altKey + ) { + if (!(event.key === 'Shift')) cfg.endEditSession() + } + + if ((event.metaKey || event.ctrlKey) && !event.altKey) { + const isUndo = event.key.toLowerCase() === 'z' && !event.shiftKey + const isRedo = + (event.key.toLowerCase() === 'z' && event.shiftKey) || + event.key.toLowerCase() === 'y' + if (isUndo) { + if (cfg.undo()) { + event.preventDefault() + return + } + } else if (isRedo) { + if (cfg.redo()) { + event.preventDefault() + return + } + } + } + + const { editorRef } = cfg + if (!editorRef.current) return + const domSelection = window.getSelection() + if (!domSelection || !domSelection.rangeCount) return + + const range = domSelection.getRangeAt(0) + const start = getAbsoluteOffset( + editorRef.current, + range.startContainer, + range.startOffset + ) + + if (cfg.isComposingRef.current) { + if (event.key === 'Enter' || event.key === 'Return') { + event.preventDefault() + event.stopPropagation() + cfg.compositionCommitKeyRef.current = 'enter' + return + } + if (event.key === ' ') { + event.preventDefault() + event.stopPropagation() + cfg.compositionCommitKeyRef.current = 'space' + return + } + return + } + + if (event.key === 'Enter') { + event.preventDefault() + const end = getAbsoluteOffset( + editorRef.current, + range.endContainer, + range.endOffset + ) + cfg.pushUndoSnapshot() + cfg.endEditSession() + cfg.setValue((currentValue) => { + const len = currentValue.length + const safeStart = Math.max(0, Math.min(start, len)) + const safeEnd = Math.max(0, Math.min(end, len)) + const before = currentValue.slice(0, safeStart) + const after = currentValue.slice(safeEnd) + const newValue = before + '\n' + after + const newSelection = safeStart + 1 + scheduleSelection(() => { + const root = editorRef.current + if (!root || !root.isConnected) return + setDomSelection(root, newSelection) + + // iOS fix: Reset autocomplete state after newline. + // iOS sometimes doesn't see as a word boundary and suggests + // merged words like "helloworld" instead of treating them separately. + // Toggle autocomplete attribute to reset iOS's autocomplete context + // without dismissing the keyboard (unlike blur/focus). + if (isIOS) { + const currentAutocomplete = root.getAttribute('autocomplete') + root.setAttribute('autocomplete', 'off') + requestAnimationFrame(() => { + if (root.isConnected) { + if (currentAutocomplete) { + root.setAttribute('autocomplete', currentAutocomplete) + } else { + root.removeAttribute('autocomplete') + } + } + }) + } + }) + return newValue + }) + } + + // On iOS, don't intercept space - let it flow to beforeinput/input + // so that iOS multi-word keyboard suggestions can work. + // On desktop, handle space directly for consistent behavior. + if (event.key === ' ' && !isIOS) { + event.preventDefault() + cfg.beginEditSession('insert') + cfg.setValue((currentValue) => { + const len = currentValue.length + const safeStart = Math.max(0, Math.min(start, len)) + const safeEnd = Math.max(0, Math.min(start, len)) + const before = currentValue.slice(0, safeStart) + const after = currentValue.slice(safeEnd) + const newValue = before + ' ' + after + const newSelection = safeStart + 1 + scheduleSelection(() => { + const root = editorRef.current + if (!root || !root.isConnected) return + setDomSelection(root, newSelection) + }) + return newValue + }) + } + + // Backspace is handled by beforeinput (deleteContentBackward) to support + // iOS swipe-text word deletion which fires multiple beforeinput events. + // Not calling preventDefault here allows beforeinput to fire. + + if (event.key === 'Delete') { + event.preventDefault() + cfg.beginEditSession('delete') + cfg.setValue((currentValue) => { + if (!currentValue) return '' + const len = currentValue.length + const safeStart = Math.max(0, Math.min(start, len)) + let newSelection = safeStart + let before: string + let after: string + if (range.collapsed) { + if (safeStart === len) return currentValue + const active = cfg.getActiveToken() + const insideToken = !!( + active && + safeStart >= active.start && + safeStart < active.end + ) + if (insideToken) { + const delEnd = safeStart + 1 + before = currentValue.slice(0, safeStart) + after = currentValue.slice(delEnd) + newSelection = safeStart + } else { + const clusterEnd = nextGraphemeEnd(currentValue, safeStart) + before = currentValue.slice(0, safeStart) + after = currentValue.slice(clusterEnd) + newSelection = safeStart + } + } else { + const end = getAbsoluteOffset( + editorRef.current!, + range.endContainer, + range.endOffset + ) + const safeEnd = Math.max(0, Math.min(end, len)) + if (selectionIntersectsToken(editorRef.current!)) { + before = currentValue.slice(0, safeStart) + after = currentValue.slice(safeEnd) + newSelection = safeStart + } else { + const adjStart = snapGraphemeStart(currentValue, safeStart) + const adjEnd = snapGraphemeEnd(currentValue, safeEnd) + before = currentValue.slice(0, adjStart) + after = currentValue.slice(adjEnd) + newSelection = adjStart + } + } + scheduleSelection(() => { + const root = editorRef.current + if (!root || !root.isConnected) return + setDomSelection(root, newSelection) + }) + return before + after + }) + } + }, + [cfg] + ) + + return { onBeforeInput, onKeyDown } +} diff --git a/src/inlay/hooks/use-placeholder-sync.ts b/src/inlay/hooks/use-placeholder-sync.ts new file mode 100644 index 0000000..480c5e2 --- /dev/null +++ b/src/inlay/hooks/use-placeholder-sync.ts @@ -0,0 +1,38 @@ +import { useLayoutEffect } from 'react' + +export function usePlaceholderSync( + editorRef: React.RefObject, + placeholderRef: React.RefObject, + deps: unknown[] +) { + useLayoutEffect(() => { + if (editorRef.current && placeholderRef.current) { + const editorStyles = window.getComputedStyle(editorRef.current) + const stylesToCopy: (keyof CSSStyleDeclaration)[] = [ + 'paddingTop', + 'paddingRight', + 'paddingBottom', + 'paddingLeft', + 'borderTopWidth', + 'borderRightWidth', + 'borderBottomWidth', + 'borderLeftWidth', + 'fontFamily', + 'fontSize', + 'lineHeight', + 'letterSpacing', + 'textAlign' + ] + + stylesToCopy.forEach((styleName) => { + const v = editorStyles[styleName] + if (v !== null) { + // @ts-expect-error - Style name is a valid CSSStyleDeclaration property + placeholderRef.current!.style[styleName] = v as string + } + }) + placeholderRef.current!.style.borderStyle = editorStyles.borderStyle + placeholderRef.current!.style.borderColor = 'transparent' + } + }, deps) +} diff --git a/src/inlay/hooks/use-selection-snap.ts b/src/inlay/hooks/use-selection-snap.ts new file mode 100644 index 0000000..5c5661d --- /dev/null +++ b/src/inlay/hooks/use-selection-snap.ts @@ -0,0 +1,175 @@ +import { useCallback } from 'react' +import { getAbsoluteOffset, setDomSelection } from '../internal/dom-utils' + +export type SelectionSnapConfig = { + editorRef: React.RefObject + setSelection: (sel: { start: number; end: number }) => void + lastAnchorRectRef: React.MutableRefObject + suppressNextSelectionAdjustRef: React.MutableRefObject + lastArrowDirectionRef: React.MutableRefObject< + 'left' | 'right' | 'up' | 'down' | null + > + lastShiftRef: React.MutableRefObject + isComposingRef: React.MutableRefObject +} + +export function useSelectionSnap(cfg: SelectionSnapConfig) { + const onSelect = useCallback(() => { + const root = cfg.editorRef.current + if (!root) return + + if (cfg.suppressNextSelectionAdjustRef.current) { + cfg.suppressNextSelectionAdjustRef.current = false + } + + const domSelection = window.getSelection() + if (!domSelection || !domSelection.rangeCount) return + + const range = domSelection.getRangeAt(0) + const clientRect = + range.getClientRects()[0] || range.getBoundingClientRect() + if (!(clientRect.x === 0 && clientRect.y === 0)) { + cfg.lastAnchorRectRef.current = new DOMRect( + clientRect.x, + clientRect.y, + clientRect.width, + clientRect.height + ) + } + + if (!root.contains(range.startContainer)) return + + const start = getAbsoluteOffset( + root, + range.startContainer, + range.startOffset + ) + const end = getAbsoluteOffset(root, range.endContainer, range.endOffset) + cfg.setSelection({ start, end }) + + if (cfg.isComposingRef.current) { + cfg.lastArrowDirectionRef.current = null + cfg.lastShiftRef.current = false + return + } + + const direction = cfg.lastArrowDirectionRef.current + const isShift = cfg.lastShiftRef.current + if (direction) { + requestAnimationFrame(() => { + const root = cfg.editorRef.current + if (!root) return + const sel = window.getSelection() + if (!sel || sel.rangeCount === 0) return + const rng = sel.getRangeAt(0) + + const getClosestTokenEl = (n: Node | null): HTMLElement | null => { + let curr: Node | null = n + while (curr) { + if (curr.nodeType === Node.ELEMENT_NODE) { + const asEl = curr as HTMLElement + if (asEl.hasAttribute('data-token-text')) return asEl + } + curr = curr.parentNode || null + } + return null + } + + const renderedLen = (el: Element): number => { + const walker = document.createTreeWalker( + el, + NodeFilter.SHOW_TEXT, + null + ) + let total = 0 + let n: Node | null + while ((n = walker.nextNode())) total += (n.textContent || '').length + return total + } + const rawLen = (el: Element): number => + (el.getAttribute('data-token-text') || '').length + + const findFirstTextNode = (el: Element): ChildNode | null => { + const walker = document.createTreeWalker( + el, + NodeFilter.SHOW_TEXT, + null + ) + return walker.nextNode() as ChildNode | null + } + const findLastTextNode = (el: Element): ChildNode | null => { + const walker = document.createTreeWalker( + el, + NodeFilter.SHOW_TEXT, + null + ) + let last: Node | null = null + let n: Node | null + while ((n = walker.nextNode())) last = n + return last as ChildNode | null + } + + const snapEdgeForToken = ( + tokenEl: Element, + prefer: 'start' | 'end' + ): number | null => { + if (!root) return null + if (prefer === 'start') { + const first = findFirstTextNode(tokenEl) + if (first) return getAbsoluteOffset(root, first, 0) + return null + } else { + const last = findLastTextNode(tokenEl) + if (last) { + const len = (last.textContent || '').length + return getAbsoluteOffset(root, last, len) + } + return null + } + } + + const arrowToEdge = (dir: typeof direction): 'start' | 'end' => + dir === 'left' || dir === 'up' ? 'start' : 'end' + + const tokenEl = getClosestTokenEl(rng.startContainer) + if (!tokenEl) { + cfg.lastArrowDirectionRef.current = null + cfg.lastShiftRef.current = false + return + } + + const isDiverged = renderedLen(tokenEl) !== rawLen(tokenEl) + if (!isDiverged) { + cfg.lastArrowDirectionRef.current = null + cfg.lastShiftRef.current = false + return + } + + if (!isShift) { + if (!rng.collapsed) return + const edge = arrowToEdge(direction) + const target = snapEdgeForToken(tokenEl, edge) + if (target == null) return + cfg.suppressNextSelectionAdjustRef.current = true + setDomSelection(root, target) + } else { + const anchorNode = sel.anchorNode + const anchorOffset = sel.anchorOffset + const edge = arrowToEdge(direction) + const focusRaw = snapEdgeForToken(tokenEl, edge) + if (focusRaw == null || !anchorNode) return + const anchorRaw = getAbsoluteOffset(root, anchorNode, anchorOffset) + const startRaw = Math.min(anchorRaw, focusRaw) + const endRaw = Math.max(anchorRaw, focusRaw) + cfg.suppressNextSelectionAdjustRef.current = true + setDomSelection(root, startRaw, endRaw) + } + + cfg.lastArrowDirectionRef.current = null + cfg.lastShiftRef.current = false + }) + } + }, [cfg]) + + return { onSelect } +} diff --git a/src/inlay/hooks/use-selection.ts b/src/inlay/hooks/use-selection.ts new file mode 100644 index 0000000..ff0690b --- /dev/null +++ b/src/inlay/hooks/use-selection.ts @@ -0,0 +1,175 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { getAbsoluteOffset, setDomSelection } from '../internal/dom-utils' +import { snapGraphemeEnd, snapGraphemeStart } from '../internal/string-utils' + +export type SelectionState = { start: number; end: number } + +// Detect iOS Safari - includes modern iPads that report as "MacIntel" with touch +const isIOS = + typeof navigator !== 'undefined' && + (/iPad|iPhone|iPod/.test(navigator.userAgent) || + (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)) + +export function useSelection( + editorRef: React.RefObject, + value: string +) { + const [selection, setSelection] = useState({ + start: 0, + end: 0 + }) + const lastAnchorRectRef = useRef(new DOMRect(0, 0, 0, 0)) + const virtualAnchorRef = useRef({ + getBoundingClientRect: () => lastAnchorRectRef.current + }) + const suppressNextSelectionAdjustRef = useRef(false) + + // Update just the anchor rect from current selection + const updateAnchorRect = useCallback(() => { + const domSelection = window.getSelection() + if (!domSelection || !domSelection.rangeCount) return + + const range = domSelection.getRangeAt(0) + const clientRect = + range.getClientRects()[0] || range.getBoundingClientRect() + if (!(clientRect.x === 0 && clientRect.y === 0)) { + lastAnchorRectRef.current = new DOMRect( + clientRect.x, + clientRect.y, + clientRect.width, + clientRect.height + ) + } + }, []) + + const handleSelectionChange = useCallback(() => { + if (!editorRef.current) return + + // Avoid feedback loop after we programmatically set selection + if (suppressNextSelectionAdjustRef.current) { + suppressNextSelectionAdjustRef.current = false + } + + const domSelection = window.getSelection() + if (!domSelection || !domSelection.rangeCount) return + + const range = domSelection.getRangeAt(0) + + // Update anchor rect + updateAnchorRect() + + if (!editorRef.current.contains(range.startContainer)) { + return + } + + const start = getAbsoluteOffset( + editorRef.current, + range.startContainer, + range.startOffset + ) + const end = getAbsoluteOffset( + editorRef.current, + range.endContainer, + range.endOffset + ) + setSelection({ start, end }) + }, [editorRef, updateAnchorRect]) + + const setSelectionImperative = useCallback( + (start: number, end?: number) => { + const root = editorRef.current + if (!root) return + const s = value + const rawStart = Math.max(0, Math.min(start, s.length)) + const rawEnd = + end != null ? Math.max(0, Math.min(end, s.length)) : rawStart + const a = Math.min(rawStart, rawEnd) + const b = Math.max(rawStart, rawEnd) + const snappedStart = snapGraphemeStart(s, a) + const snappedEnd = end != null ? snapGraphemeEnd(s, b) : snappedStart + suppressNextSelectionAdjustRef.current = true + setDomSelection(root, snappedStart, snappedEnd) + handleSelectionChange() + }, + [editorRef, handleSelectionChange, value] + ) + + // iOS Safari fires selectionchange on document, not the element. + // This listener ensures we capture selection changes on mobile Safari. + useEffect(() => { + const handleDocumentSelectionChange = () => { + const root = editorRef.current + if (!root) return + + // Only process if our editor is focused or contains the selection + const domSelection = window.getSelection() + if (!domSelection || !domSelection.rangeCount) return + + const range = domSelection.getRangeAt(0) + if (!root.contains(range.startContainer)) return + + // Delegate to our existing handler + handleSelectionChange() + } + + document.addEventListener('selectionchange', handleDocumentSelectionChange) + return () => { + document.removeEventListener( + 'selectionchange', + handleDocumentSelectionChange + ) + } + }, [editorRef, handleSelectionChange]) + + // iOS: Update anchor rect after input/viewport changes (caret rect can be stale) + useEffect(() => { + if (!isIOS) return + + let rafId: number | null = null + const deferredUpdate = () => { + if (rafId !== null) cancelAnimationFrame(rafId) + rafId = requestAnimationFrame(() => { + rafId = null + updateAnchorRect() + }) + } + + const handleInput = (e: Event) => { + const root = editorRef.current + if (root?.contains(e.target as Node)) deferredUpdate() + } + + const handleViewportChange = () => { + const root = editorRef.current + if ( + root && + (document.activeElement === root || + root.contains(document.activeElement)) + ) { + deferredUpdate() + } + } + + document.addEventListener('input', handleInput, true) + const vv = window.visualViewport + vv?.addEventListener('resize', handleViewportChange) + vv?.addEventListener('scroll', handleViewportChange) + + return () => { + if (rafId !== null) cancelAnimationFrame(rafId) + document.removeEventListener('input', handleInput, true) + vv?.removeEventListener('resize', handleViewportChange) + vv?.removeEventListener('scroll', handleViewportChange) + } + }, [editorRef, updateAnchorRect]) + + return { + selection, + setSelection, + setSelectionImperative, + handleSelectionChange, + lastAnchorRectRef, + virtualAnchorRef, + suppressNextSelectionAdjustRef + } +} diff --git a/src/inlay/hooks/use-token-weaver.tsx b/src/inlay/hooks/use-token-weaver.tsx new file mode 100644 index 0000000..cb9ad52 --- /dev/null +++ b/src/inlay/hooks/use-token-weaver.tsx @@ -0,0 +1,187 @@ +import React, { + useCallback, + useLayoutEffect, + useMemo, + useRef, + useState +} from 'react' + +export type TokenState = { + isCollapsed: boolean + isAtStartOfToken: boolean + isAtEndOfToken: boolean +} + +export type TokenInfo = { + text: string + node: React.ReactElement + start: number + end: number +} + +export function useTokenWeaver( + value: string, + selection: { start: number; end: number }, + multiline: boolean, + children: React.ReactNode +) { + const [isRegistered, setIsRegistered] = useState(false) + const tokenRegistry = useRef<{ text: string; node: React.ReactElement }[]>([]) + const activeTokenRef = useRef(null) + + if (!isRegistered) tokenRegistry.current = [] + + const registerToken = useCallback( + (token: { text: string; node: React.ReactElement }) => { + tokenRegistry.current.push(token) + }, + [] + ) + + useLayoutEffect(() => { + setIsRegistered(false) + }, [children]) + + useLayoutEffect(() => { + if (!isRegistered) setIsRegistered(true) + }, [isRegistered]) + + const { weavedChildren, activeToken, activeTokenState } = useMemo(() => { + if (!isRegistered) { + return { + weavedChildren: null as React.ReactNode, + activeToken: activeTokenRef.current, + activeTokenState: null as TokenState | null + } + } + + const isStale = tokenRegistry.current.some( + (token) => value.indexOf(token.text) === -1 + ) + if (isStale) { + return { + weavedChildren: null as React.ReactNode, + activeToken: activeTokenRef.current, + activeTokenState: null as TokenState | null + } + } + + // Empty state: render zero-width space to maintain consistent caret height + if (value.length === 0) { + return { + weavedChildren: {'\u200B'}, + activeToken: null, + activeTokenState: null as TokenState | null + } + } + + // Robust sorting to handle duplicate tokens + const sortedTokens: { text: string; node: React.ReactElement }[] = [] + const tokenPool = [...tokenRegistry.current] + let searchIndex = 0 + while (searchIndex < value.length && tokenPool.length > 0) { + let foundToken = false + for (let i = 0; i < tokenPool.length; i++) { + const token = tokenPool[i] + if (value.startsWith(token.text, searchIndex)) { + sortedTokens.push(token) + tokenPool.splice(i, 1) + searchIndex += token.text.length + foundToken = true + break + } + } + if (!foundToken) searchIndex++ + } + + const result: React.ReactNode[] = [] + const map: TokenInfo[] = [] + let currentIndex = 0 + if (sortedTokens.length === 0) { + const nodes: React.ReactNode[] = [{value}] + if (multiline && value.endsWith('\n')) + nodes.push() + const tokenMap = [ + { text: value, node: , start: 0, end: value.length } + ] + const active = + tokenMap.find( + (t) => selection.start >= t.start && selection.end <= t.end + ) || null + return { + weavedChildren: nodes, + activeToken: active, + activeTokenState: null as TokenState | null + } + } + + for (const token of sortedTokens) { + const tokenStartIndex = value.indexOf(token.text, currentIndex) + if (tokenStartIndex === -1) continue + if (tokenStartIndex > currentIndex) { + const spacerText = value.slice(currentIndex, tokenStartIndex) + result.push({spacerText}) + map.push({ + text: spacerText, + node: , + start: currentIndex, + end: tokenStartIndex + }) + } + const tokenWithKey = React.cloneElement(token.node, { + key: `token-${currentIndex}` + }) + result.push(tokenWithKey) + map.push({ + text: token.text, + node: token.node, + start: tokenStartIndex, + end: tokenStartIndex + token.text.length + }) + currentIndex = tokenStartIndex + token.text.length + } + + if (currentIndex < value.length) { + const trailingSpacer = value.slice(currentIndex) + result.push({trailingSpacer}) + map.push({ + text: trailingSpacer, + node: , + start: currentIndex, + end: value.length + }) + } + + if (multiline && value.endsWith('\n')) { + result.push() + } + + const active = + map.find((t) => selection.start >= t.start && selection.end <= t.end) || + null + + let activeTokenState: TokenState | null = null + if (active) { + const isCollapsed = selection.start === selection.end + activeTokenState = { + isCollapsed, + isAtStartOfToken: isCollapsed && selection.start === active.start, + isAtEndOfToken: isCollapsed && selection.end === active.end + } + } + + activeTokenRef.current = active + + return { weavedChildren: result, activeToken: active, activeTokenState } + }, [value, isRegistered, selection, multiline]) + + return { + isRegistered, + setIsRegistered, + registerToken, + weavedChildren, + activeToken, + activeTokenRef, + activeTokenState + } +} diff --git a/src/inlay/hooks/use-touch-selection.ts b/src/inlay/hooks/use-touch-selection.ts new file mode 100644 index 0000000..eb294d8 --- /dev/null +++ b/src/inlay/hooks/use-touch-selection.ts @@ -0,0 +1,144 @@ +import { useCallback, useRef } from 'react' +import { getClosestTokenEl } from '../internal/dom-utils' + +export type TouchSelectionConfig = { + editorRef: React.RefObject + handleSelectionChange: () => void + isComposingRef: React.MutableRefObject +} + +/** + * Handles touch-based selection interactions on mobile devices. + * - Debounces rapid touch events + * - Snaps selection to token boundaries when touching inside tokens + * - Handles long-press detection for native selection mode + */ +export function useTouchSelection(cfg: TouchSelectionConfig) { + const touchStartPosRef = useRef<{ x: number; y: number } | null>(null) + const longPressTimeoutRef = useRef(null) + const isTouchActiveRef = useRef(false) + + const clearLongPressTimeout = useCallback(() => { + if (longPressTimeoutRef.current !== null) { + clearTimeout(longPressTimeoutRef.current) + longPressTimeoutRef.current = null + } + }, []) + + const onTouchStart = useCallback( + (event: React.TouchEvent) => { + if (cfg.isComposingRef.current) return + + const touch = event.touches[0] + if (!touch) return + + touchStartPosRef.current = { x: touch.clientX, y: touch.clientY } + isTouchActiveRef.current = true + + // Long-press detection (500ms) - triggers native selection mode + // We don't prevent this; we just use it to know user wants selection + clearLongPressTimeout() + longPressTimeoutRef.current = window.setTimeout(() => { + // After long press, let native selection handle it + // We'll sync state in touchend or selectionchange + longPressTimeoutRef.current = null + }, 500) + }, + [cfg.isComposingRef, clearLongPressTimeout] + ) + + const onTouchMove = useCallback( + (event: React.TouchEvent) => { + if (!isTouchActiveRef.current) return + + const touch = event.touches[0] + if (!touch || !touchStartPosRef.current) return + + // If user moves finger significantly, cancel long-press + const dx = Math.abs(touch.clientX - touchStartPosRef.current.x) + const dy = Math.abs(touch.clientY - touchStartPosRef.current.y) + if (dx > 10 || dy > 10) { + clearLongPressTimeout() + } + }, + [clearLongPressTimeout] + ) + + const onTouchEnd = useCallback( + (event: React.TouchEvent) => { + clearLongPressTimeout() + isTouchActiveRef.current = false + touchStartPosRef.current = null + + if (cfg.isComposingRef.current) return + + const root = cfg.editorRef.current + if (!root) return + + // Check if touch ended inside a token + const changedTouch = event.changedTouches[0] + if (!changedTouch) return + + const elementAtPoint = document.elementFromPoint( + changedTouch.clientX, + changedTouch.clientY + ) + + if (elementAtPoint && root.contains(elementAtPoint)) { + const tokenEl = getClosestTokenEl(elementAtPoint) + + if (tokenEl) { + // If inside a token, snap selection to token boundary + // Let the native selection happen first, then sync + requestAnimationFrame(() => { + cfg.handleSelectionChange() + }) + } else { + // Normal text area - just sync selection + requestAnimationFrame(() => { + cfg.handleSelectionChange() + }) + } + } + }, + [cfg, clearLongPressTimeout] + ) + + return { + onTouchStart, + onTouchMove, + onTouchEnd + } +} + +/** + * Utility to detect if the current device supports touch + */ +export function isTouchDevice(): boolean { + if (typeof window === 'undefined') return false + return ( + 'ontouchstart' in window || + navigator.maxTouchPoints > 0 || + // @ts-expect-error - msMaxTouchPoints is IE-specific + navigator.msMaxTouchPoints > 0 + ) +} + +/** + * Utility to detect iOS devices + */ +export function isIOS(): boolean { + if (typeof navigator === 'undefined') return false + return ( + /iPad|iPhone|iPod/.test(navigator.userAgent) || + (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1) + ) +} + +/** + * Utility to detect Android devices + */ +export function isAndroid(): boolean { + if (typeof navigator === 'undefined') return false + return /Android/.test(navigator.userAgent) +} diff --git a/src/inlay/hooks/use-virtual-keyboard.ts b/src/inlay/hooks/use-virtual-keyboard.ts new file mode 100644 index 0000000..8c5bb29 --- /dev/null +++ b/src/inlay/hooks/use-virtual-keyboard.ts @@ -0,0 +1,67 @@ +import { useEffect, useRef, useCallback } from 'react' + +export type VirtualKeyboardConfig = { + editorRef: React.RefObject + onVirtualKeyboardChange?: (open: boolean) => void +} + +/** + * Detects virtual keyboard visibility changes using the visualViewport API. + * Scrolls the editor into view when the keyboard opens. + */ +export function useVirtualKeyboard(cfg: VirtualKeyboardConfig) { + const isKeyboardOpenRef = useRef(false) + const initialViewportHeightRef = useRef(null) + + const handleViewportResize = useCallback(() => { + const vv = window.visualViewport + if (!vv) return + + // Store initial viewport height on first call + if (initialViewportHeightRef.current === null) { + initialViewportHeightRef.current = vv.height + } + + // Detect keyboard by comparing current viewport height to initial + // A significant reduction (>25%) typically indicates keyboard is open + const threshold = initialViewportHeightRef.current * 0.75 + const keyboardOpen = vv.height < threshold + + // Only fire callback on state change + if (keyboardOpen !== isKeyboardOpenRef.current) { + isKeyboardOpenRef.current = keyboardOpen + cfg.onVirtualKeyboardChange?.(keyboardOpen) + + // When keyboard opens, scroll editor into view + if (keyboardOpen && cfg.editorRef.current) { + // Use a small delay to let the viewport settle + requestAnimationFrame(() => { + cfg.editorRef.current?.scrollIntoView({ + behavior: 'smooth', + block: 'center' + }) + }) + } + } + }, [cfg]) + + useEffect(() => { + const vv = window.visualViewport + if (!vv) return + + // Initialize with current height + initialViewportHeightRef.current = vv.height + + vv.addEventListener('resize', handleViewportResize) + vv.addEventListener('scroll', handleViewportResize) + + return () => { + vv.removeEventListener('resize', handleViewportResize) + vv.removeEventListener('scroll', handleViewportResize) + } + }, [handleViewportResize]) + + return { + isKeyboardOpen: isKeyboardOpenRef.current + } +} diff --git a/src/inlay/index.ts b/src/inlay/index.ts new file mode 100644 index 0000000..9586135 --- /dev/null +++ b/src/inlay/index.ts @@ -0,0 +1,15 @@ +import { Root, Token, Portal } from './inlay' +import { StructuredInlay } from './structured/structured-inlay' + +// Re-export types that consumers may need +export type { InlayProps, InlayRef, TokenState } from './inlay' +export type { Plugin } from './structured/plugins/plugin' +export type { Matcher, Match } from './internal/string-utils' + +// Compound component export — use as Inlay.Root, Inlay.Token, Inlay.Portal, etc. +export const Inlay = { + Root, + Token, + Portal, + StructuredInlay +} diff --git a/src/inlay/inlay.tsx b/src/inlay/inlay.tsx new file mode 100644 index 0000000..75af325 --- /dev/null +++ b/src/inlay/inlay.tsx @@ -0,0 +1,653 @@ +import type { Scope } from '@radix-ui/react-context' +import { createContextScope } from '@radix-ui/react-context' +import * as Popover from '@radix-ui/react-popover' +import { useControllableState } from '@radix-ui/react-use-controllable-state' +import React, { + createContext, + useCallback, + useContext, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState +} from 'react' +import { + getAbsoluteOffset, + setDomSelection, + serializeRawFromDom as serializeFromDom +} from './internal/dom-utils' +import { useHistory } from './hooks/use-history' +import { useSelection } from './hooks/use-selection' +import { useTokenWeaver } from './hooks/use-token-weaver' +import { useComposition } from './hooks/use-composition' +import { useKeyHandlers } from './hooks/use-key-handlers' +import { usePlaceholderSync } from './hooks/use-placeholder-sync' +import { useSelectionSnap } from './hooks/use-selection-snap' +import { + PortalList, + PortalItem, + type PortalKeyboardHandler +} from './portal-list' +import { useClipboard } from './hooks/use-clipboard' +import { useVirtualKeyboard } from './hooks/use-virtual-keyboard' +import { useTouchSelection } from './hooks/use-touch-selection' + +export const COMPONENT_NAME = 'Inlay' +export const TEXT_COMPONENT_NAME = 'Inlay.Text' + +export type ScopedProps = P & { __scope?: Scope } +const [createInlayContext] = createContextScope(COMPONENT_NAME) + +const AncestorContext = createContext(null) +const PopoverControlContext = createContext<{ + setOpen: (open: boolean) => void +} | null>(null) + +// Context for portal keyboard navigation +type PortalKeyboardContextValue = { + setHandler: (handler: PortalKeyboardHandler | null) => void +} +const PortalKeyboardContext = createContext( + null +) +export { PortalKeyboardContext } + +// Context for anchor rect - allows Portal to position itself based on caret position +type AnchorRectContextValue = { + getRect: () => DOMRect +} +const AnchorRectContext = createContext(null) + +function annotateWithAncestor( + node: React.ReactNode, + currentAncestor: React.ReactElement | null +): React.ReactNode { + if (!React.isValidElement(node)) { + return node + } + + const element = node as React.ReactElement<{ children?: React.ReactNode }> + const nextAncestor = currentAncestor ?? element + + const children = element.props.children + const wrappedChildren = React.Children.map( + children, + (child: React.ReactNode) => annotateWithAncestor(child, nextAncestor) + ) + + return ( + + {React.cloneElement(element, undefined, wrappedChildren)} + + ) +} + +// --- Internal Context for token registration --- +type InternalInlayContextValue = { + registerToken: (token: { text: string; node: React.ReactElement }) => void +} +const [InternalInlayProvider, useInternalInlayContext] = + createInlayContext(COMPONENT_NAME) + +// --- Public Context for consumer state --- +type TokenInfo = { + text: string + node: React.ReactElement + start: number + end: number +} + +export type TokenState = { + isCollapsed: boolean + isAtStartOfToken: boolean + isAtEndOfToken: boolean +} + +type PublicInlayContextValue = { + value: string + selection: { start: number; end: number } + activeToken: TokenInfo | null + activeTokenState: TokenState | null + getSelectionRange: () => Range | null +} + +const [PublicInlayProvider, usePublicInlayContext] = + createInlayContext(COMPONENT_NAME) + +export type InlayProps = ScopedProps< + { + children: React.ReactNode + value?: string + defaultValue?: string + onChange?: (value: string) => void + placeholder?: React.ReactNode + multiline?: boolean + // Mobile input attributes + inputMode?: + | 'text' + | 'search' + | 'email' + | 'tel' + | 'url' + | 'numeric' + | 'decimal' + | 'none' + autoCapitalize?: + | 'off' + | 'none' + | 'on' + | 'sentences' + | 'words' + | 'characters' + autoCorrect?: 'on' | 'off' + enterKeyHint?: + | 'enter' + | 'done' + | 'go' + | 'next' + | 'previous' + | 'search' + | 'send' + onVirtualKeyboardChange?: (open: boolean) => void + } & Omit< + React.HTMLAttributes, + 'onChange' | 'defaultValue' | 'onKeyDown' + > & { + onKeyDown?: (event: React.KeyboardEvent) => boolean // Return true to stop propagation + } & { + getPopoverAnchorRect?: (root: HTMLDivElement | null) => DOMRect + } +> + +export type InlayRef = { + root: HTMLDivElement | null + setSelection: (start: number, end?: number) => void +} + +const Inlay = React.forwardRef((props, forwardedRef) => { + const { + __scope, + children: allChildren, + value: valueProp, + defaultValue, + onChange, + onKeyDown: onKeyDownProp, + placeholder, + multiline = true, + getPopoverAnchorRect, + // Mobile input attributes + inputMode = 'text', + autoCapitalize = 'sentences', + autoCorrect, // Omit by default - let iOS use native behavior (single-word suggestions) + enterKeyHint, + onVirtualKeyboardChange, + ...inlayProps + } = props + + const popoverPortal = React.useMemo( + () => + React.Children.toArray(allChildren).find( + (child) => React.isValidElement(child) && child.type === Portal + ), + [allChildren] + ) + + const children = React.useMemo( + () => + React.Children.toArray(allChildren).filter( + (child) => !React.isValidElement(child) || child.type !== Portal + ), + [allChildren] + ) + + const [isPopoverOpen, setIsPopoverOpen] = useState(false) + + // Portal keyboard handler - allows Portal.List to intercept keyboard events + const portalKeyboardHandlerRef = useRef(null) + const portalKeyboardContext = useMemo( + () => ({ + setHandler: (handler: PortalKeyboardHandler | null) => { + portalKeyboardHandlerRef.current = handler + } + }), + [] + ) + const popoverControl = useMemo(() => ({ setOpen: setIsPopoverOpen }), []) + const [value, setValue] = useControllableState({ + prop: valueProp, + defaultProp: defaultValue || '', + onChange + }) + // Keep a ref to the current value for synchronous access in event handlers + const valueRef = useRef(value) + valueRef.current = value + const editorRef = useRef(null) + const placeholderRef = useRef(null) + const { + selection, + setSelection, + setSelectionImperative, + handleSelectionChange, + lastAnchorRectRef, + suppressNextSelectionAdjustRef + } = useSelection(editorRef, value) + const virtualAnchorRef = useRef({ + getBoundingClientRect: () => lastAnchorRectRef.current + }) + const customAnchorRef = useRef({ + getBoundingClientRect: () => new DOMRect(0, 0, 0, 0) + }) + useLayoutEffect(() => { + if (!getPopoverAnchorRect) return + customAnchorRef.current.getBoundingClientRect = () => { + try { + const root = editorRef.current + const rect = getPopoverAnchorRect(root) + return rect ?? new DOMRect(0, 0, 0, 0) + } catch { + return new DOMRect(0, 0, 0, 0) + } + } + }, [getPopoverAnchorRect]) + const chosenAnchorRef = getPopoverAnchorRect + ? customAnchorRef + : virtualAnchorRef + const { + isRegistered, + registerToken, + weavedChildren, + activeToken, + activeTokenRef, + activeTokenState + } = useTokenWeaver(value, selection, multiline, children) + + const lastArrowDirectionRef = useRef<'left' | 'right' | 'up' | 'down' | null>( + null + ) + const lastShiftRef = useRef(false) + + const getCurrentSnapshot = useCallback(() => { + const root = editorRef.current + if (root) { + const sel = window.getSelection() + if (sel && sel.rangeCount > 0) { + const r = sel.getRangeAt(0) + const start = getAbsoluteOffset(root, r.startContainer, r.startOffset) + const end = getAbsoluteOffset(root, r.endContainer, r.endOffset) + return { value, selection: { start, end } } + } + } + return { value, selection } + }, [value, selection]) + const applySnapshot = useCallback( + (snap: { value: string; selection: { start: number; end: number } }) => { + setValue(() => snap.value) + requestAnimationFrame(() => { + const root = editorRef.current + if (root) { + suppressNextSelectionAdjustRef.current = true + setDomSelection(root, snap.selection.start, snap.selection.end) + } + }) + }, + [setValue] + ) + const { pushUndoSnapshot, beginEditSession, endEditSession, undo, redo } = + useHistory(getCurrentSnapshot, applySnapshot, 200) + + const serializeRawFromDom = useCallback((): string => { + const root = editorRef.current + if (!root) return value + return serializeFromDom(root) + }, [value]) + + const { + isComposing, + isComposingRef, + suppressNextBeforeInputRef, + suppressNextKeydownCommitRef, + compositionCommitKeyRef, + compositionJustEndedAtRef, + contentKey, + onCompositionStart, + onCompositionUpdate, + onCompositionEnd + } = useComposition( + editorRef, + serializeRawFromDom, + handleSelectionChange, + setValue, + () => value + ) + + usePlaceholderSync(editorRef, placeholderRef, [value, placeholder]) + + // weaving moved + const getSelectionRange = useCallback(() => { + const domSelection = window.getSelection() + if (domSelection && domSelection.rangeCount > 0) { + return domSelection.getRangeAt(0) + } + return null + }, []) + const { onBeforeInput, onKeyDown } = useKeyHandlers({ + editorRef, + contentKey, + multiline, + onKeyDownProp, + beginEditSession, + endEditSession, + pushUndoSnapshot, + undo, + redo, + isComposingRef, + compositionCommitKeyRef, + suppressNextBeforeInputRef, + suppressNextKeydownCommitRef, + compositionJustEndedAtRef, + setValue, + valueRef, + getActiveToken: () => + activeTokenRef.current + ? { + start: activeTokenRef.current.start, + end: activeTokenRef.current.end + } + : null + }) + const { onSelect } = useSelectionSnap({ + editorRef, + setSelection, + lastAnchorRectRef, + suppressNextSelectionAdjustRef, + lastArrowDirectionRef, + lastShiftRef, + isComposingRef + }) + const { onCopy, onCut, onPaste } = useClipboard({ + editorRef, + getValue: () => value, + setValue, + pushUndoSnapshot, + isComposingRef + }) + useVirtualKeyboard({ + editorRef, + onVirtualKeyboardChange + }) + const { onTouchStart, onTouchMove, onTouchEnd } = useTouchSelection({ + editorRef, + handleSelectionChange, + isComposingRef + }) + // Wrap onKeyDown to route through portal keyboard handler first + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + // If portal has a keyboard handler registered and it handles the event, stop + if ( + isPopoverOpen && + portalKeyboardHandlerRef.current && + portalKeyboardHandlerRef.current(event) + ) { + return + } + // Otherwise, use the default key handler + onKeyDown(event) + }, + [isPopoverOpen, onKeyDown] + ) + useImperativeHandle(forwardedRef, () => ({ + root: editorRef.current, + setSelection: (start: number, end?: number) => { + if (editorRef.current) { + setSelectionImperative(start, end) + } + } + })) + return ( + + + + + + {/* First pass: renders children to populate registry */} + + {React.Children.map(children, (child) => + annotateWithAncestor(child, null) + )} + + + {/* Second pass: renders weaved content */} + + + {isRegistered ? weavedChildren : children} + + {value.length === 0 && placeholder && !isComposing && ( + + {placeholder} + + )} + + ({ getRect: () => lastAnchorRectRef.current }), + [] + )} + > + + {popoverPortal} + + + + + + + ) +}) + +type PortalRenderProps = (context: PublicInlayContextValue) => React.ReactNode + +type PortalProps = ScopedProps< + Omit & { + children: PortalRenderProps + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: `data-${string}`]: any + } +> + +const Portal = (props: PortalProps) => { + const { + __scope, + children, + side = 'bottom', + sideOffset = 0, + alignOffset = 0, + ...contentProps + } = props + const context = usePublicInlayContext(COMPONENT_NAME, __scope) + const popoverControl = useContext(PopoverControlContext) + const keyboardContext = useContext(PortalKeyboardContext) + const anchorRectContext = useContext(AnchorRectContext) + const contentRef = useRef(null) + + const content = children(context) + const hasContent = !!content + + // Track last content to avoid flashing during timing gaps between render and effects + const lastContentRef = useRef(null) + const closeTimeoutRef = useRef(null) + + if (hasContent) { + lastContentRef.current = content + if (closeTimeoutRef.current !== null) { + clearTimeout(closeTimeoutRef.current) + closeTimeoutRef.current = null + } + } + + useLayoutEffect(() => { + if (hasContent) { + popoverControl?.setOpen(true) + } else { + // Defer close to allow effects to catch up + if (closeTimeoutRef.current !== null) + clearTimeout(closeTimeoutRef.current) + closeTimeoutRef.current = window.setTimeout(() => { + closeTimeoutRef.current = null + popoverControl?.setOpen(false) + lastContentRef.current = null + }, 50) + } + return () => { + if (closeTimeoutRef.current !== null) { + clearTimeout(closeTimeoutRef.current) + closeTimeoutRef.current = null + } + } + }, [hasContent, popoverControl]) + + // Position manually via DOM to follow caret on iOS and avoid re-render loops + useLayoutEffect(() => { + const el = contentRef.current + if (!el || !anchorRectContext) return + + const rect = anchorRectContext.getRect() + if (rect.x === 0 && rect.y === 0 && rect.width === 0 && rect.height === 0) + return + + let top: number, left: number + if (side === 'bottom') { + top = rect.bottom + sideOffset + left = rect.left + alignOffset + } else if (side === 'top') { + top = rect.top - el.offsetHeight - sideOffset + left = rect.left + alignOffset + } else if (side === 'left') { + top = rect.top + alignOffset + left = rect.left - el.offsetWidth - sideOffset + } else { + top = rect.top + alignOffset + left = rect.right + sideOffset + } + + el.style.top = `${top}px` + el.style.left = `${left}px` + el.style.visibility = 'visible' + }) + + const displayContent = content || lastContentRef.current + + if (!displayContent) return null + + return ( + + e.preventDefault()} + {...contentProps} + style={{ + ...contentProps.style, + position: 'fixed', + top: 0, + left: 0, + zIndex: 50, + visibility: 'hidden' // Made visible by useLayoutEffect after positioning + }} + > + + {displayContent} + + + + ) +} +Portal.displayName = 'Inlay.Portal' + +// Attach List and Item to Portal for compound component API +const PortalWithList = Object.assign(Portal, { + List: PortalList, + Item: PortalItem +}) + +type TokenProps = ScopedProps<{ + value: string + children: React.ReactNode +}> & + React.HTMLAttributes + +const Token = React.forwardRef((props, ref) => { + const { __scope, value, children, ...textProps } = props + const internalContext = useInternalInlayContext(TEXT_COMPONENT_NAME, __scope) + const ancestor = useContext(AncestorContext) + + const nodeToRegister = ( + + {children} + + ) + + // Register the token with its text value and the React node itself. + // If an ancestor is found in context, register that instead of the immediate span. + internalContext.registerToken({ + text: value, + node: ancestor || nodeToRegister + }) + + return nodeToRegister +}) + +Token.displayName = TEXT_COMPONENT_NAME + +export { Inlay as Root, Token, PortalWithList as Portal } diff --git a/src/inlay/internal/dom-utils.test.ts b/src/inlay/internal/dom-utils.test.ts new file mode 100644 index 0000000..38dcedc --- /dev/null +++ b/src/inlay/internal/dom-utils.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'vitest' +import { getAbsoluteOffset, getTextNodeAtOffset } from './dom-utils' + +// Helper to build a minimal editor DOM tree +function createEditor(html: string): HTMLElement { + const root = document.createElement('div') + root.innerHTML = html + return root +} + +describe('dom-utils', () => { + it('getAbsoluteOffset on plain text accumulates correctly', () => { + const root = createEditor('hello world') + const firstText = root.querySelector('span')!.firstChild as Text + const secondText = root.querySelectorAll('span')[1].firstChild as Text + + expect(getAbsoluteOffset(root, firstText, 0)).toBe(0) // h| + expect(getAbsoluteOffset(root, firstText, 5)).toBe(5) // hello| + expect(getAbsoluteOffset(root, secondText, 1)).toBe(6) // hello␠| + }) + + it('getTextNodeAtOffset on plain text maps back to nodes/offsets', () => { + const root = createEditor('hello world') + const [n0, o0] = getTextNodeAtOffset(root, 0) + expect((n0 as Text).data.slice(0, o0)).toBe('') + + const [n5, o5] = getTextNodeAtOffset(root, 5) + expect((n5 as Text).data.slice(0, o5)).toBe('hello') + + const [n7, o7] = getTextNodeAtOffset(root, 7) + expect((n7 as Text).data.slice(0, o7)).toBe(' w') + }) + + it('diverged token: absolute offset snaps to token edges when inside token', () => { + const root = createEditor( + 'hi ' + + 'Alex' + + '!' + ) + const token = root.querySelector('[data-token-text]') as HTMLElement + const tokenInner = token.querySelector('span')!.firstChild as Text // "Alex" + + // Inside first char of rendered token should snap to start of raw token (offset at start of token = 3) + expect(getAbsoluteOffset(root, tokenInner, 1)).toBe(3) + + // Inside last char of rendered token should snap to end of raw token (raw len = 5, base start = 3, so end = 8) + expect(getAbsoluteOffset(root, tokenInner, 4)).toBe(8) + }) + + it('round-trip sweep over all offsets in mixed content (snap inside diverged token)', () => { + const root = createEditor( + 'X' + + 'Alex' + + 'Y' + ) + // raw: "X@alexY" -> token starts at 1, rawLen=5, tokenEnd=6, total len=7 + const tokenStart = 1 + const tokenRawLen = 5 + const tokenEnd = tokenStart + tokenRawLen + for (let i = 0; i <= 7; i++) { + const [n, o] = getTextNodeAtOffset(root, i) + const roundTrip = getAbsoluteOffset(root, n as Node, o) + const expected = + i < tokenStart || i > tokenEnd + ? i + : i - tokenStart <= tokenRawLen / 2 + ? tokenStart + : tokenEnd + expect(roundTrip).toBe(expected) + } + }) + + it('fallbacks: offsets past end clamp to last text node end', () => { + const root = createEditor('abc') + const [n, o] = getTextNodeAtOffset(root, 999) + expect((n as Text).data.length).toBe(3) + expect(o).toBe(3) + }) +}) diff --git a/src/inlay/internal/dom-utils.ts b/src/inlay/internal/dom-utils.ts new file mode 100644 index 0000000..8146bde --- /dev/null +++ b/src/inlay/internal/dom-utils.ts @@ -0,0 +1,381 @@ +const isTokenElement = (el: Element): boolean => + el.hasAttribute('data-token-text') + +const getTokenRawLength = (el: Element): number => + (el.getAttribute('data-token-text') || '').length + +const getRenderedTextLength = (el: Element): number => { + const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null) + let total = 0 + let n: Node | null + while ((n = walker.nextNode())) total += (n.textContent || '').length + return total +} + +const findFirstTextNode = (el: Element): ChildNode | null => { + const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null) + return walker.nextNode() as ChildNode | null +} + +const findLastTextNode = (el: Element): ChildNode | null => { + const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null) + let last: Node | null = null + let n: Node | null + while ((n = walker.nextNode())) last = n + return last as ChildNode | null +} + +export function getClosestTokenEl(node: Node | null): HTMLElement | null { + let curr: Node | null = node + while (curr) { + if (curr.nodeType === Node.ELEMENT_NODE) { + const el = curr as HTMLElement + if (el.hasAttribute('data-token-text')) return el + } + curr = curr.parentNode + } + return null +} + +export function getTokenRawRange( + root: HTMLElement, + tokenEl: HTMLElement +): { start: number; end: number } | null { + const rawText = tokenEl.getAttribute('data-token-text') + if (!rawText) return null + + const walker = document.createTreeWalker(tokenEl, NodeFilter.SHOW_TEXT, null) + const firstText = walker.nextNode() + if (!firstText) return null + + const start = getAbsoluteOffset(root, firstText, 0) + return { start, end: start + rawText.length } +} + +export const getTextNodeAtOffset = ( + root: HTMLElement, + offset: number +): [ChildNode | null, number] => { + const traverse = ( + container: Node, + remaining: { value: number } + ): [ChildNode | null, number] | null => { + const children = container.childNodes + for (let i = 0; i < children.length; i++) { + const child = children[i] + + if (child.nodeType === Node.ELEMENT_NODE) { + const el = child as Element + + // represents a newline character - count as 1 + // If we're at the position right after a , return the next text node + if (el.tagName === 'BR') { + if (remaining.value === 0) { + // Position is right at the - find next text node + // Look for the next sibling text node or continue traversal + for (let j = i + 1; j < children.length; j++) { + const next = children[j] + if (next.nodeType === Node.TEXT_NODE) { + return [next as ChildNode, 0] + } + if (next.nodeType === Node.ELEMENT_NODE) { + const nextEl = next as Element + const first = findFirstTextNode(nextEl) + if (first) return [first, 0] + } + } + // No next text node found - return null + return null + } + remaining.value -= 1 + continue + } + + if (isTokenElement(el)) { + const rawLen = getTokenRawLength(el) + const renderedLen = getRenderedTextLength(el) + const isDiverged = renderedLen !== rawLen + + if (isDiverged) { + if (remaining.value <= rawLen) { + const first = findFirstTextNode(el) + const last = findLastTextNode(el) + if (!first && !last) return null + + const snapToStart = remaining.value <= rawLen / 2 + if (snapToStart) { + if (first) return [first, 0] + } else { + if (last) return [last, (last.textContent || '').length] + } + if (first) return [first, 0] + if (last) return [last, (last.textContent || '').length] + return null + } + remaining.value -= rawLen + continue + } + + const found = traverse(el, remaining) + if (found) return found + continue + } + + const found = traverse(el, remaining) + if (found) return found + continue + } + + if (child.nodeType === Node.TEXT_NODE) { + const text = child.textContent || '' + if (remaining.value <= text.length) { + return [child as ChildNode, remaining.value] + } + remaining.value -= text.length + } + } + + return null + } + + const result = traverse(root, { value: Math.max(0, offset) }) + if (result) return result + + // Fallback: place at end of last text node + const allTokenTextNodes = Array.from( + root.querySelectorAll('[data-token-text]') + ) + .map((el) => findLastTextNode(el)) + .filter(Boolean) as ChildNode[] + + if (allTokenTextNodes.length > 0) { + const lastNode = allTokenTextNodes[allTokenTextNodes.length - 1] + return [lastNode, (lastNode.textContent || '').length] + } + + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null) + let last: Node | null = null + let n: Node | null + while ((n = walker.nextNode())) last = n + if (last) return [last as ChildNode, (last.textContent || '').length] + + return [null, 0] +} + +export const getAbsoluteOffset = ( + root: HTMLElement, + node: Node, + offset: number +): number => { + const getOffsetWithinElement = ( + el: Element, + target: Node, + targetOffset: number + ): number => { + const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null) + let total = 0 + let n: Node | null + while ((n = walker.nextNode())) { + if (n === target) { + total += Math.min(targetOffset, (n.textContent || '').length) + break + } + total += (n.textContent || '').length + } + return total + } + + // Handle element node containers (e.g. Firefox Ctrl+a sets selection on element, not text node) + if (node.nodeType === Node.ELEMENT_NODE) { + const el = node as Element + const children = el.childNodes + + let total = 0 + const measure = (n: Node): number => { + if (n.nodeType === Node.TEXT_NODE) { + return (n.textContent || '').length + } + if (n.nodeType === Node.ELEMENT_NODE) { + const e = n as Element + // represents a newline character + if (e.tagName === 'BR') return 1 + if (isTokenElement(e)) return getTokenRawLength(e) + let sum = 0 + for (let i = 0; i < e.childNodes.length; i++) { + sum += measure(e.childNodes[i]) + } + return sum + } + return 0 + } + + for (let i = 0; i < offset && i < children.length; i++) { + total += measure(children[i]) + } + return total + } + + const traverse = (container: Node, acc: { value: number }): number | null => { + const children = container.childNodes + for (let i = 0; i < children.length; i++) { + const child = children[i] + + if (child.nodeType === Node.ELEMENT_NODE) { + const el = child as Element + + if (isTokenElement(el)) { + const rawLen = getTokenRawLength(el) + const renderedLen = getRenderedTextLength(el) + const isDiverged = renderedLen !== rawLen + + if (isDiverged) { + if (el.contains(node)) { + const withinRendered = getOffsetWithinElement(el, node, offset) + const snapToStart = withinRendered <= renderedLen / 2 + return acc.value + (snapToStart ? 0 : rawLen) + } + acc.value += rawLen + continue + } + + if (el.contains(node)) { + const inner = traverse(el, acc) + if (inner != null) return inner + } else { + acc.value += renderedLen + } + continue + } + + // represents a newline character - count as 1 and continue + if (el.tagName === 'BR') { + acc.value += 1 + continue + } + + if (el.contains(node)) { + const inner = traverse(el, acc) + if (inner != null) return inner + } else { + const measureSubtree = (e: Element): number => { + // represents a newline character + if (e.tagName === 'BR') return 1 + let total = 0 + const cn = e.childNodes + for (let j = 0; j < cn.length; j++) { + const c = cn[j] + if (c.nodeType === Node.TEXT_NODE) { + total += (c.textContent || '').length + } else if (c.nodeType === Node.ELEMENT_NODE) { + const ce = c as Element + if (isTokenElement(ce)) { + const rl = getTokenRawLength(ce) + const rr = getRenderedTextLength(ce) + total += rr === rl ? rr : rl + } else { + total += measureSubtree(ce) + } + } + } + return total + } + acc.value += measureSubtree(el) + } + continue + } + + if (child.nodeType === Node.TEXT_NODE) { + if (child === node) { + return acc.value + Math.min(offset, (child.textContent || '').length) + } else { + acc.value += (child.textContent || '').length + } + } + } + return null + } + + const result = traverse(root, { value: 0 }) + if (result != null) return result + + // Fallback: total length + let total = 0 + const stack: Node[] = [root] + while (stack.length) { + const n = stack.pop()! + if (n.nodeType === Node.ELEMENT_NODE) { + const el = n as Element + if (isTokenElement(el)) { + const rl = getTokenRawLength(el) + const rr = getRenderedTextLength(el) + total += rr === rl ? rr : rl + continue + } + const cn = el.childNodes + for (let i = cn.length - 1; i >= 0; i--) stack.push(cn[i]) + } else if (n.nodeType === Node.TEXT_NODE) { + total += (n.textContent || '').length + } + } + return total +} + +export const setDomSelection = ( + root: HTMLElement, + start: number, + end?: number +) => { + const [startNode, startOffset] = getTextNodeAtOffset(root, start) + const [endNode, endOffset] = end + ? getTextNodeAtOffset(root, end) + : [startNode, startOffset] + + if (startNode && endNode) { + // Guard against NotFoundError on iOS: nodes may have been removed + // by React re-renders between scheduling and execution + if (!startNode.isConnected || !endNode.isConnected) { + return + } + + const range = document.createRange() + range.setStart(startNode, startOffset) + range.setEnd(endNode, endOffset) + const selection = window.getSelection() + if (selection) { + selection.removeAllRanges() + selection.addRange(range) + } + } +} + +export const serializeRawFromDom = (root: HTMLElement): string => { + const clone = root.cloneNode(true) as HTMLElement + const tokenEls = clone.querySelectorAll('[data-token-text]') + tokenEls.forEach((el) => { + const raw = el.getAttribute('data-token-text') || '' + const renderedLen = getRenderedTextLength(el) + if (renderedLen !== raw.length) { + ;(el as HTMLElement).textContent = raw + } + }) + let result = (clone as HTMLElement).innerText + // iOS contentEditable often has trailing newlines, zero-width spaces, or other + // invisible characters when "empty". Strip these from the end. + // Also handle the case where the entire content is just whitespace/invisible chars. + result = result.replace(/[\u200B\uFEFF]+/g, '') // Remove zero-width spaces throughout + + // Handle empty content (just newlines or whitespace) + if (result === '\n' || result.trim() === '') { + return '' + } + + // ContentEditable often adds ONE extra trailing newline. Remove it if present. + // But preserve intentional newlines in the content (e.g., "hello\nworld\n" → "hello\nworld") + // This is tricky: we can't know if the final newline is intentional or added by browser. + // Heuristic: if it ends with double newline, remove one. Single trailing newline stays. + if (result.endsWith('\n\n')) { + result = result.slice(0, -1) + } + + return result +} diff --git a/src/inlay/internal/string-utils.test.ts b/src/inlay/internal/string-utils.test.ts new file mode 100644 index 0000000..b323ae4 --- /dev/null +++ b/src/inlay/internal/string-utils.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect } from 'vitest' +import { + createRegexMatcher, + filterMatchesByMatcher, + groupMatchesByMatcher, + scan +} from './string-utils' + +describe('string-utils', () => { + it('createRegexMatcher + scan: finds and sorts matches with names', () => { + const mention = createRegexMatcher<{ mention: string }, 'mention'>( + 'mention', + { + regex: /@\w+/g, + transform: (m) => ({ mention: m[0] }) + } + ) + + const text = 'hi @alex and @bob' + const matches = scan(text, [mention]) + + expect(matches.map((m) => m.raw)).toEqual(['@alex', '@bob']) + expect(matches.map((m) => m.start)).toEqual([3, 13]) + expect(matches.every((m) => m.matcher === 'mention')).toBe(true) + }) + + it('createRegexMatcher resets lastIndex between scans', () => { + const word = createRegexMatcher<{ w: string }, 'word'>('word', { + regex: /\w+/g, + transform: (m) => ({ w: m[0] }) + }) + + const a = scan('alpha beta', [word]) + const b = scan('gamma', [word]) + + expect(a.map((m) => m.raw)).toEqual(['alpha', 'beta']) + expect(b.map((m) => m.raw)).toEqual(['gamma']) + }) + + it('filterMatchesByMatcher returns only requested matcher type', () => { + const mention = createRegexMatcher<{ mention: string }, 'mention'>( + 'mention', + { regex: /@\w+/g, transform: (m) => ({ mention: m[0] }) } + ) + const hash = createRegexMatcher<{ tag: string }, 'hashtag'>('hashtag', { + regex: /#\w+/g, + transform: (m) => ({ tag: m[0] }) + }) + + const matches = scan('say @alex and #music', [mention, hash]) + + const onlyMentions = filterMatchesByMatcher(matches, 'mention') + expect(onlyMentions.map((m) => m.raw)).toEqual(['@alex']) + }) + + it('groupMatchesByMatcher groups by matcher name', () => { + const m1 = createRegexMatcher<{ v: string }, 'a'>('a', { + regex: /a/g, + transform: (m) => ({ v: m[0] }) + }) + const m2 = createRegexMatcher<{ v: string }, 'b'>('b', { + regex: /b/g, + transform: (m) => ({ v: m[0] }) + }) + + const matches = scan('ababa', [m1, m2]) + const grouped = groupMatchesByMatcher(matches) + + expect(grouped.a?.map((x) => x.raw)).toEqual(['a', 'a', 'a']) + expect(grouped.b?.map((x) => x.raw)).toEqual(['b', 'b']) + }) + + it('createRegexMatcher throws when regex lacks global flag', () => { + expect(() => + // @ts-expect-error intentional invalid regex config for test + createRegexMatcher('bad', { regex: /@\w+/, transform: (m) => m[0] }) + ).toThrow() + }) + + it('createRegexMatcher throws when regex uses sticky flag', () => { + expect(() => + // @ts-expect-error intentional invalid regex config for test + createRegexMatcher('bad', { regex: /@\w+/gy, transform: (m) => m[0] }) + ).toThrow() + }) + + it('scan filters overlapping matches - longer match wins', () => { + const atWord = createRegexMatcher<{ v: string }, 'at'>('at', { + regex: /@\w+/g, + transform: (m) => ({ v: m[0] }) + }) + const word = createRegexMatcher<{ v: string }, 'word'>('word', { + regex: /\w+/g, + transform: (m) => ({ v: m[0] }) + }) + + // @alex (5 chars starting at 0) vs alex (4 chars starting at 1) + // They overlap, and @alex is longer, so only @alex should be returned + const matches = scan('@alex', [atWord, word]) + expect(matches.map((m) => `${m.matcher}:${m.raw}`)).toEqual(['at:@alex']) + }) + + it('scan: longest match wins when two matchers start at same position', () => { + const short = createRegexMatcher<{ v: string }, 'short'>('short', { + regex: /@alice/g, + transform: (m) => ({ v: m[0] }) + }) + const long = createRegexMatcher<{ v: string }, 'long'>('long', { + regex: /@alice_vip/g, + transform: (m) => ({ v: m[0] }) + }) + + // Both match starting at position 0, but long is 10 chars vs short's 6 + const matches = scan('@alice_vip', [short, long]) + expect(matches.map((m) => `${m.matcher}:${m.raw}`)).toEqual([ + 'long:@alice_vip' + ]) + }) + + it('scan: first matcher wins when matches are exact same range', () => { + const pluginA = createRegexMatcher<{ v: string }, 'a'>('a', { + regex: /@test/g, + transform: (m) => ({ v: m[0] }) + }) + const pluginB = createRegexMatcher<{ v: string }, 'b'>('b', { + regex: /@test/g, + transform: (m) => ({ v: m[0] }) + }) + + // Both match @test at exact same range - first matcher (a) should win + const matches = scan('@test', [pluginA, pluginB]) + expect(matches.map((m) => `${m.matcher}:${m.raw}`)).toEqual(['a:@test']) + }) + + it('scan preserves non-overlapping matches from multiple matchers', () => { + const mention = createRegexMatcher<{ v: string }, 'mention'>('mention', { + regex: /@\w+/g, + transform: (m) => ({ v: m[0] }) + }) + const hashtag = createRegexMatcher<{ v: string }, 'hashtag'>('hashtag', { + regex: /#\w+/g, + transform: (m) => ({ v: m[0] }) + }) + + // @bob and #music don't overlap, both should be kept + const matches = scan('@bob loves #music', [mention, hashtag]) + expect(matches.map((m) => `${m.matcher}:${m.raw}`)).toEqual([ + 'mention:@bob', + 'hashtag:#music' + ]) + }) +}) diff --git a/src/inlay/internal/string-utils.ts b/src/inlay/internal/string-utils.ts new file mode 100644 index 0000000..b5de702 --- /dev/null +++ b/src/inlay/internal/string-utils.ts @@ -0,0 +1,248 @@ +/** + * Represents a single matched token within a larger string. + * @template T The type of the structured data associated with the match. + * @template N The literal string type of the matcher's name. + */ +export type Match = { + /** The raw matched text span from the original string. */ + raw: string + /** The start index of the match in the full string. */ + start: number + /** The end index of the match in the full string. */ + end: number + /** A structured object with parsed values defined by the matcher. */ + data: T + /** The unique `name` of the matcher that produced this match. */ + matcher: N +} + +/** + * A utility type that constructs a `Match` type from a `Matcher` type. + * It infers the data type `T` and the literal name `N` from the matcher. + * This is key to creating a discriminated union for the `scan` function's return type. + */ +export type MatchFromMatcher = + M extends Matcher ? Match : never + +type MatchData = Omit, 'matcher'> + +/** + * Defines a configuration for finding a specific kind of token. + * It's a generic interface that can be implemented using various strategies (e.g., regex, prefix, custom logic). + * @template T The type of the structured data this matcher will produce. + * @template N The literal string type of the matcher's name. + */ +export type Matcher = { + /** A unique name for the matcher, used to identify which matcher found a token. */ + name: N + /** + * The core matching logic. It scans the input text and returns all found tokens. + * @param text The full string to scan. + * @returns An array of match data, without the `matcher` property. + */ + match: (text: string) => MatchData[] +} + +/** + * Scans a string for tokens using a set of configured matchers. + * + * This function is the core of the token matching utility. It orchestrates the matching process + * by running all provided matchers over the input text and consolidating the results. + * Each matcher can produce a different type of structured data, and the return type will + * be a discriminated union of all possible `Match` types, inferred from the provided matchers. + * + * @param text The full string to scan. + * @param matchers An array of `Matcher` objects to run on the string. The use of `` ensures + * that the literal types of matcher names are preserved for type inference. + * @returns An array of all `Match` objects found, sorted by their start index. + * The type of each element is a discriminated union of all possible `Match` types. + * Consumers can switch on `match.matcher` to safely access `match.data`. + */ +export function scan[]>( + text: string, + matchers: M +): MatchFromMatcher[] { + const allMatches = matchers.flatMap((matcher) => + matcher.match(text).map((m) => ({ ...m, matcher: matcher.name })) + ) as MatchFromMatcher[] + + // Sort by start position ascending, then by length descending (longest first at same position) + // This ensures the longest match at each position is considered first + allMatches.sort((a, b) => { + if (a.start !== b.start) return a.start - b.start + return b.end - b.start - (a.end - a.start) + }) + + // Greedy algorithm: accept non-overlapping matches, preferring longer ones + // When two matches overlap, the one starting earlier (or longer at same position) wins + const accepted: typeof allMatches = [] + let lastEnd = -1 + for (const match of allMatches) { + if (match.start >= lastEnd) { + accepted.push(match) + lastEnd = match.end + } + } + + return accepted +} + +/** + * Options for creating a regex-based matcher. + * @template T The type of the structured data the matcher will produce. + */ +type RegexMatcherOptions = { + /** The regular expression to match against. Must include the 'g' flag for global matching. */ + regex: RegExp + /** A function to transform the raw regex match array into the desired structured data. */ + transform: (match: RegExpExecArray) => T +} + +/** + * A factory function to create a `Matcher` that uses a regular expression. + * This simplifies the creation of matchers for patterns that can be described with regex. + * + * @template T The type of the structured data the created matcher will produce. + * @param name The unique name for this matcher. + * @param options The configuration for the regex matcher. + * @returns A new `Matcher` object. + */ +export function createRegexMatcher( + name: N, + options: RegexMatcherOptions +): Matcher { + if (!options.regex.global) { + throw new Error('createRegexMatcher requires a global regex (e.g. /.../g)') + } + if (options.regex.sticky) { + throw new Error( + `createRegexMatcher does not support the sticky 'y' flag, as it interferes with scanning.` + ) + } + + return { + name, + match: (text: string): MatchData[] => { + const matches: MatchData[] = [] + let regexMatch: RegExpExecArray | null + + // Important: Reset lastIndex on the regex before each new scan. + options.regex.lastIndex = 0 + + while ((regexMatch = options.regex.exec(text)) !== null) { + // This check is necessary to avoid infinite loops with zero-width matches. + if (regexMatch.index === options.regex.lastIndex) { + options.regex.lastIndex++ + } + + matches.push({ + raw: regexMatch[0], + start: regexMatch.index, + end: regexMatch.index + regexMatch[0].length, + data: options.transform(regexMatch) + }) + } + return matches + } + } +} + +/** + * Filters an array of matches to only include those from a specific matcher. + * This is a type-safe alternative to `matches.filter(m => m.matcher === '...')`. + * + * @param matches The array of `Match` objects to filter. + * @param matcherName The name of the matcher to filter by. + * @returns A new array containing only the matches from the specified matcher, + * with the `data` property correctly typed. + */ +export function filterMatchesByMatcher< + M extends Match, + N extends M['matcher'] +>(matches: readonly M[], matcherName: N): Extract[] { + return matches.filter( + (match): match is Extract => + match.matcher === matcherName + ) +} + +/** + * Groups an array of matches by their matcher name. + * + * @param matches An array of `Match` objects, typically from `scan`. + * @returns A record where keys are matcher names and values are arrays of matches + * from that matcher, with each array correctly typed. + */ +export function groupMatchesByMatcher>( + matches: readonly M[] +): Partial<{ [N in M['matcher']]: Extract[] }> { + // We use a less specific type for `grouped` internally to work around a TypeScript + // limitation where it cannot correlate the matcher name (key) with the match + // object's type (value) within the loop. The final cast is safe because the + // logic guarantees the structure of the returned object. + const grouped: Record = {} + + for (const match of matches) { + const key = match.matcher + if (!grouped[key]) { + grouped[key] = [] + } + grouped[key].push(match) + } + + return grouped as unknown as Partial<{ + [N in M['matcher']]: Extract[] + }> +} + +// --- Grapheme segmentation helpers --- +// These utilities provide consistent grapheme cluster boundaries across engines. +// They are intentionally here since they operate purely on strings. +import Graphemer from 'graphemer' + +const graphemeSplitter = new Graphemer() + +export function prevGraphemeStart(text: string, index: number): number { + if (index <= 0) return 0 + let pos = 0 + for (const cluster of graphemeSplitter.iterateGraphemes(text)) { + const next = pos + cluster.length + if (next >= index) return pos + pos = next + } + return pos +} + +export function nextGraphemeEnd(text: string, index: number): number { + if (index >= text.length) return text.length + let pos = 0 + for (const cluster of graphemeSplitter.iterateGraphemes(text)) { + const next = pos + cluster.length + if (index < next) return next + pos = next + } + return text.length +} + +export function snapGraphemeStart(text: string, index: number): number { + if (index <= 0) return 0 + let pos = 0 + for (const cluster of graphemeSplitter.iterateGraphemes(text)) { + const next = pos + cluster.length + if (index < next) return pos + pos = next + } + return pos +} + +export function snapGraphemeEnd(text: string, index: number): number { + if (index >= text.length) return text.length + let pos = 0 + for (const cluster of graphemeSplitter.iterateGraphemes(text)) { + const next = pos + cluster.length + if (index <= pos) return pos + if (index <= next) return next + pos = next + } + return pos +} diff --git a/src/inlay/portal-list.tsx b/src/inlay/portal-list.tsx new file mode 100644 index 0000000..b30d3db --- /dev/null +++ b/src/inlay/portal-list.tsx @@ -0,0 +1,348 @@ +import React, { + createContext, + useCallback, + useContext, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState +} from 'react' + +// --- Portal List Navigation Context --- + +type PortalListContextValue = { + activeIndex: number + setActiveIndex: (index: number) => void + registerItem: (value: T, disabled?: boolean) => number + unregisterItem: (index: number) => void + selectItem: (index: number) => void + loop: boolean +} + +const PortalListContext = createContext(null) + +function usePortalListContext() { + const context = useContext(PortalListContext) + if (!context) { + throw new Error('Portal.Item must be used within Portal.List') + } + return context +} + +// --- Keyboard Handler Context --- +// This context allows the parent Inlay.Root to intercept keyboard events + +export type PortalKeyboardHandler = ( + event: React.KeyboardEvent +) => boolean + +// Import the context from inlay.tsx to avoid circular dependency issues +// The context is created in inlay.tsx and exported +import { PortalKeyboardContext } from './inlay' + +export function usePortalKeyboardContext() { + return useContext(PortalKeyboardContext) +} + +// --- Portal.List Component --- + +export type PortalListProps = { + children: React.ReactNode + onSelect: (item: T, index: number) => void + onDismiss?: () => void + loop?: boolean +} & Omit, 'onSelect'> + +function PortalListInner( + props: PortalListProps, + ref: React.ForwardedRef +) { + const { children, onSelect, onDismiss, loop = true, ...divProps } = props + + const [activeIndex, setActiveIndex] = useState(0) + const itemsRef = useRef>([]) + const [, forceUpdate] = useState(0) + const keyboardContext = usePortalKeyboardContext() + + const registerItem = useCallback((value: T, disabled?: boolean) => { + const index = itemsRef.current.length + itemsRef.current.push({ value, disabled }) + forceUpdate((n) => n + 1) + return index + }, []) + + const unregisterItem = useCallback((index: number) => { + itemsRef.current.splice(index, 1) + forceUpdate((n) => n + 1) + }, []) + + const selectItem = useCallback( + (index: number) => { + const item = itemsRef.current[index] + if (item && !item.disabled) { + onSelect(item.value, index) + } + }, + [onSelect] + ) + + const navigateUp = useCallback(() => { + const items = itemsRef.current + if (items.length === 0) return + + setActiveIndex((current) => { + let nextIndex = current - 1 + if (nextIndex < 0) { + nextIndex = loop ? items.length - 1 : 0 + } + + // Skip disabled items + let attempts = 0 + while (items[nextIndex]?.disabled && attempts < items.length) { + nextIndex = nextIndex - 1 + if (nextIndex < 0) { + nextIndex = loop ? items.length - 1 : 0 + } + attempts++ + } + + return nextIndex + }) + }, [loop]) + + const navigateDown = useCallback(() => { + const items = itemsRef.current + if (items.length === 0) return + + setActiveIndex((current) => { + let nextIndex = current + 1 + if (nextIndex >= items.length) { + nextIndex = loop ? 0 : items.length - 1 + } + + // Skip disabled items + let attempts = 0 + while (items[nextIndex]?.disabled && attempts < items.length) { + nextIndex = nextIndex + 1 + if (nextIndex >= items.length) { + nextIndex = loop ? 0 : items.length - 1 + } + attempts++ + } + + return nextIndex + }) + }, [loop]) + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent): boolean => { + const items = itemsRef.current + + switch (event.key) { + case 'ArrowUp': + event.preventDefault() + navigateUp() + return true + case 'ArrowDown': + event.preventDefault() + navigateDown() + return true + case 'Enter': { + const item = items[activeIndex] + if (item && !item.disabled) { + event.preventDefault() + onSelect(item.value, activeIndex) + return true + } + return false + } + case 'Escape': + event.preventDefault() + onDismiss?.() + return true + default: + return false + } + }, + [activeIndex, navigateUp, navigateDown, onSelect, onDismiss] + ) + + // Register keyboard handler with parent context + useLayoutEffect(() => { + keyboardContext?.setHandler(handleKeyDown) + return () => { + keyboardContext?.setHandler(null) + } + }, [keyboardContext, handleKeyDown]) + + // Reset active index when items change + useEffect(() => { + const items = itemsRef.current + if (activeIndex >= items.length && items.length > 0) { + setActiveIndex(items.length - 1) + } + }, [activeIndex]) + + const contextValue = useMemo( + (): PortalListContextValue => ({ + activeIndex, + setActiveIndex, + registerItem, + unregisterItem, + selectItem, + loop + }), + [activeIndex, registerItem, unregisterItem, selectItem, loop] + ) + + return ( + + { + // Prevent focus loss from editor + e.preventDefault() + divProps.onMouseDown?.(e) + }} + onTouchStart={(e) => { + // Prevent focus loss from editor on touch devices + e.preventDefault() + divProps.onTouchStart?.(e) + }} + > + {children} + + + ) +} + +export const PortalList = React.forwardRef(PortalListInner) as ( + props: PortalListProps & { ref?: React.ForwardedRef } +) => React.ReactElement + +// --- Portal.Item Component --- + +export type PortalItemProps = { + value: T + disabled?: boolean + children: React.ReactNode +} & Omit, 'value'> + +function PortalItemInner( + props: PortalItemProps, + ref: React.ForwardedRef +) { + const { value, disabled = false, children, ...divProps } = props + const context = usePortalListContext() + const [index, setIndex] = useState(-1) + const touchStartPosRef = useRef<{ x: number; y: number } | null>(null) + + // Register this item on mount + useLayoutEffect(() => { + const idx = context.registerItem(value, disabled) + setIndex(idx) + return () => { + context.unregisterItem(idx) + } + }, []) + + const isActive = context.activeIndex === index + + const handleClick = useCallback( + (e: React.MouseEvent) => { + if (!disabled && index >= 0) { + context.selectItem(index) + } + divProps.onClick?.(e) + }, + [disabled, context, divProps, index] + ) + + const handleMouseEnter = useCallback( + (e: React.MouseEvent) => { + if (!disabled && index >= 0) { + context.setActiveIndex(index) + } + divProps.onMouseEnter?.(e) + }, + [disabled, context, divProps, index] + ) + + // Touch handlers for mobile - activate on touch, select on release + const handleTouchStart = useCallback( + (e: React.TouchEvent) => { + const touch = e.touches[0] + if (touch) { + touchStartPosRef.current = { x: touch.clientX, y: touch.clientY } + } + // Activate item on touch start (like hover on desktop) + if (!disabled && index >= 0) { + context.setActiveIndex(index) + } + divProps.onTouchStart?.(e) + }, + [disabled, context, divProps, index] + ) + + const handleTouchEnd = useCallback( + (e: React.TouchEvent) => { + const touch = e.changedTouches[0] + const startPos = touchStartPosRef.current + + // Only select if touch didn't move significantly (not a scroll gesture) + if (touch && startPos) { + const dx = Math.abs(touch.clientX - startPos.x) + const dy = Math.abs(touch.clientY - startPos.y) + + // If movement was minimal, treat as a tap and select + if (dx < 10 && dy < 10) { + if (!disabled && index >= 0) { + // Prevent the subsequent click event + e.preventDefault() + context.selectItem(index) + } + } + } + + touchStartPosRef.current = null + divProps.onTouchEnd?.(e) + }, + [disabled, context, divProps, index] + ) + + return ( + + {children} + + ) +} + +export const PortalItem = React.forwardRef(PortalItemInner) as ( + props: PortalItemProps
= P & { __scope?: Scope } +const [createInlayContext] = createContextScope(COMPONENT_NAME) + +const AncestorContext = createContext(null) +const PopoverControlContext = createContext<{ + setOpen: (open: boolean) => void +} | null>(null) + +// Context for portal keyboard navigation +type PortalKeyboardContextValue = { + setHandler: (handler: PortalKeyboardHandler | null) => void +} +const PortalKeyboardContext = createContext( + null +) +export { PortalKeyboardContext } + +// Context for anchor rect - allows Portal to position itself based on caret position +type AnchorRectContextValue = { + getRect: () => DOMRect +} +const AnchorRectContext = createContext(null) + +function annotateWithAncestor( + node: React.ReactNode, + currentAncestor: React.ReactElement | null +): React.ReactNode { + if (!React.isValidElement(node)) { + return node + } + + const element = node as React.ReactElement<{ children?: React.ReactNode }> + const nextAncestor = currentAncestor ?? element + + const children = element.props.children + const wrappedChildren = React.Children.map( + children, + (child: React.ReactNode) => annotateWithAncestor(child, nextAncestor) + ) + + return ( + + {React.cloneElement(element, undefined, wrappedChildren)} + + ) +} + +// --- Internal Context for token registration --- +type InternalInlayContextValue = { + registerToken: (token: { text: string; node: React.ReactElement }) => void +} +const [InternalInlayProvider, useInternalInlayContext] = + createInlayContext(COMPONENT_NAME) + +// --- Public Context for consumer state --- +type TokenInfo = { + text: string + node: React.ReactElement + start: number + end: number +} + +export type TokenState = { + isCollapsed: boolean + isAtStartOfToken: boolean + isAtEndOfToken: boolean +} + +type PublicInlayContextValue = { + value: string + selection: { start: number; end: number } + activeToken: TokenInfo | null + activeTokenState: TokenState | null + getSelectionRange: () => Range | null +} + +const [PublicInlayProvider, usePublicInlayContext] = + createInlayContext(COMPONENT_NAME) + +export type InlayProps = ScopedProps< + { + children: React.ReactNode + value?: string + defaultValue?: string + onChange?: (value: string) => void + placeholder?: React.ReactNode + multiline?: boolean + // Mobile input attributes + inputMode?: + | 'text' + | 'search' + | 'email' + | 'tel' + | 'url' + | 'numeric' + | 'decimal' + | 'none' + autoCapitalize?: + | 'off' + | 'none' + | 'on' + | 'sentences' + | 'words' + | 'characters' + autoCorrect?: 'on' | 'off' + enterKeyHint?: + | 'enter' + | 'done' + | 'go' + | 'next' + | 'previous' + | 'search' + | 'send' + onVirtualKeyboardChange?: (open: boolean) => void + } & Omit< + React.HTMLAttributes, + 'onChange' | 'defaultValue' | 'onKeyDown' + > & { + onKeyDown?: (event: React.KeyboardEvent) => boolean // Return true to stop propagation + } & { + getPopoverAnchorRect?: (root: HTMLDivElement | null) => DOMRect + } +> + +export type InlayRef = { + root: HTMLDivElement | null + setSelection: (start: number, end?: number) => void +} + +const Inlay = React.forwardRef((props, forwardedRef) => { + const { + __scope, + children: allChildren, + value: valueProp, + defaultValue, + onChange, + onKeyDown: onKeyDownProp, + placeholder, + multiline = true, + getPopoverAnchorRect, + // Mobile input attributes + inputMode = 'text', + autoCapitalize = 'sentences', + autoCorrect, // Omit by default - let iOS use native behavior (single-word suggestions) + enterKeyHint, + onVirtualKeyboardChange, + ...inlayProps + } = props + + const popoverPortal = React.useMemo( + () => + React.Children.toArray(allChildren).find( + (child) => React.isValidElement(child) && child.type === Portal + ), + [allChildren] + ) + + const children = React.useMemo( + () => + React.Children.toArray(allChildren).filter( + (child) => !React.isValidElement(child) || child.type !== Portal + ), + [allChildren] + ) + + const [isPopoverOpen, setIsPopoverOpen] = useState(false) + + // Portal keyboard handler - allows Portal.List to intercept keyboard events + const portalKeyboardHandlerRef = useRef(null) + const portalKeyboardContext = useMemo( + () => ({ + setHandler: (handler: PortalKeyboardHandler | null) => { + portalKeyboardHandlerRef.current = handler + } + }), + [] + ) + const popoverControl = useMemo(() => ({ setOpen: setIsPopoverOpen }), []) + const [value, setValue] = useControllableState({ + prop: valueProp, + defaultProp: defaultValue || '', + onChange + }) + // Keep a ref to the current value for synchronous access in event handlers + const valueRef = useRef(value) + valueRef.current = value + const editorRef = useRef(null) + const placeholderRef = useRef(null) + const { + selection, + setSelection, + setSelectionImperative, + handleSelectionChange, + lastAnchorRectRef, + suppressNextSelectionAdjustRef + } = useSelection(editorRef, value) + const virtualAnchorRef = useRef({ + getBoundingClientRect: () => lastAnchorRectRef.current + }) + const customAnchorRef = useRef({ + getBoundingClientRect: () => new DOMRect(0, 0, 0, 0) + }) + useLayoutEffect(() => { + if (!getPopoverAnchorRect) return + customAnchorRef.current.getBoundingClientRect = () => { + try { + const root = editorRef.current + const rect = getPopoverAnchorRect(root) + return rect ?? new DOMRect(0, 0, 0, 0) + } catch { + return new DOMRect(0, 0, 0, 0) + } + } + }, [getPopoverAnchorRect]) + const chosenAnchorRef = getPopoverAnchorRect + ? customAnchorRef + : virtualAnchorRef + const { + isRegistered, + registerToken, + weavedChildren, + activeToken, + activeTokenRef, + activeTokenState + } = useTokenWeaver(value, selection, multiline, children) + + const lastArrowDirectionRef = useRef<'left' | 'right' | 'up' | 'down' | null>( + null + ) + const lastShiftRef = useRef(false) + + const getCurrentSnapshot = useCallback(() => { + const root = editorRef.current + if (root) { + const sel = window.getSelection() + if (sel && sel.rangeCount > 0) { + const r = sel.getRangeAt(0) + const start = getAbsoluteOffset(root, r.startContainer, r.startOffset) + const end = getAbsoluteOffset(root, r.endContainer, r.endOffset) + return { value, selection: { start, end } } + } + } + return { value, selection } + }, [value, selection]) + const applySnapshot = useCallback( + (snap: { value: string; selection: { start: number; end: number } }) => { + setValue(() => snap.value) + requestAnimationFrame(() => { + const root = editorRef.current + if (root) { + suppressNextSelectionAdjustRef.current = true + setDomSelection(root, snap.selection.start, snap.selection.end) + } + }) + }, + [setValue] + ) + const { pushUndoSnapshot, beginEditSession, endEditSession, undo, redo } = + useHistory(getCurrentSnapshot, applySnapshot, 200) + + const serializeRawFromDom = useCallback((): string => { + const root = editorRef.current + if (!root) return value + return serializeFromDom(root) + }, [value]) + + const { + isComposing, + isComposingRef, + suppressNextBeforeInputRef, + suppressNextKeydownCommitRef, + compositionCommitKeyRef, + compositionJustEndedAtRef, + contentKey, + onCompositionStart, + onCompositionUpdate, + onCompositionEnd + } = useComposition( + editorRef, + serializeRawFromDom, + handleSelectionChange, + setValue, + () => value + ) + + usePlaceholderSync(editorRef, placeholderRef, [value, placeholder]) + + // weaving moved + const getSelectionRange = useCallback(() => { + const domSelection = window.getSelection() + if (domSelection && domSelection.rangeCount > 0) { + return domSelection.getRangeAt(0) + } + return null + }, []) + const { onBeforeInput, onKeyDown } = useKeyHandlers({ + editorRef, + contentKey, + multiline, + onKeyDownProp, + beginEditSession, + endEditSession, + pushUndoSnapshot, + undo, + redo, + isComposingRef, + compositionCommitKeyRef, + suppressNextBeforeInputRef, + suppressNextKeydownCommitRef, + compositionJustEndedAtRef, + setValue, + valueRef, + getActiveToken: () => + activeTokenRef.current + ? { + start: activeTokenRef.current.start, + end: activeTokenRef.current.end + } + : null + }) + const { onSelect } = useSelectionSnap({ + editorRef, + setSelection, + lastAnchorRectRef, + suppressNextSelectionAdjustRef, + lastArrowDirectionRef, + lastShiftRef, + isComposingRef + }) + const { onCopy, onCut, onPaste } = useClipboard({ + editorRef, + getValue: () => value, + setValue, + pushUndoSnapshot, + isComposingRef + }) + useVirtualKeyboard({ + editorRef, + onVirtualKeyboardChange + }) + const { onTouchStart, onTouchMove, onTouchEnd } = useTouchSelection({ + editorRef, + handleSelectionChange, + isComposingRef + }) + // Wrap onKeyDown to route through portal keyboard handler first + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + // If portal has a keyboard handler registered and it handles the event, stop + if ( + isPopoverOpen && + portalKeyboardHandlerRef.current && + portalKeyboardHandlerRef.current(event) + ) { + return + } + // Otherwise, use the default key handler + onKeyDown(event) + }, + [isPopoverOpen, onKeyDown] + ) + useImperativeHandle(forwardedRef, () => ({ + root: editorRef.current, + setSelection: (start: number, end?: number) => { + if (editorRef.current) { + setSelectionImperative(start, end) + } + } + })) + return ( + + + + + + {/* First pass: renders children to populate registry */} + + {React.Children.map(children, (child) => + annotateWithAncestor(child, null) + )} + + + {/* Second pass: renders weaved content */} + + + {isRegistered ? weavedChildren : children} + + {value.length === 0 && placeholder && !isComposing && ( + + {placeholder} + + )} + + ({ getRect: () => lastAnchorRectRef.current }), + [] + )} + > + + {popoverPortal} + + + + + + + ) +}) + +type PortalRenderProps = (context: PublicInlayContextValue) => React.ReactNode + +type PortalProps = ScopedProps< + Omit & { + children: PortalRenderProps + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: `data-${string}`]: any + } +> + +const Portal = (props: PortalProps) => { + const { + __scope, + children, + side = 'bottom', + sideOffset = 0, + alignOffset = 0, + ...contentProps + } = props + const context = usePublicInlayContext(COMPONENT_NAME, __scope) + const popoverControl = useContext(PopoverControlContext) + const keyboardContext = useContext(PortalKeyboardContext) + const anchorRectContext = useContext(AnchorRectContext) + const contentRef = useRef(null) + + const content = children(context) + const hasContent = !!content + + // Track last content to avoid flashing during timing gaps between render and effects + const lastContentRef = useRef(null) + const closeTimeoutRef = useRef(null) + + if (hasContent) { + lastContentRef.current = content + if (closeTimeoutRef.current !== null) { + clearTimeout(closeTimeoutRef.current) + closeTimeoutRef.current = null + } + } + + useLayoutEffect(() => { + if (hasContent) { + popoverControl?.setOpen(true) + } else { + // Defer close to allow effects to catch up + if (closeTimeoutRef.current !== null) + clearTimeout(closeTimeoutRef.current) + closeTimeoutRef.current = window.setTimeout(() => { + closeTimeoutRef.current = null + popoverControl?.setOpen(false) + lastContentRef.current = null + }, 50) + } + return () => { + if (closeTimeoutRef.current !== null) { + clearTimeout(closeTimeoutRef.current) + closeTimeoutRef.current = null + } + } + }, [hasContent, popoverControl]) + + // Position manually via DOM to follow caret on iOS and avoid re-render loops + useLayoutEffect(() => { + const el = contentRef.current + if (!el || !anchorRectContext) return + + const rect = anchorRectContext.getRect() + if (rect.x === 0 && rect.y === 0 && rect.width === 0 && rect.height === 0) + return + + let top: number, left: number + if (side === 'bottom') { + top = rect.bottom + sideOffset + left = rect.left + alignOffset + } else if (side === 'top') { + top = rect.top - el.offsetHeight - sideOffset + left = rect.left + alignOffset + } else if (side === 'left') { + top = rect.top + alignOffset + left = rect.left - el.offsetWidth - sideOffset + } else { + top = rect.top + alignOffset + left = rect.right + sideOffset + } + + el.style.top = `${top}px` + el.style.left = `${left}px` + el.style.visibility = 'visible' + }) + + const displayContent = content || lastContentRef.current + + if (!displayContent) return null + + return ( + + e.preventDefault()} + {...contentProps} + style={{ + ...contentProps.style, + position: 'fixed', + top: 0, + left: 0, + zIndex: 50, + visibility: 'hidden' // Made visible by useLayoutEffect after positioning + }} + > + + {displayContent} + + + + ) +} +Portal.displayName = 'Inlay.Portal' + +// Attach List and Item to Portal for compound component API +const PortalWithList = Object.assign(Portal, { + List: PortalList, + Item: PortalItem +}) + +type TokenProps = ScopedProps<{ + value: string + children: React.ReactNode +}> & + React.HTMLAttributes + +const Token = React.forwardRef((props, ref) => { + const { __scope, value, children, ...textProps } = props + const internalContext = useInternalInlayContext(TEXT_COMPONENT_NAME, __scope) + const ancestor = useContext(AncestorContext) + + const nodeToRegister = ( + + {children} + + ) + + // Register the token with its text value and the React node itself. + // If an ancestor is found in context, register that instead of the immediate span. + internalContext.registerToken({ + text: value, + node: ancestor || nodeToRegister + }) + + return nodeToRegister +}) + +Token.displayName = TEXT_COMPONENT_NAME + +export { Inlay as Root, Token, PortalWithList as Portal } diff --git a/src/inlay/internal/dom-utils.test.ts b/src/inlay/internal/dom-utils.test.ts new file mode 100644 index 0000000..38dcedc --- /dev/null +++ b/src/inlay/internal/dom-utils.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'vitest' +import { getAbsoluteOffset, getTextNodeAtOffset } from './dom-utils' + +// Helper to build a minimal editor DOM tree +function createEditor(html: string): HTMLElement { + const root = document.createElement('div') + root.innerHTML = html + return root +} + +describe('dom-utils', () => { + it('getAbsoluteOffset on plain text accumulates correctly', () => { + const root = createEditor('hello world') + const firstText = root.querySelector('span')!.firstChild as Text + const secondText = root.querySelectorAll('span')[1].firstChild as Text + + expect(getAbsoluteOffset(root, firstText, 0)).toBe(0) // h| + expect(getAbsoluteOffset(root, firstText, 5)).toBe(5) // hello| + expect(getAbsoluteOffset(root, secondText, 1)).toBe(6) // hello␠| + }) + + it('getTextNodeAtOffset on plain text maps back to nodes/offsets', () => { + const root = createEditor('hello world') + const [n0, o0] = getTextNodeAtOffset(root, 0) + expect((n0 as Text).data.slice(0, o0)).toBe('') + + const [n5, o5] = getTextNodeAtOffset(root, 5) + expect((n5 as Text).data.slice(0, o5)).toBe('hello') + + const [n7, o7] = getTextNodeAtOffset(root, 7) + expect((n7 as Text).data.slice(0, o7)).toBe(' w') + }) + + it('diverged token: absolute offset snaps to token edges when inside token', () => { + const root = createEditor( + 'hi ' + + 'Alex' + + '!' + ) + const token = root.querySelector('[data-token-text]') as HTMLElement + const tokenInner = token.querySelector('span')!.firstChild as Text // "Alex" + + // Inside first char of rendered token should snap to start of raw token (offset at start of token = 3) + expect(getAbsoluteOffset(root, tokenInner, 1)).toBe(3) + + // Inside last char of rendered token should snap to end of raw token (raw len = 5, base start = 3, so end = 8) + expect(getAbsoluteOffset(root, tokenInner, 4)).toBe(8) + }) + + it('round-trip sweep over all offsets in mixed content (snap inside diverged token)', () => { + const root = createEditor( + 'X' + + 'Alex' + + 'Y' + ) + // raw: "X@alexY" -> token starts at 1, rawLen=5, tokenEnd=6, total len=7 + const tokenStart = 1 + const tokenRawLen = 5 + const tokenEnd = tokenStart + tokenRawLen + for (let i = 0; i <= 7; i++) { + const [n, o] = getTextNodeAtOffset(root, i) + const roundTrip = getAbsoluteOffset(root, n as Node, o) + const expected = + i < tokenStart || i > tokenEnd + ? i + : i - tokenStart <= tokenRawLen / 2 + ? tokenStart + : tokenEnd + expect(roundTrip).toBe(expected) + } + }) + + it('fallbacks: offsets past end clamp to last text node end', () => { + const root = createEditor('abc') + const [n, o] = getTextNodeAtOffset(root, 999) + expect((n as Text).data.length).toBe(3) + expect(o).toBe(3) + }) +}) diff --git a/src/inlay/internal/dom-utils.ts b/src/inlay/internal/dom-utils.ts new file mode 100644 index 0000000..8146bde --- /dev/null +++ b/src/inlay/internal/dom-utils.ts @@ -0,0 +1,381 @@ +const isTokenElement = (el: Element): boolean => + el.hasAttribute('data-token-text') + +const getTokenRawLength = (el: Element): number => + (el.getAttribute('data-token-text') || '').length + +const getRenderedTextLength = (el: Element): number => { + const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null) + let total = 0 + let n: Node | null + while ((n = walker.nextNode())) total += (n.textContent || '').length + return total +} + +const findFirstTextNode = (el: Element): ChildNode | null => { + const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null) + return walker.nextNode() as ChildNode | null +} + +const findLastTextNode = (el: Element): ChildNode | null => { + const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null) + let last: Node | null = null + let n: Node | null + while ((n = walker.nextNode())) last = n + return last as ChildNode | null +} + +export function getClosestTokenEl(node: Node | null): HTMLElement | null { + let curr: Node | null = node + while (curr) { + if (curr.nodeType === Node.ELEMENT_NODE) { + const el = curr as HTMLElement + if (el.hasAttribute('data-token-text')) return el + } + curr = curr.parentNode + } + return null +} + +export function getTokenRawRange( + root: HTMLElement, + tokenEl: HTMLElement +): { start: number; end: number } | null { + const rawText = tokenEl.getAttribute('data-token-text') + if (!rawText) return null + + const walker = document.createTreeWalker(tokenEl, NodeFilter.SHOW_TEXT, null) + const firstText = walker.nextNode() + if (!firstText) return null + + const start = getAbsoluteOffset(root, firstText, 0) + return { start, end: start + rawText.length } +} + +export const getTextNodeAtOffset = ( + root: HTMLElement, + offset: number +): [ChildNode | null, number] => { + const traverse = ( + container: Node, + remaining: { value: number } + ): [ChildNode | null, number] | null => { + const children = container.childNodes + for (let i = 0; i < children.length; i++) { + const child = children[i] + + if (child.nodeType === Node.ELEMENT_NODE) { + const el = child as Element + + // represents a newline character - count as 1 + // If we're at the position right after a , return the next text node + if (el.tagName === 'BR') { + if (remaining.value === 0) { + // Position is right at the - find next text node + // Look for the next sibling text node or continue traversal + for (let j = i + 1; j < children.length; j++) { + const next = children[j] + if (next.nodeType === Node.TEXT_NODE) { + return [next as ChildNode, 0] + } + if (next.nodeType === Node.ELEMENT_NODE) { + const nextEl = next as Element + const first = findFirstTextNode(nextEl) + if (first) return [first, 0] + } + } + // No next text node found - return null + return null + } + remaining.value -= 1 + continue + } + + if (isTokenElement(el)) { + const rawLen = getTokenRawLength(el) + const renderedLen = getRenderedTextLength(el) + const isDiverged = renderedLen !== rawLen + + if (isDiverged) { + if (remaining.value <= rawLen) { + const first = findFirstTextNode(el) + const last = findLastTextNode(el) + if (!first && !last) return null + + const snapToStart = remaining.value <= rawLen / 2 + if (snapToStart) { + if (first) return [first, 0] + } else { + if (last) return [last, (last.textContent || '').length] + } + if (first) return [first, 0] + if (last) return [last, (last.textContent || '').length] + return null + } + remaining.value -= rawLen + continue + } + + const found = traverse(el, remaining) + if (found) return found + continue + } + + const found = traverse(el, remaining) + if (found) return found + continue + } + + if (child.nodeType === Node.TEXT_NODE) { + const text = child.textContent || '' + if (remaining.value <= text.length) { + return [child as ChildNode, remaining.value] + } + remaining.value -= text.length + } + } + + return null + } + + const result = traverse(root, { value: Math.max(0, offset) }) + if (result) return result + + // Fallback: place at end of last text node + const allTokenTextNodes = Array.from( + root.querySelectorAll('[data-token-text]') + ) + .map((el) => findLastTextNode(el)) + .filter(Boolean) as ChildNode[] + + if (allTokenTextNodes.length > 0) { + const lastNode = allTokenTextNodes[allTokenTextNodes.length - 1] + return [lastNode, (lastNode.textContent || '').length] + } + + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null) + let last: Node | null = null + let n: Node | null + while ((n = walker.nextNode())) last = n + if (last) return [last as ChildNode, (last.textContent || '').length] + + return [null, 0] +} + +export const getAbsoluteOffset = ( + root: HTMLElement, + node: Node, + offset: number +): number => { + const getOffsetWithinElement = ( + el: Element, + target: Node, + targetOffset: number + ): number => { + const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null) + let total = 0 + let n: Node | null + while ((n = walker.nextNode())) { + if (n === target) { + total += Math.min(targetOffset, (n.textContent || '').length) + break + } + total += (n.textContent || '').length + } + return total + } + + // Handle element node containers (e.g. Firefox Ctrl+a sets selection on element, not text node) + if (node.nodeType === Node.ELEMENT_NODE) { + const el = node as Element + const children = el.childNodes + + let total = 0 + const measure = (n: Node): number => { + if (n.nodeType === Node.TEXT_NODE) { + return (n.textContent || '').length + } + if (n.nodeType === Node.ELEMENT_NODE) { + const e = n as Element + // represents a newline character + if (e.tagName === 'BR') return 1 + if (isTokenElement(e)) return getTokenRawLength(e) + let sum = 0 + for (let i = 0; i < e.childNodes.length; i++) { + sum += measure(e.childNodes[i]) + } + return sum + } + return 0 + } + + for (let i = 0; i < offset && i < children.length; i++) { + total += measure(children[i]) + } + return total + } + + const traverse = (container: Node, acc: { value: number }): number | null => { + const children = container.childNodes + for (let i = 0; i < children.length; i++) { + const child = children[i] + + if (child.nodeType === Node.ELEMENT_NODE) { + const el = child as Element + + if (isTokenElement(el)) { + const rawLen = getTokenRawLength(el) + const renderedLen = getRenderedTextLength(el) + const isDiverged = renderedLen !== rawLen + + if (isDiverged) { + if (el.contains(node)) { + const withinRendered = getOffsetWithinElement(el, node, offset) + const snapToStart = withinRendered <= renderedLen / 2 + return acc.value + (snapToStart ? 0 : rawLen) + } + acc.value += rawLen + continue + } + + if (el.contains(node)) { + const inner = traverse(el, acc) + if (inner != null) return inner + } else { + acc.value += renderedLen + } + continue + } + + // represents a newline character - count as 1 and continue + if (el.tagName === 'BR') { + acc.value += 1 + continue + } + + if (el.contains(node)) { + const inner = traverse(el, acc) + if (inner != null) return inner + } else { + const measureSubtree = (e: Element): number => { + // represents a newline character + if (e.tagName === 'BR') return 1 + let total = 0 + const cn = e.childNodes + for (let j = 0; j < cn.length; j++) { + const c = cn[j] + if (c.nodeType === Node.TEXT_NODE) { + total += (c.textContent || '').length + } else if (c.nodeType === Node.ELEMENT_NODE) { + const ce = c as Element + if (isTokenElement(ce)) { + const rl = getTokenRawLength(ce) + const rr = getRenderedTextLength(ce) + total += rr === rl ? rr : rl + } else { + total += measureSubtree(ce) + } + } + } + return total + } + acc.value += measureSubtree(el) + } + continue + } + + if (child.nodeType === Node.TEXT_NODE) { + if (child === node) { + return acc.value + Math.min(offset, (child.textContent || '').length) + } else { + acc.value += (child.textContent || '').length + } + } + } + return null + } + + const result = traverse(root, { value: 0 }) + if (result != null) return result + + // Fallback: total length + let total = 0 + const stack: Node[] = [root] + while (stack.length) { + const n = stack.pop()! + if (n.nodeType === Node.ELEMENT_NODE) { + const el = n as Element + if (isTokenElement(el)) { + const rl = getTokenRawLength(el) + const rr = getRenderedTextLength(el) + total += rr === rl ? rr : rl + continue + } + const cn = el.childNodes + for (let i = cn.length - 1; i >= 0; i--) stack.push(cn[i]) + } else if (n.nodeType === Node.TEXT_NODE) { + total += (n.textContent || '').length + } + } + return total +} + +export const setDomSelection = ( + root: HTMLElement, + start: number, + end?: number +) => { + const [startNode, startOffset] = getTextNodeAtOffset(root, start) + const [endNode, endOffset] = end + ? getTextNodeAtOffset(root, end) + : [startNode, startOffset] + + if (startNode && endNode) { + // Guard against NotFoundError on iOS: nodes may have been removed + // by React re-renders between scheduling and execution + if (!startNode.isConnected || !endNode.isConnected) { + return + } + + const range = document.createRange() + range.setStart(startNode, startOffset) + range.setEnd(endNode, endOffset) + const selection = window.getSelection() + if (selection) { + selection.removeAllRanges() + selection.addRange(range) + } + } +} + +export const serializeRawFromDom = (root: HTMLElement): string => { + const clone = root.cloneNode(true) as HTMLElement + const tokenEls = clone.querySelectorAll('[data-token-text]') + tokenEls.forEach((el) => { + const raw = el.getAttribute('data-token-text') || '' + const renderedLen = getRenderedTextLength(el) + if (renderedLen !== raw.length) { + ;(el as HTMLElement).textContent = raw + } + }) + let result = (clone as HTMLElement).innerText + // iOS contentEditable often has trailing newlines, zero-width spaces, or other + // invisible characters when "empty". Strip these from the end. + // Also handle the case where the entire content is just whitespace/invisible chars. + result = result.replace(/[\u200B\uFEFF]+/g, '') // Remove zero-width spaces throughout + + // Handle empty content (just newlines or whitespace) + if (result === '\n' || result.trim() === '') { + return '' + } + + // ContentEditable often adds ONE extra trailing newline. Remove it if present. + // But preserve intentional newlines in the content (e.g., "hello\nworld\n" → "hello\nworld") + // This is tricky: we can't know if the final newline is intentional or added by browser. + // Heuristic: if it ends with double newline, remove one. Single trailing newline stays. + if (result.endsWith('\n\n')) { + result = result.slice(0, -1) + } + + return result +} diff --git a/src/inlay/internal/string-utils.test.ts b/src/inlay/internal/string-utils.test.ts new file mode 100644 index 0000000..b323ae4 --- /dev/null +++ b/src/inlay/internal/string-utils.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect } from 'vitest' +import { + createRegexMatcher, + filterMatchesByMatcher, + groupMatchesByMatcher, + scan +} from './string-utils' + +describe('string-utils', () => { + it('createRegexMatcher + scan: finds and sorts matches with names', () => { + const mention = createRegexMatcher<{ mention: string }, 'mention'>( + 'mention', + { + regex: /@\w+/g, + transform: (m) => ({ mention: m[0] }) + } + ) + + const text = 'hi @alex and @bob' + const matches = scan(text, [mention]) + + expect(matches.map((m) => m.raw)).toEqual(['@alex', '@bob']) + expect(matches.map((m) => m.start)).toEqual([3, 13]) + expect(matches.every((m) => m.matcher === 'mention')).toBe(true) + }) + + it('createRegexMatcher resets lastIndex between scans', () => { + const word = createRegexMatcher<{ w: string }, 'word'>('word', { + regex: /\w+/g, + transform: (m) => ({ w: m[0] }) + }) + + const a = scan('alpha beta', [word]) + const b = scan('gamma', [word]) + + expect(a.map((m) => m.raw)).toEqual(['alpha', 'beta']) + expect(b.map((m) => m.raw)).toEqual(['gamma']) + }) + + it('filterMatchesByMatcher returns only requested matcher type', () => { + const mention = createRegexMatcher<{ mention: string }, 'mention'>( + 'mention', + { regex: /@\w+/g, transform: (m) => ({ mention: m[0] }) } + ) + const hash = createRegexMatcher<{ tag: string }, 'hashtag'>('hashtag', { + regex: /#\w+/g, + transform: (m) => ({ tag: m[0] }) + }) + + const matches = scan('say @alex and #music', [mention, hash]) + + const onlyMentions = filterMatchesByMatcher(matches, 'mention') + expect(onlyMentions.map((m) => m.raw)).toEqual(['@alex']) + }) + + it('groupMatchesByMatcher groups by matcher name', () => { + const m1 = createRegexMatcher<{ v: string }, 'a'>('a', { + regex: /a/g, + transform: (m) => ({ v: m[0] }) + }) + const m2 = createRegexMatcher<{ v: string }, 'b'>('b', { + regex: /b/g, + transform: (m) => ({ v: m[0] }) + }) + + const matches = scan('ababa', [m1, m2]) + const grouped = groupMatchesByMatcher(matches) + + expect(grouped.a?.map((x) => x.raw)).toEqual(['a', 'a', 'a']) + expect(grouped.b?.map((x) => x.raw)).toEqual(['b', 'b']) + }) + + it('createRegexMatcher throws when regex lacks global flag', () => { + expect(() => + // @ts-expect-error intentional invalid regex config for test + createRegexMatcher('bad', { regex: /@\w+/, transform: (m) => m[0] }) + ).toThrow() + }) + + it('createRegexMatcher throws when regex uses sticky flag', () => { + expect(() => + // @ts-expect-error intentional invalid regex config for test + createRegexMatcher('bad', { regex: /@\w+/gy, transform: (m) => m[0] }) + ).toThrow() + }) + + it('scan filters overlapping matches - longer match wins', () => { + const atWord = createRegexMatcher<{ v: string }, 'at'>('at', { + regex: /@\w+/g, + transform: (m) => ({ v: m[0] }) + }) + const word = createRegexMatcher<{ v: string }, 'word'>('word', { + regex: /\w+/g, + transform: (m) => ({ v: m[0] }) + }) + + // @alex (5 chars starting at 0) vs alex (4 chars starting at 1) + // They overlap, and @alex is longer, so only @alex should be returned + const matches = scan('@alex', [atWord, word]) + expect(matches.map((m) => `${m.matcher}:${m.raw}`)).toEqual(['at:@alex']) + }) + + it('scan: longest match wins when two matchers start at same position', () => { + const short = createRegexMatcher<{ v: string }, 'short'>('short', { + regex: /@alice/g, + transform: (m) => ({ v: m[0] }) + }) + const long = createRegexMatcher<{ v: string }, 'long'>('long', { + regex: /@alice_vip/g, + transform: (m) => ({ v: m[0] }) + }) + + // Both match starting at position 0, but long is 10 chars vs short's 6 + const matches = scan('@alice_vip', [short, long]) + expect(matches.map((m) => `${m.matcher}:${m.raw}`)).toEqual([ + 'long:@alice_vip' + ]) + }) + + it('scan: first matcher wins when matches are exact same range', () => { + const pluginA = createRegexMatcher<{ v: string }, 'a'>('a', { + regex: /@test/g, + transform: (m) => ({ v: m[0] }) + }) + const pluginB = createRegexMatcher<{ v: string }, 'b'>('b', { + regex: /@test/g, + transform: (m) => ({ v: m[0] }) + }) + + // Both match @test at exact same range - first matcher (a) should win + const matches = scan('@test', [pluginA, pluginB]) + expect(matches.map((m) => `${m.matcher}:${m.raw}`)).toEqual(['a:@test']) + }) + + it('scan preserves non-overlapping matches from multiple matchers', () => { + const mention = createRegexMatcher<{ v: string }, 'mention'>('mention', { + regex: /@\w+/g, + transform: (m) => ({ v: m[0] }) + }) + const hashtag = createRegexMatcher<{ v: string }, 'hashtag'>('hashtag', { + regex: /#\w+/g, + transform: (m) => ({ v: m[0] }) + }) + + // @bob and #music don't overlap, both should be kept + const matches = scan('@bob loves #music', [mention, hashtag]) + expect(matches.map((m) => `${m.matcher}:${m.raw}`)).toEqual([ + 'mention:@bob', + 'hashtag:#music' + ]) + }) +}) diff --git a/src/inlay/internal/string-utils.ts b/src/inlay/internal/string-utils.ts new file mode 100644 index 0000000..b5de702 --- /dev/null +++ b/src/inlay/internal/string-utils.ts @@ -0,0 +1,248 @@ +/** + * Represents a single matched token within a larger string. + * @template T The type of the structured data associated with the match. + * @template N The literal string type of the matcher's name. + */ +export type Match = { + /** The raw matched text span from the original string. */ + raw: string + /** The start index of the match in the full string. */ + start: number + /** The end index of the match in the full string. */ + end: number + /** A structured object with parsed values defined by the matcher. */ + data: T + /** The unique `name` of the matcher that produced this match. */ + matcher: N +} + +/** + * A utility type that constructs a `Match` type from a `Matcher` type. + * It infers the data type `T` and the literal name `N` from the matcher. + * This is key to creating a discriminated union for the `scan` function's return type. + */ +export type MatchFromMatcher = + M extends Matcher ? Match : never + +type MatchData = Omit, 'matcher'> + +/** + * Defines a configuration for finding a specific kind of token. + * It's a generic interface that can be implemented using various strategies (e.g., regex, prefix, custom logic). + * @template T The type of the structured data this matcher will produce. + * @template N The literal string type of the matcher's name. + */ +export type Matcher = { + /** A unique name for the matcher, used to identify which matcher found a token. */ + name: N + /** + * The core matching logic. It scans the input text and returns all found tokens. + * @param text The full string to scan. + * @returns An array of match data, without the `matcher` property. + */ + match: (text: string) => MatchData[] +} + +/** + * Scans a string for tokens using a set of configured matchers. + * + * This function is the core of the token matching utility. It orchestrates the matching process + * by running all provided matchers over the input text and consolidating the results. + * Each matcher can produce a different type of structured data, and the return type will + * be a discriminated union of all possible `Match` types, inferred from the provided matchers. + * + * @param text The full string to scan. + * @param matchers An array of `Matcher` objects to run on the string. The use of `` ensures + * that the literal types of matcher names are preserved for type inference. + * @returns An array of all `Match` objects found, sorted by their start index. + * The type of each element is a discriminated union of all possible `Match` types. + * Consumers can switch on `match.matcher` to safely access `match.data`. + */ +export function scan[]>( + text: string, + matchers: M +): MatchFromMatcher[] { + const allMatches = matchers.flatMap((matcher) => + matcher.match(text).map((m) => ({ ...m, matcher: matcher.name })) + ) as MatchFromMatcher[] + + // Sort by start position ascending, then by length descending (longest first at same position) + // This ensures the longest match at each position is considered first + allMatches.sort((a, b) => { + if (a.start !== b.start) return a.start - b.start + return b.end - b.start - (a.end - a.start) + }) + + // Greedy algorithm: accept non-overlapping matches, preferring longer ones + // When two matches overlap, the one starting earlier (or longer at same position) wins + const accepted: typeof allMatches = [] + let lastEnd = -1 + for (const match of allMatches) { + if (match.start >= lastEnd) { + accepted.push(match) + lastEnd = match.end + } + } + + return accepted +} + +/** + * Options for creating a regex-based matcher. + * @template T The type of the structured data the matcher will produce. + */ +type RegexMatcherOptions = { + /** The regular expression to match against. Must include the 'g' flag for global matching. */ + regex: RegExp + /** A function to transform the raw regex match array into the desired structured data. */ + transform: (match: RegExpExecArray) => T +} + +/** + * A factory function to create a `Matcher` that uses a regular expression. + * This simplifies the creation of matchers for patterns that can be described with regex. + * + * @template T The type of the structured data the created matcher will produce. + * @param name The unique name for this matcher. + * @param options The configuration for the regex matcher. + * @returns A new `Matcher` object. + */ +export function createRegexMatcher( + name: N, + options: RegexMatcherOptions +): Matcher { + if (!options.regex.global) { + throw new Error('createRegexMatcher requires a global regex (e.g. /.../g)') + } + if (options.regex.sticky) { + throw new Error( + `createRegexMatcher does not support the sticky 'y' flag, as it interferes with scanning.` + ) + } + + return { + name, + match: (text: string): MatchData[] => { + const matches: MatchData[] = [] + let regexMatch: RegExpExecArray | null + + // Important: Reset lastIndex on the regex before each new scan. + options.regex.lastIndex = 0 + + while ((regexMatch = options.regex.exec(text)) !== null) { + // This check is necessary to avoid infinite loops with zero-width matches. + if (regexMatch.index === options.regex.lastIndex) { + options.regex.lastIndex++ + } + + matches.push({ + raw: regexMatch[0], + start: regexMatch.index, + end: regexMatch.index + regexMatch[0].length, + data: options.transform(regexMatch) + }) + } + return matches + } + } +} + +/** + * Filters an array of matches to only include those from a specific matcher. + * This is a type-safe alternative to `matches.filter(m => m.matcher === '...')`. + * + * @param matches The array of `Match` objects to filter. + * @param matcherName The name of the matcher to filter by. + * @returns A new array containing only the matches from the specified matcher, + * with the `data` property correctly typed. + */ +export function filterMatchesByMatcher< + M extends Match, + N extends M['matcher'] +>(matches: readonly M[], matcherName: N): Extract[] { + return matches.filter( + (match): match is Extract => + match.matcher === matcherName + ) +} + +/** + * Groups an array of matches by their matcher name. + * + * @param matches An array of `Match` objects, typically from `scan`. + * @returns A record where keys are matcher names and values are arrays of matches + * from that matcher, with each array correctly typed. + */ +export function groupMatchesByMatcher>( + matches: readonly M[] +): Partial<{ [N in M['matcher']]: Extract[] }> { + // We use a less specific type for `grouped` internally to work around a TypeScript + // limitation where it cannot correlate the matcher name (key) with the match + // object's type (value) within the loop. The final cast is safe because the + // logic guarantees the structure of the returned object. + const grouped: Record = {} + + for (const match of matches) { + const key = match.matcher + if (!grouped[key]) { + grouped[key] = [] + } + grouped[key].push(match) + } + + return grouped as unknown as Partial<{ + [N in M['matcher']]: Extract[] + }> +} + +// --- Grapheme segmentation helpers --- +// These utilities provide consistent grapheme cluster boundaries across engines. +// They are intentionally here since they operate purely on strings. +import Graphemer from 'graphemer' + +const graphemeSplitter = new Graphemer() + +export function prevGraphemeStart(text: string, index: number): number { + if (index <= 0) return 0 + let pos = 0 + for (const cluster of graphemeSplitter.iterateGraphemes(text)) { + const next = pos + cluster.length + if (next >= index) return pos + pos = next + } + return pos +} + +export function nextGraphemeEnd(text: string, index: number): number { + if (index >= text.length) return text.length + let pos = 0 + for (const cluster of graphemeSplitter.iterateGraphemes(text)) { + const next = pos + cluster.length + if (index < next) return next + pos = next + } + return text.length +} + +export function snapGraphemeStart(text: string, index: number): number { + if (index <= 0) return 0 + let pos = 0 + for (const cluster of graphemeSplitter.iterateGraphemes(text)) { + const next = pos + cluster.length + if (index < next) return pos + pos = next + } + return pos +} + +export function snapGraphemeEnd(text: string, index: number): number { + if (index >= text.length) return text.length + let pos = 0 + for (const cluster of graphemeSplitter.iterateGraphemes(text)) { + const next = pos + cluster.length + if (index <= pos) return pos + if (index <= next) return next + pos = next + } + return pos +} diff --git a/src/inlay/portal-list.tsx b/src/inlay/portal-list.tsx new file mode 100644 index 0000000..b30d3db --- /dev/null +++ b/src/inlay/portal-list.tsx @@ -0,0 +1,348 @@ +import React, { + createContext, + useCallback, + useContext, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState +} from 'react' + +// --- Portal List Navigation Context --- + +type PortalListContextValue = { + activeIndex: number + setActiveIndex: (index: number) => void + registerItem: (value: T, disabled?: boolean) => number + unregisterItem: (index: number) => void + selectItem: (index: number) => void + loop: boolean +} + +const PortalListContext = createContext(null) + +function usePortalListContext() { + const context = useContext(PortalListContext) + if (!context) { + throw new Error('Portal.Item must be used within Portal.List') + } + return context +} + +// --- Keyboard Handler Context --- +// This context allows the parent Inlay.Root to intercept keyboard events + +export type PortalKeyboardHandler = ( + event: React.KeyboardEvent +) => boolean + +// Import the context from inlay.tsx to avoid circular dependency issues +// The context is created in inlay.tsx and exported +import { PortalKeyboardContext } from './inlay' + +export function usePortalKeyboardContext() { + return useContext(PortalKeyboardContext) +} + +// --- Portal.List Component --- + +export type PortalListProps = { + children: React.ReactNode + onSelect: (item: T, index: number) => void + onDismiss?: () => void + loop?: boolean +} & Omit, 'onSelect'> + +function PortalListInner( + props: PortalListProps, + ref: React.ForwardedRef +) { + const { children, onSelect, onDismiss, loop = true, ...divProps } = props + + const [activeIndex, setActiveIndex] = useState(0) + const itemsRef = useRef>([]) + const [, forceUpdate] = useState(0) + const keyboardContext = usePortalKeyboardContext() + + const registerItem = useCallback((value: T, disabled?: boolean) => { + const index = itemsRef.current.length + itemsRef.current.push({ value, disabled }) + forceUpdate((n) => n + 1) + return index + }, []) + + const unregisterItem = useCallback((index: number) => { + itemsRef.current.splice(index, 1) + forceUpdate((n) => n + 1) + }, []) + + const selectItem = useCallback( + (index: number) => { + const item = itemsRef.current[index] + if (item && !item.disabled) { + onSelect(item.value, index) + } + }, + [onSelect] + ) + + const navigateUp = useCallback(() => { + const items = itemsRef.current + if (items.length === 0) return + + setActiveIndex((current) => { + let nextIndex = current - 1 + if (nextIndex < 0) { + nextIndex = loop ? items.length - 1 : 0 + } + + // Skip disabled items + let attempts = 0 + while (items[nextIndex]?.disabled && attempts < items.length) { + nextIndex = nextIndex - 1 + if (nextIndex < 0) { + nextIndex = loop ? items.length - 1 : 0 + } + attempts++ + } + + return nextIndex + }) + }, [loop]) + + const navigateDown = useCallback(() => { + const items = itemsRef.current + if (items.length === 0) return + + setActiveIndex((current) => { + let nextIndex = current + 1 + if (nextIndex >= items.length) { + nextIndex = loop ? 0 : items.length - 1 + } + + // Skip disabled items + let attempts = 0 + while (items[nextIndex]?.disabled && attempts < items.length) { + nextIndex = nextIndex + 1 + if (nextIndex >= items.length) { + nextIndex = loop ? 0 : items.length - 1 + } + attempts++ + } + + return nextIndex + }) + }, [loop]) + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent): boolean => { + const items = itemsRef.current + + switch (event.key) { + case 'ArrowUp': + event.preventDefault() + navigateUp() + return true + case 'ArrowDown': + event.preventDefault() + navigateDown() + return true + case 'Enter': { + const item = items[activeIndex] + if (item && !item.disabled) { + event.preventDefault() + onSelect(item.value, activeIndex) + return true + } + return false + } + case 'Escape': + event.preventDefault() + onDismiss?.() + return true + default: + return false + } + }, + [activeIndex, navigateUp, navigateDown, onSelect, onDismiss] + ) + + // Register keyboard handler with parent context + useLayoutEffect(() => { + keyboardContext?.setHandler(handleKeyDown) + return () => { + keyboardContext?.setHandler(null) + } + }, [keyboardContext, handleKeyDown]) + + // Reset active index when items change + useEffect(() => { + const items = itemsRef.current + if (activeIndex >= items.length && items.length > 0) { + setActiveIndex(items.length - 1) + } + }, [activeIndex]) + + const contextValue = useMemo( + (): PortalListContextValue => ({ + activeIndex, + setActiveIndex, + registerItem, + unregisterItem, + selectItem, + loop + }), + [activeIndex, registerItem, unregisterItem, selectItem, loop] + ) + + return ( + + { + // Prevent focus loss from editor + e.preventDefault() + divProps.onMouseDown?.(e) + }} + onTouchStart={(e) => { + // Prevent focus loss from editor on touch devices + e.preventDefault() + divProps.onTouchStart?.(e) + }} + > + {children} + + + ) +} + +export const PortalList = React.forwardRef(PortalListInner) as ( + props: PortalListProps & { ref?: React.ForwardedRef } +) => React.ReactElement + +// --- Portal.Item Component --- + +export type PortalItemProps = { + value: T + disabled?: boolean + children: React.ReactNode +} & Omit, 'value'> + +function PortalItemInner( + props: PortalItemProps, + ref: React.ForwardedRef +) { + const { value, disabled = false, children, ...divProps } = props + const context = usePortalListContext() + const [index, setIndex] = useState(-1) + const touchStartPosRef = useRef<{ x: number; y: number } | null>(null) + + // Register this item on mount + useLayoutEffect(() => { + const idx = context.registerItem(value, disabled) + setIndex(idx) + return () => { + context.unregisterItem(idx) + } + }, []) + + const isActive = context.activeIndex === index + + const handleClick = useCallback( + (e: React.MouseEvent) => { + if (!disabled && index >= 0) { + context.selectItem(index) + } + divProps.onClick?.(e) + }, + [disabled, context, divProps, index] + ) + + const handleMouseEnter = useCallback( + (e: React.MouseEvent) => { + if (!disabled && index >= 0) { + context.setActiveIndex(index) + } + divProps.onMouseEnter?.(e) + }, + [disabled, context, divProps, index] + ) + + // Touch handlers for mobile - activate on touch, select on release + const handleTouchStart = useCallback( + (e: React.TouchEvent) => { + const touch = e.touches[0] + if (touch) { + touchStartPosRef.current = { x: touch.clientX, y: touch.clientY } + } + // Activate item on touch start (like hover on desktop) + if (!disabled && index >= 0) { + context.setActiveIndex(index) + } + divProps.onTouchStart?.(e) + }, + [disabled, context, divProps, index] + ) + + const handleTouchEnd = useCallback( + (e: React.TouchEvent) => { + const touch = e.changedTouches[0] + const startPos = touchStartPosRef.current + + // Only select if touch didn't move significantly (not a scroll gesture) + if (touch && startPos) { + const dx = Math.abs(touch.clientX - startPos.x) + const dy = Math.abs(touch.clientY - startPos.y) + + // If movement was minimal, treat as a tap and select + if (dx < 10 && dy < 10) { + if (!disabled && index >= 0) { + // Prevent the subsequent click event + e.preventDefault() + context.selectItem(index) + } + } + } + + touchStartPosRef.current = null + divProps.onTouchEnd?.(e) + }, + [disabled, context, divProps, index] + ) + + return ( + + {children} + + ) +} + +export const PortalItem = React.forwardRef(PortalItemInner) as ( + props: PortalItemProps