diff --git a/package.json b/package.json index cca0172..d1693e2 100644 --- a/package.json +++ b/package.json @@ -13,11 +13,14 @@ "types:check": "tsc --noEmit" }, "devDependencies": { + "@base-ui/react": "^1.4.0", "@eslint/js": "^9.19.0", "@laravel/vite-plugin-wayfinder": "^0.1.3", "@stylistic/eslint-plugin": "^5.10.0", + "@types/lodash.throttle": "^4.1.9", "@types/node": "^22.13.5", "babel-plugin-react-compiler": "^1.0.0", + "class-variance-authority": "^0.7.1", "eslint": "^9.17.0", "eslint-config-prettier": "^10.0.1", "eslint-import-resolver-typescript": "^4.4.4", @@ -26,9 +29,11 @@ "eslint-plugin-react-hooks": "^7.0.0", "prettier": "^3.4.2", "prettier-plugin-tailwindcss": "^0.6.11", + "sass-embedded": "^1.99.0", "typescript-eslint": "^8.23.0" }, "dependencies": { + "@floating-ui/react": "^0.27.19", "@fontsource-variable/inter": "^5.2.8", "@headlessui/react": "^2.2.0", "@inertiajs/react": "^3.0.0", @@ -40,6 +45,7 @@ "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-label": "^2.1.2", "@radix-ui/react-navigation-menu": "^1.2.5", + "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-select": "^2.1.6", "@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-slot": "^1.2.3", @@ -48,20 +54,34 @@ "@radix-ui/react-tooltip": "^1.1.8", "@tailwindcss/vite": "^4.1.11", "@tanstack/react-table": "^8.21.3", + "@tiptap/core": "^3.22.3", + "@tiptap/extension-highlight": "^3.22.3", + "@tiptap/extension-horizontal-rule": "^3.22.3", + "@tiptap/extension-image": "^3.22.3", + "@tiptap/extension-list": "^3.22.3", + "@tiptap/extension-subscript": "^3.22.3", + "@tiptap/extension-superscript": "^3.22.3", + "@tiptap/extension-text-align": "^3.22.3", + "@tiptap/extension-typography": "^3.22.3", + "@tiptap/extensions": "^3.22.3", + "@tiptap/pm": "^3.22.3", + "@tiptap/react": "^3.22.3", + "@tiptap/starter-kit": "^3.22.3", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "@vitejs/plugin-react": "^5.2.0", - "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "concurrently": "^9.0.1", "globals": "^15.14.0", "input-otp": "^1.4.2", "laravel-vite-plugin": "^3.0.0", + "lodash.throttle": "^4.1.1", "lucide-react": "^0.475.0", "next-themes": "^0.4.6", "radix-ui": "^1.4.3", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-hotkeys-hook": "^5.2.4", "shadcn": "^4.2.0", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 26cb15b..ba4b2dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@floating-ui/react': + specifier: ^0.27.19 + version: 0.27.19(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@fontsource-variable/inter': specifier: ^5.2.8 version: 5.2.8 @@ -19,7 +22,7 @@ importers: version: 3.0.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@inertiajs/vite': specifier: ^3.0.0 - version: 3.0.3(vite@8.0.8(@types/node@22.19.17)(jiti@2.6.1)) + version: 3.0.3(vite@8.0.8(@types/node@22.19.17)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)) '@radix-ui/react-avatar': specifier: ^1.1.3 version: 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -41,6 +44,9 @@ importers: '@radix-ui/react-navigation-menu': specifier: ^1.2.5 version: 1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-popover': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@radix-ui/react-select': specifier: ^2.1.6 version: 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -61,10 +67,49 @@ importers: version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@tailwindcss/vite': specifier: ^4.1.11 - version: 4.2.2(vite@8.0.8(@types/node@22.19.17)(jiti@2.6.1)) + version: 4.2.2(vite@8.0.8(@types/node@22.19.17)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)) '@tanstack/react-table': specifier: ^8.21.3 version: 8.21.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@tiptap/core': + specifier: ^3.22.3 + version: 3.22.3(@tiptap/pm@3.22.3) + '@tiptap/extension-highlight': + specifier: ^3.22.3 + version: 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3)) + '@tiptap/extension-horizontal-rule': + specifier: ^3.22.3 + version: 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3) + '@tiptap/extension-image': + specifier: ^3.22.3 + version: 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3)) + '@tiptap/extension-list': + specifier: ^3.22.3 + version: 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3) + '@tiptap/extension-subscript': + specifier: ^3.22.3 + version: 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3) + '@tiptap/extension-superscript': + specifier: ^3.22.3 + version: 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3) + '@tiptap/extension-text-align': + specifier: ^3.22.3 + version: 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3)) + '@tiptap/extension-typography': + specifier: ^3.22.3 + version: 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3)) + '@tiptap/extensions': + specifier: ^3.22.3 + version: 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3) + '@tiptap/pm': + specifier: ^3.22.3 + version: 3.22.3 + '@tiptap/react': + specifier: ^3.22.3 + version: 3.22.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@tiptap/starter-kit': + specifier: ^3.22.3 + version: 3.22.3 '@types/react': specifier: ^19.2.0 version: 19.2.14 @@ -73,10 +118,7 @@ importers: version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': specifier: ^5.2.0 - version: 5.2.0(vite@8.0.8(@types/node@22.19.17)(jiti@2.6.1)) - class-variance-authority: - specifier: ^0.7.1 - version: 0.7.1 + version: 5.2.0(vite@8.0.8(@types/node@22.19.17)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -91,7 +133,10 @@ importers: version: 1.4.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5) laravel-vite-plugin: specifier: ^3.0.0 - version: 3.0.1(vite@8.0.8(@types/node@22.19.17)(jiti@2.6.1)) + version: 3.0.1(vite@8.0.8(@types/node@22.19.17)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)) + lodash.throttle: + specifier: ^4.1.1 + version: 4.1.1 lucide-react: specifier: ^0.475.0 version: 0.475.0(react@19.2.5) @@ -107,6 +152,9 @@ importers: react-dom: specifier: ^19.2.0 version: 19.2.5(react@19.2.5) + react-hotkeys-hook: + specifier: ^5.2.4 + version: 5.2.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5) shadcn: specifier: ^4.2.0 version: 4.2.0(@types/node@22.19.17)(typescript@5.9.3) @@ -127,8 +175,11 @@ importers: version: 5.9.3 vite: specifier: ^8.0.0 - version: 8.0.8(@types/node@22.19.17)(jiti@2.6.1) + version: 8.0.8(@types/node@22.19.17)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0) devDependencies: + '@base-ui/react': + specifier: ^1.4.0 + version: 1.4.0(@date-fns/tz@1.4.1)(@types/react@19.2.14)(date-fns@4.1.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@eslint/js': specifier: ^9.19.0 version: 9.39.4 @@ -138,12 +189,18 @@ importers: '@stylistic/eslint-plugin': specifier: ^5.10.0 version: 5.10.0(eslint@9.39.4(jiti@2.6.1)) + '@types/lodash.throttle': + specifier: ^4.1.9 + version: 4.1.9 '@types/node': specifier: ^22.13.5 version: 22.19.17 babel-plugin-react-compiler: specifier: ^1.0.0 version: 1.0.0 + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 eslint: specifier: ^9.17.0 version: 9.39.4(jiti@2.6.1) @@ -168,6 +225,9 @@ importers: prettier-plugin-tailwindcss: specifier: ^0.6.11 version: 0.6.14(prettier@3.8.2) + sass-embedded: + specifier: ^1.99.0 + version: 1.99.0 typescript-eslint: specifier: ^8.23.0 version: 8.58.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) @@ -322,6 +382,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} @@ -334,6 +398,35 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@base-ui/react@1.4.0': + resolution: {integrity: sha512-QcqdVbr/+ba2/RAKJIV1PV6S02Q5+r6a4Eym8ndBw+ZbBILkkmQAyRxXCg/pArrHnkrGeU8goe26aw0h6eE8pg==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@date-fns/tz': ^1.2.0 + '@types/react': ^17 || ^18 || ^19 + date-fns: ^4.0.0 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + + '@base-ui/utils@0.2.7': + resolution: {integrity: sha512-nXYKhiL/0JafyJE8PfcflipGftOftlIwKd72rU15iZ1M5yqgg5J9P8NHU71GReDuXco5MJA/eVQqUT5WRqX9sA==} + peerDependencies: + '@types/react': ^17 || ^18 || ^19 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + + '@bufbuild/protobuf@2.11.0': + resolution: {integrity: sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==} + + '@date-fns/tz@1.4.1': + resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==} + '@dotenvx/dotenvx@1.61.0': resolution: {integrity: sha512-utL3cpZoFzflyqUkjYbxYujI6STBTmO5LFn4bbin/NZnRWN6wQ7eErhr3/Vpa5h/jicPFC6kTa42r940mQftJQ==} hasBin: true @@ -409,6 +502,12 @@ packages: react: '>=16.8.0' react-dom: '>=16.8.0' + '@floating-ui/react@0.27.19': + resolution: {integrity: sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==} + peerDependencies: + react: '>=17.0.0' + react-dom: '>=17.0.0' + '@floating-ui/utils@0.2.11': resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} @@ -576,6 +675,94 @@ packages: '@oxc-project/types@0.124.0': resolution: {integrity: sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==} + '@parcel/watcher-android-arm64@2.5.6': + resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [android] + + '@parcel/watcher-darwin-arm64@2.5.6': + resolution: {integrity: sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [darwin] + + '@parcel/watcher-darwin-x64@2.5.6': + resolution: {integrity: sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [darwin] + + '@parcel/watcher-freebsd-x64@2.5.6': + resolution: {integrity: sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [freebsd] + + '@parcel/watcher-linux-arm-glibc@2.5.6': + resolution: {integrity: sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-arm-musl@2.5.6': + resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + libc: [musl] + + '@parcel/watcher-linux-arm64-glibc@2.5.6': + resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-arm64-musl@2.5.6': + resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@parcel/watcher-linux-x64-glibc@2.5.6': + resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-x64-musl@2.5.6': + resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@parcel/watcher-win32-arm64@2.5.6': + resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [win32] + + '@parcel/watcher-win32-ia32@2.5.6': + resolution: {integrity: sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==} + engines: {node: '>= 10.0.0'} + cpu: [ia32] + os: [win32] + + '@parcel/watcher-win32-x64@2.5.6': + resolution: {integrity: sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [win32] + + '@parcel/watcher@2.5.6': + resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} + engines: {node: '>= 10.0.0'} + '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -1373,6 +1560,9 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + '@remirror/core-constants@3.0.0': + resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==} + '@rolldown/binding-android-arm64@1.0.0-rc.15': resolution: {integrity: sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1618,6 +1808,187 @@ packages: '@tanstack/virtual-core@3.13.23': resolution: {integrity: sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==} + '@tiptap/core@3.22.3': + resolution: {integrity: sha512-Dv9MKK5BDWCF0N2l6/Pxv3JNCce2kwuWf2cKMBc2bEetx0Pn6o7zlFmSxMvYK4UtG1Tw9Yg/ZHi6QOFWK0Zm9Q==} + peerDependencies: + '@tiptap/pm': ^3.22.3 + + '@tiptap/extension-blockquote@3.22.3': + resolution: {integrity: sha512-IaUx3zh7yLHXzIXKL+fw/jzFhsIImdhJyw0lMhe8FfYrefFqXJFYW/sey6+L/e8B3AWvTksPA6VBwefzbH77JA==} + peerDependencies: + '@tiptap/core': ^3.22.3 + + '@tiptap/extension-bold@3.22.3': + resolution: {integrity: sha512-tysipHla2zCWr8XNIWRaW9O+7i7/SoEqnRqSRUUi2ailcJjlia+RBy3RykhkgyThrQDStu5KGBS/UvrXwA+O1A==} + peerDependencies: + '@tiptap/core': ^3.22.3 + + '@tiptap/extension-bubble-menu@3.22.3': + resolution: {integrity: sha512-Y6zQjh0ypDg32HWgICEvmPSKjGLr39k3aDxxt/H0uQEZSfw4smT0hxUyyyjVjx68C6t6MTnwdfz0hPI5lL68vQ==} + peerDependencies: + '@tiptap/core': ^3.22.3 + '@tiptap/pm': ^3.22.3 + + '@tiptap/extension-bullet-list@3.22.3': + resolution: {integrity: sha512-xOmW/b1hgECIE6r3IeZvKn4VVlG3+dfTjCWE6lnnyLaqdNkNhKS1CwUmDZdYNLUS2ryIUtgz5ID1W/8A3PhbiA==} + peerDependencies: + '@tiptap/extension-list': ^3.22.3 + + '@tiptap/extension-code-block@3.22.3': + resolution: {integrity: sha512-RiQtEjDAPrHpdo6sw6b7fOw/PijqgFIsozKKkGcSeBgWHQuFg7q9OxJTj+l0e60rVwSu/5gmKEEobzM9bX+t2Q==} + peerDependencies: + '@tiptap/core': ^3.22.3 + '@tiptap/pm': ^3.22.3 + + '@tiptap/extension-code@3.22.3': + resolution: {integrity: sha512-wafWTDQOuMKtXpZEuk1PFQmzopabBciNLryL90MB9S03MNLaQQZYLnmYkDBlzAaLAbgF5QiC+2XZQEBQuTVjFQ==} + peerDependencies: + '@tiptap/core': ^3.22.3 + + '@tiptap/extension-document@3.22.3': + resolution: {integrity: sha512-MCSr1PFPtTd++lA3H1RNgqAczAE59XXJ5wUFIQf2F+/0DPY5q2SU4g5QsNJVxPPft5mrNT4C6ty8xBPrALFEdA==} + peerDependencies: + '@tiptap/core': ^3.22.3 + + '@tiptap/extension-dropcursor@3.22.3': + resolution: {integrity: sha512-taXq9Tl5aybdFbptJtFRHX9LFJzbXphAbPp4/vutFyTrBu5meXDxuS+B9pEmE+Or0XcolTlW2nDZB0Tqnr18JQ==} + peerDependencies: + '@tiptap/extensions': ^3.22.3 + + '@tiptap/extension-floating-menu@3.22.3': + resolution: {integrity: sha512-0f8b4KZ3XKai8GXWseIYJGdOfQr3evtFbBo3U08zy2aYzMMXWG0zEF7qe5/oiYp2aZ95edjjITnEceviTsZkIg==} + peerDependencies: + '@floating-ui/dom': ^1.0.0 + '@tiptap/core': ^3.22.3 + '@tiptap/pm': ^3.22.3 + + '@tiptap/extension-gapcursor@3.22.3': + resolution: {integrity: sha512-L/Px4UeQEVG/D9WIlcAOIej+4wyIBCMUSYicSR+hW68UsObe4rxVbUas1QgidQKm6DOhoT7U7D4KQHA/Gdg/7A==} + peerDependencies: + '@tiptap/extensions': ^3.22.3 + + '@tiptap/extension-hard-break@3.22.3': + resolution: {integrity: sha512-J0v8I99y9tbvVmgKYKzKP/JYNsWaZYS7avn4rzLft2OhnyTfwt3OoY8DtpHmmi6apSUaCtoWHWta/TmoEfK1nQ==} + peerDependencies: + '@tiptap/core': ^3.22.3 + + '@tiptap/extension-heading@3.22.3': + resolution: {integrity: sha512-XBHuhiEV2EEhZHpOLcplLqAmBIhJciU3I6AtwmqeEqDC0P114uMEfAO7JGlbBZdCYotNer26PKnu44TBTeNtkw==} + peerDependencies: + '@tiptap/core': ^3.22.3 + + '@tiptap/extension-highlight@3.22.3': + resolution: {integrity: sha512-iGDzQ3IuVQpfQcWsMEQ0B8q3R83bZZH6l6O2MuCmWbzm/p7mMi5vQwRCMLAbM9xFELq8KjDMHOWeER4fozp/Sg==} + peerDependencies: + '@tiptap/core': ^3.22.3 + + '@tiptap/extension-horizontal-rule@3.22.3': + resolution: {integrity: sha512-wI2bFzScs+KgWeBH/BtypcVKeYelCyqV0RG8nxsZMWtPrBhqixzNd0Oi3gEKtjSjKUqMQ/kjJAIRuESr5UzlHA==} + peerDependencies: + '@tiptap/core': ^3.22.3 + '@tiptap/pm': ^3.22.3 + + '@tiptap/extension-image@3.22.3': + resolution: {integrity: sha512-Qpp8c5LOQaNpHrzjqZtoxtIR+8sSqJ7k8v+8anmYw3nxjvt2kpfT28Vd7aWMX55ZS43LaxMx+MkZqbmgUmMP0w==} + peerDependencies: + '@tiptap/core': ^3.22.3 + + '@tiptap/extension-italic@3.22.3': + resolution: {integrity: sha512-LteA4cb4EGCiUtrK2JHvDF/Zg0/YqV4DUyHhAAho+oGEQDupZlsS6m0ia5wQcclkiTLzsoPrwcSNu6RDGQ16wQ==} + peerDependencies: + '@tiptap/core': ^3.22.3 + + '@tiptap/extension-link@3.22.3': + resolution: {integrity: sha512-S8/P2o9pv6B3kqLjH2TRWwSAximGbciNc6R8/QcN6HWLYxp0N0JoqN3rZHl9VWIBAGRWc4zkt80dhqrl2xmgfQ==} + peerDependencies: + '@tiptap/core': ^3.22.3 + '@tiptap/pm': ^3.22.3 + + '@tiptap/extension-list-item@3.22.3': + resolution: {integrity: sha512-80CNf4oO5y8+LdckT4CyMe1t01EyhpRrQC9H45JW20P7559Nrchp5my3vvMtIAJbpTPPZtcB7LwdzWGKsG5drg==} + peerDependencies: + '@tiptap/extension-list': ^3.22.3 + + '@tiptap/extension-list-keymap@3.22.3': + resolution: {integrity: sha512-pKuyj5llu35zd/s2u/H9aydKZjmPRAIK5P1q/YXULhhCNln2RnmuRfQ5NklAqTD3yGciQ2lxDwwf7J6iw3ergA==} + peerDependencies: + '@tiptap/extension-list': ^3.22.3 + + '@tiptap/extension-list@3.22.3': + resolution: {integrity: sha512-rqvv/dtqwbX+8KnPv0eMYp6PnBcuhPMol5cv1GlS8Nq/Cxt68EWGUHBuTFesw+hdnRQLmKwzoO1DlRn7PhxYRQ==} + peerDependencies: + '@tiptap/core': ^3.22.3 + '@tiptap/pm': ^3.22.3 + + '@tiptap/extension-ordered-list@3.22.3': + resolution: {integrity: sha512-orAghtmd+K4Euu4BgI1hG+iZDXBYOyl5YTwiLBc2mQn+pqtZ9LqaH2us4ETwEwNP3/IWXGSAimUZ19nuL+eM2w==} + peerDependencies: + '@tiptap/extension-list': ^3.22.3 + + '@tiptap/extension-paragraph@3.22.3': + resolution: {integrity: sha512-oO7rhfyhEuwm+50s9K3GZPjYyEEEvFAvm1wXopvZnhbkBLydIWImBfrZoC5IQh4/sRDlTIjosV2C+ji5y0tUSg==} + peerDependencies: + '@tiptap/core': ^3.22.3 + + '@tiptap/extension-strike@3.22.3': + resolution: {integrity: sha512-jY2InoUlKkuk5KHoIDGdML1OCA2n6PRHAtxwHNkAmiYh0Khf0zaVPGFpx4dgQrN7W5Q1WE6oBZnjrvy6qb7w0g==} + peerDependencies: + '@tiptap/core': ^3.22.3 + + '@tiptap/extension-subscript@3.22.3': + resolution: {integrity: sha512-fcHinQMye6tPSxTB74CLW4Z/HbA9FX4epKOQ0ld0wIFsQB2bVz0NH9FfwrTEsYWLjeFWkDcL41VOReZXqKz1jQ==} + peerDependencies: + '@tiptap/core': ^3.22.3 + '@tiptap/pm': ^3.22.3 + + '@tiptap/extension-superscript@3.22.3': + resolution: {integrity: sha512-q+yPA+vbvaqiVGTJddjBhvWgqLXu6j39knz3TgJKw4nBuvoAXWrlWtgOy44wwIIs8vILI4wENoUuoI0+vOzynA==} + peerDependencies: + '@tiptap/core': ^3.22.3 + '@tiptap/pm': ^3.22.3 + + '@tiptap/extension-text-align@3.22.3': + resolution: {integrity: sha512-dG1NHE0yGf7fYiOdabCJuecI2IJ1uogyY/QvZqvPNaxRjZDoXYuGlMtz9jEDiIdQSaPED2MSsS7KkuNFQIEMGg==} + peerDependencies: + '@tiptap/core': ^3.22.3 + + '@tiptap/extension-text@3.22.3': + resolution: {integrity: sha512-Q9R7JsTdomP5uUjtPjNKxHT1xoh/i9OJZnmgJLe7FcgZEaPOQ3bWxmKZoLZQfDfZjyB8BtH+Hc7nUvhCMOePxw==} + peerDependencies: + '@tiptap/core': ^3.22.3 + + '@tiptap/extension-typography@3.22.3': + resolution: {integrity: sha512-QQv1KUHsr3LTGhtPIG/ZP15Cj5VmI5ChgK4Q7UZ7JZBppBKAeMnyAnOcO2ith+qZ8qp5s+UiQ7Unp0zP1VSYkg==} + peerDependencies: + '@tiptap/core': ^3.22.3 + + '@tiptap/extension-underline@3.22.3': + resolution: {integrity: sha512-Ch6CBWRa5w90yYSPUW6x9Py9JdrXMqk3pZ9OIlMYD8A7BqyZGfiHerX7XDMYDS09KjyK3U9XH60/zxYOzXdDLA==} + peerDependencies: + '@tiptap/core': ^3.22.3 + + '@tiptap/extensions@3.22.3': + resolution: {integrity: sha512-s5eiMq0m5N6N+W7dU6rd60KgZyyCD7FvtPNNswISfPr12EQwJBfbjWwTqd0UKNzA4fNrhQEERXnzORkykttPeA==} + peerDependencies: + '@tiptap/core': ^3.22.3 + '@tiptap/pm': ^3.22.3 + + '@tiptap/pm@3.22.3': + resolution: {integrity: sha512-NjfWjZuvrqmpICT+GZWNIjtOdhPyqFKDMtQy7tsQ5rErM9L2ZQdy/+T/BKSO1JdTeBhdg9OP+0yfsqoYp2aT6A==} + + '@tiptap/react@3.22.3': + resolution: {integrity: sha512-6MNr6z0PxwfJFs+BKhHcvPNvY+UV1PXgqzTiTM4Z9guml84iVZxv7ZOCSj1dFYTr3Bf1MiOs4hT1yvBFlTfIaQ==} + peerDependencies: + '@tiptap/core': ^3.22.3 + '@tiptap/pm': ^3.22.3 + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + '@types/react-dom': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tiptap/starter-kit@3.22.3': + resolution: {integrity: sha512-vdW/Oo1fdwTL1VOQ5YYbTov00ANeHLquBVEZyL/EkV7Xv5io9rXQsCysJfTSHhiQlyr2MtWFB4+CPGuwXjQWOQ==} + '@ts-morph/common@0.27.0': resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} @@ -1645,6 +2016,21 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + + '@types/lodash.throttle@4.1.9': + resolution: {integrity: sha512-PCPVfpfueguWZQB7pJQK890F2scYKoDUL3iM522AptHWn7d5NQmeS/LTEHIcLr5PaTzl3dK2Z0xSUHHTHwaL5g==} + + '@types/lodash@4.17.24': + resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==} + + '@types/markdown-it@14.1.2': + resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + '@types/node@22.19.17': resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} @@ -1659,6 +2045,9 @@ packages: '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@types/validate-npm-package-name@4.0.2': resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==} @@ -1995,6 +2384,10 @@ packages: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} @@ -2028,6 +2421,9 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + colorjs.io@0.5.2: + resolution: {integrity: sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==} + commander@11.1.0: resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} engines: {node: '>=16'} @@ -2080,6 +2476,9 @@ packages: typescript: optional: true + crelt@1.0.6: + resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -2108,6 +2507,9 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} + date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: @@ -2211,6 +2613,10 @@ packages: resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} engines: {node: '>=10.13.0'} + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -2422,6 +2828,10 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-equals@5.4.0: + resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==} + engines: {node: '>=6.0.0'} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -2662,6 +3072,9 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} + immutable@5.1.5: + resolution: {integrity: sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -3031,6 +3444,12 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + linkify-it@5.0.0: + resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + + linkifyjs@4.3.2: + resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -3038,6 +3457,9 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.throttle@4.1.1: + resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==} + log-symbols@6.0.0: resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} engines: {node: '>=18'} @@ -3057,10 +3479,17 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + markdown-it@14.1.1: + resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdurl@2.0.0: + resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + media-typer@1.1.0: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} @@ -3146,6 +3575,9 @@ packages: react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} @@ -3233,6 +3665,9 @@ packages: resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} engines: {node: '>=18'} + orderedmap@2.1.1: + resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} + outvariant@1.4.3: resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} @@ -3400,10 +3835,72 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + prosemirror-changeset@2.4.1: + resolution: {integrity: sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==} + + prosemirror-collab@1.3.1: + resolution: {integrity: sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==} + + prosemirror-commands@1.7.1: + resolution: {integrity: sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==} + + prosemirror-dropcursor@1.8.2: + resolution: {integrity: sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==} + + prosemirror-gapcursor@1.4.1: + resolution: {integrity: sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==} + + prosemirror-history@1.5.0: + resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==} + + prosemirror-inputrules@1.5.1: + resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==} + + prosemirror-keymap@1.2.3: + resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==} + + prosemirror-markdown@1.13.4: + resolution: {integrity: sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==} + + prosemirror-menu@1.3.1: + resolution: {integrity: sha512-2OSIKBFyLo2iqDpjQHEC7tKt3lluhY7L44pcRai8EpoU9R7cZDj/dklEsOOIubNKWUXab6dL7y4JtAWnrlR4lA==} + + prosemirror-model@1.25.4: + resolution: {integrity: sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==} + + prosemirror-schema-basic@1.2.4: + resolution: {integrity: sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==} + + prosemirror-schema-list@1.5.1: + resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==} + + prosemirror-state@1.4.4: + resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==} + + prosemirror-tables@1.8.5: + resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==} + + prosemirror-trailing-node@3.0.0: + resolution: {integrity: sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==} + peerDependencies: + prosemirror-model: ^1.22.1 + prosemirror-state: ^1.4.2 + prosemirror-view: ^1.33.8 + + prosemirror-transform@1.12.0: + resolution: {integrity: sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==} + + prosemirror-view@1.41.8: + resolution: {integrity: sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -3441,6 +3938,12 @@ packages: peerDependencies: react: ^19.2.5 + react-hotkeys-hook@5.2.4: + resolution: {integrity: sha512-BgKg+A1+TawkYluh5Bo4cTmcgMN5L29uhJbDUQdHwPX+qgXRjIPYU5kIDHyxnAwCkCBiu9V5OpB2mpyeluVF2A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -3482,6 +3985,10 @@ packages: resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==} engines: {node: '>=0.10.0'} + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + recast@0.23.11: resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} engines: {node: '>= 4'} @@ -3502,6 +4009,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + reselect@5.1.1: + resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -3530,6 +4040,9 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + rope-sequence@1.3.4: + resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -3559,6 +4072,128 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + sass-embedded-all-unknown@1.99.0: + resolution: {integrity: sha512-qPIRG8Uhjo6/OKyAKixTnwMliTz+t9K6Duk0mx5z+K7n0Ts38NSJz2sjDnc7cA/8V9Lb3q09H38dZ1CLwD+ssw==} + cpu: ['!arm', '!arm64', '!riscv64', '!x64'] + + sass-embedded-android-arm64@1.99.0: + resolution: {integrity: sha512-fNHhdnP23yqqieCbAdym4N47AleSwjbNt6OYIYx4DdACGdtERjQB4iOX/TaKsW034MupfF7SjnAAK8w7Ptldtg==} + engines: {node: '>=14.0.0'} + cpu: [arm64] + os: [android] + + sass-embedded-android-arm@1.99.0: + resolution: {integrity: sha512-EHvJ0C7/VuP78Qr6f8gIUVUmCqIorEQpw2yp3cs3SMg02ZuumlhjXvkTcFBxHmFdFR23vTNk1WnhY6QSeV1nFQ==} + engines: {node: '>=14.0.0'} + cpu: [arm] + os: [android] + + sass-embedded-android-riscv64@1.99.0: + resolution: {integrity: sha512-4zqDFRvgGDTL5vTHuIhRxUpXFoh0Cy7Gm5Ywk19ASd8Settmd14YdPRZPmMxfgS1GH292PofV1fq1ifiSEJWBw==} + engines: {node: '>=14.0.0'} + cpu: [riscv64] + os: [android] + + sass-embedded-android-x64@1.99.0: + resolution: {integrity: sha512-Uk53k/dGYt04RjOL4gFjZ0Z9DH9DKh8IA8WsXUkNqsxerAygoy3zqRBS2zngfE9K2jiOM87q+1R1p87ory9oQQ==} + engines: {node: '>=14.0.0'} + cpu: [x64] + os: [android] + + sass-embedded-darwin-arm64@1.99.0: + resolution: {integrity: sha512-u61/7U3IGLqoO6gL+AHeiAtlTPFwJK1+964U8gp45ZN0hzh1yrARf5O1mivXv8NnNgJvbG2wWJbiNZP0lG/lTg==} + engines: {node: '>=14.0.0'} + cpu: [arm64] + os: [darwin] + + sass-embedded-darwin-x64@1.99.0: + resolution: {integrity: sha512-j/kkk/NcXdIameLezSfXjgCiBkVcA+G60AXrX768/3g0miK1g7M9dj7xOhCb1i7/wQeiEI3rw2LLuO63xRIn4A==} + engines: {node: '>=14.0.0'} + cpu: [x64] + os: [darwin] + + sass-embedded-linux-arm64@1.99.0: + resolution: {integrity: sha512-btNcFpItcB56L40n8hDeL7sRSMLDXQ56nB5h2deddJx1n60rpKSElJmkaDGHtpkrY+CTtDRV0FZDjHeTJddYew==} + engines: {node: '>=14.0.0'} + cpu: [arm64] + os: [linux] + libc: glibc + + sass-embedded-linux-arm@1.99.0: + resolution: {integrity: sha512-d4IjJZrX2+AwB2YCy1JySwdptJECNP/WfAQLUl8txI3ka8/d3TUI155GtelnoZUkio211PwIeFvvAeZ9RXPQnw==} + engines: {node: '>=14.0.0'} + cpu: [arm] + os: [linux] + libc: glibc + + sass-embedded-linux-musl-arm64@1.99.0: + resolution: {integrity: sha512-Hi2bt/IrM5P4FBKz6EcHAlniwfpoz9mnTdvSd58y+avA3SANM76upIkAdSayA8ZGwyL3gZokru1AKDPF9lJDNw==} + engines: {node: '>=14.0.0'} + cpu: [arm64] + os: [linux] + libc: musl + + sass-embedded-linux-musl-arm@1.99.0: + resolution: {integrity: sha512-2gvHOupgIw3ytatXT4nFUow71LFbuOZPEwG+HUzcNQDH8ue4Ez8cr03vsv5MDv3lIjOKcXwDvWD980t18MwkoQ==} + engines: {node: '>=14.0.0'} + cpu: [arm] + os: [linux] + libc: musl + + sass-embedded-linux-musl-riscv64@1.99.0: + resolution: {integrity: sha512-mKqGvVaJ9rHMqyZsF0kikQe4NO0f4osb67+X6nLhBiVDKvyazQHJ3zJQreNefIE36yL2sjHIclSB//MprzaQDg==} + engines: {node: '>=14.0.0'} + cpu: [riscv64] + os: [linux] + libc: musl + + sass-embedded-linux-musl-x64@1.99.0: + resolution: {integrity: sha512-huhgOMmOc30r7CH7qbRbT9LerSEGSnWuS4CYNOskr9BvNeQp4dIneFufNRGZ7hkOAxUM8DglxIZJN/cyAT95Ew==} + engines: {node: '>=14.0.0'} + cpu: [x64] + os: [linux] + libc: musl + + sass-embedded-linux-riscv64@1.99.0: + resolution: {integrity: sha512-mevFPIFAVhrH90THifxLfOntFmHtcEKOcdWnep2gJ0X4DVva4AiVIRlQe/7w9JFx5+gnDRE1oaJJkzuFUuYZsA==} + engines: {node: '>=14.0.0'} + cpu: [riscv64] + os: [linux] + libc: glibc + + sass-embedded-linux-x64@1.99.0: + resolution: {integrity: sha512-9k7IkULqIZdCIVt4Mboryt6vN8Mjmm3EhI1P3mClU5y5i3wLK5ExC3cbVWk047KsID/fvB1RLslqghXJx5BoxA==} + engines: {node: '>=14.0.0'} + cpu: [x64] + os: [linux] + libc: glibc + + sass-embedded-unknown-all@1.99.0: + resolution: {integrity: sha512-P7MxiUtL/XzGo3PX0CaB8lNNEFLQWKikPA8pbKytx9ZCLZSDkt2NJcdAbblB/sqMs4AV3EK2NadV8rI/diq3xg==} + os: ['!android', '!darwin', '!linux', '!win32'] + + sass-embedded-win32-arm64@1.99.0: + resolution: {integrity: sha512-8whpsW7S+uO8QApKfQuc36m3P9EISzbVZOgC79goob4qGy09u8Gz/rYvw8h1prJDSjltpHGhOzBE6LDz7WvzVw==} + engines: {node: '>=14.0.0'} + cpu: [arm64] + os: [win32] + + sass-embedded-win32-x64@1.99.0: + resolution: {integrity: sha512-ipuOv1R2K4MHeuCEAZGpuUbAgma4gb0sdacyrTjJtMOy/OY9UvWfVlwErdB09KIkp4fPDpQJDJfvYN6bC8jeNg==} + engines: {node: '>=14.0.0'} + cpu: [x64] + os: [win32] + + sass-embedded@1.99.0: + resolution: {integrity: sha512-gF/juR1aX02lZHkvwxdF80SapkQeg2fetoDF6gIQkNbSw5YEUFspMkyGTjPjgZSgIHuZpy+Wz4PlebKnLXMjdg==} + engines: {node: '>=16.0.0'} + hasBin: true + + sass@1.99.0: + resolution: {integrity: sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==} + engines: {node: '>=14.0.0'} + hasBin: true + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -3736,6 +4371,14 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + sync-child-process@1.0.2: + resolution: {integrity: sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==} + engines: {node: '>=16.0.0'} + + sync-message-port@1.2.0: + resolution: {integrity: sha512-gAQ9qrUN/UCypHtGFbbe7Rc/f9bzO88IwrG8TDo/aMKAApKyD6E3W4Cm0EfhfBb6Z6SKt59tTCTfD+n1xmAvMg==} + engines: {node: '>=16.0.0'} + tabbable@6.4.0: resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} @@ -3845,6 +4488,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} @@ -3911,6 +4557,9 @@ packages: resolution: {integrity: sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==} engines: {node: ^20.17.0 || >=22.9.0} + varint@6.0.0: + resolution: {integrity: sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==} + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -3961,6 +4610,9 @@ packages: yaml: optional: true + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + web-streams-polyfill@3.3.3: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} @@ -4233,6 +4885,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/runtime@7.29.2': {} + '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 @@ -4256,6 +4910,35 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@base-ui/react@1.4.0(@date-fns/tz@1.4.1)(@types/react@19.2.14)(date-fns@4.1.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@babel/runtime': 7.29.2 + '@base-ui/utils': 0.2.7(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@date-fns/tz': 1.4.1 + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@floating-ui/utils': 0.2.11 + date-fns: 4.1.0 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + use-sync-external-store: 1.6.0(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + + '@base-ui/utils@0.2.7(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@babel/runtime': 7.29.2 + '@floating-ui/utils': 0.2.11 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + reselect: 5.1.1 + use-sync-external-store: 1.6.0(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + + '@bufbuild/protobuf@2.11.0': {} + + '@date-fns/tz@1.4.1': {} + '@dotenvx/dotenvx@1.61.0': dependencies: commander: 11.1.0 @@ -4358,6 +5041,14 @@ snapshots: react-dom: 19.2.5(react@19.2.5) tabbable: 6.4.0 + '@floating-ui/react@0.27.19(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@floating-ui/utils': 0.2.11 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + tabbable: 6.4.0 + '@floating-ui/utils@0.2.11': {} '@fontsource-variable/inter@5.2.8': {} @@ -4403,11 +5094,11 @@ snapshots: transitivePeerDependencies: - axios - '@inertiajs/vite@3.0.3(vite@8.0.8(@types/node@22.19.17)(jiti@2.6.1))': + '@inertiajs/vite@3.0.3(vite@8.0.8(@types/node@22.19.17)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0))': dependencies: '@inertiajs/core': 3.0.3 tinyglobby: 0.2.16 - vite: 8.0.8(@types/node@22.19.17)(jiti@2.6.1) + vite: 8.0.8(@types/node@22.19.17)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0) transitivePeerDependencies: - axios @@ -4536,6 +5227,67 @@ snapshots: '@oxc-project/types@0.124.0': {} + '@parcel/watcher-android-arm64@2.5.6': + optional: true + + '@parcel/watcher-darwin-arm64@2.5.6': + optional: true + + '@parcel/watcher-darwin-x64@2.5.6': + optional: true + + '@parcel/watcher-freebsd-x64@2.5.6': + optional: true + + '@parcel/watcher-linux-arm-glibc@2.5.6': + optional: true + + '@parcel/watcher-linux-arm-musl@2.5.6': + optional: true + + '@parcel/watcher-linux-arm64-glibc@2.5.6': + optional: true + + '@parcel/watcher-linux-arm64-musl@2.5.6': + optional: true + + '@parcel/watcher-linux-x64-glibc@2.5.6': + optional: true + + '@parcel/watcher-linux-x64-musl@2.5.6': + optional: true + + '@parcel/watcher-win32-arm64@2.5.6': + optional: true + + '@parcel/watcher-win32-ia32@2.5.6': + optional: true + + '@parcel/watcher-win32-x64@2.5.6': + optional: true + + '@parcel/watcher@2.5.6': + dependencies: + detect-libc: 2.1.2 + is-glob: 4.0.3 + node-addon-api: 7.1.1 + picomatch: 4.0.4 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.5.6 + '@parcel/watcher-darwin-arm64': 2.5.6 + '@parcel/watcher-darwin-x64': 2.5.6 + '@parcel/watcher-freebsd-x64': 2.5.6 + '@parcel/watcher-linux-arm-glibc': 2.5.6 + '@parcel/watcher-linux-arm-musl': 2.5.6 + '@parcel/watcher-linux-arm64-glibc': 2.5.6 + '@parcel/watcher-linux-arm64-musl': 2.5.6 + '@parcel/watcher-linux-x64-glibc': 2.5.6 + '@parcel/watcher-linux-x64-musl': 2.5.6 + '@parcel/watcher-win32-arm64': 2.5.6 + '@parcel/watcher-win32-ia32': 2.5.6 + '@parcel/watcher-win32-x64': 2.5.6 + optional: true + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -5385,6 +6137,8 @@ snapshots: dependencies: react: 19.2.5 + '@remirror/core-constants@3.0.0': {} + '@rolldown/binding-android-arm64@1.0.0-rc.15': optional: true @@ -5525,12 +6279,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2 '@tailwindcss/oxide-win32-x64-msvc': 4.2.2 - '@tailwindcss/vite@4.2.2(vite@8.0.8(@types/node@22.19.17)(jiti@2.6.1))': + '@tailwindcss/vite@4.2.2(vite@8.0.8(@types/node@22.19.17)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0))': dependencies: '@tailwindcss/node': 4.2.2 '@tailwindcss/oxide': 4.2.2 tailwindcss: 4.2.2 - vite: 8.0.8(@types/node@22.19.17)(jiti@2.6.1) + vite: 8.0.8(@types/node@22.19.17)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0) '@tanstack/react-table@8.21.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: @@ -5548,6 +6302,209 @@ snapshots: '@tanstack/virtual-core@3.13.23': {} + '@tiptap/core@3.22.3(@tiptap/pm@3.22.3)': + dependencies: + '@tiptap/pm': 3.22.3 + + '@tiptap/extension-blockquote@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))': + dependencies: + '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3) + + '@tiptap/extension-bold@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))': + dependencies: + '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3) + + '@tiptap/extension-bubble-menu@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)': + dependencies: + '@floating-ui/dom': 1.7.6 + '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3) + '@tiptap/pm': 3.22.3 + optional: true + + '@tiptap/extension-bullet-list@3.22.3(@tiptap/extension-list@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))': + dependencies: + '@tiptap/extension-list': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3) + + '@tiptap/extension-code-block@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)': + dependencies: + '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3) + '@tiptap/pm': 3.22.3 + + '@tiptap/extension-code@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))': + dependencies: + '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3) + + '@tiptap/extension-document@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))': + dependencies: + '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3) + + '@tiptap/extension-dropcursor@3.22.3(@tiptap/extensions@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))': + dependencies: + '@tiptap/extensions': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3) + + '@tiptap/extension-floating-menu@3.22.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)': + dependencies: + '@floating-ui/dom': 1.7.6 + '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3) + '@tiptap/pm': 3.22.3 + optional: true + + '@tiptap/extension-gapcursor@3.22.3(@tiptap/extensions@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))': + dependencies: + '@tiptap/extensions': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3) + + '@tiptap/extension-hard-break@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))': + dependencies: + '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3) + + '@tiptap/extension-heading@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))': + dependencies: + '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3) + + '@tiptap/extension-highlight@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))': + dependencies: + '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3) + + '@tiptap/extension-horizontal-rule@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)': + dependencies: + '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3) + '@tiptap/pm': 3.22.3 + + '@tiptap/extension-image@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))': + dependencies: + '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3) + + '@tiptap/extension-italic@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))': + dependencies: + '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3) + + '@tiptap/extension-link@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)': + dependencies: + '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3) + '@tiptap/pm': 3.22.3 + linkifyjs: 4.3.2 + + '@tiptap/extension-list-item@3.22.3(@tiptap/extension-list@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))': + dependencies: + '@tiptap/extension-list': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3) + + '@tiptap/extension-list-keymap@3.22.3(@tiptap/extension-list@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))': + dependencies: + '@tiptap/extension-list': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3) + + '@tiptap/extension-list@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)': + dependencies: + '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3) + '@tiptap/pm': 3.22.3 + + '@tiptap/extension-ordered-list@3.22.3(@tiptap/extension-list@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))': + dependencies: + '@tiptap/extension-list': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3) + + '@tiptap/extension-paragraph@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))': + dependencies: + '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3) + + '@tiptap/extension-strike@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))': + dependencies: + '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3) + + '@tiptap/extension-subscript@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)': + dependencies: + '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3) + '@tiptap/pm': 3.22.3 + + '@tiptap/extension-superscript@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)': + dependencies: + '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3) + '@tiptap/pm': 3.22.3 + + '@tiptap/extension-text-align@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))': + dependencies: + '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3) + + '@tiptap/extension-text@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))': + dependencies: + '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3) + + '@tiptap/extension-typography@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))': + dependencies: + '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3) + + '@tiptap/extension-underline@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))': + dependencies: + '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3) + + '@tiptap/extensions@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)': + dependencies: + '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3) + '@tiptap/pm': 3.22.3 + + '@tiptap/pm@3.22.3': + dependencies: + prosemirror-changeset: 2.4.1 + prosemirror-collab: 1.3.1 + prosemirror-commands: 1.7.1 + prosemirror-dropcursor: 1.8.2 + prosemirror-gapcursor: 1.4.1 + prosemirror-history: 1.5.0 + prosemirror-inputrules: 1.5.1 + prosemirror-keymap: 1.2.3 + prosemirror-markdown: 1.13.4 + prosemirror-menu: 1.3.1 + prosemirror-model: 1.25.4 + prosemirror-schema-basic: 1.2.4 + prosemirror-schema-list: 1.5.1 + prosemirror-state: 1.4.4 + prosemirror-tables: 1.8.5 + prosemirror-trailing-node: 3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8) + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 + + '@tiptap/react@3.22.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3) + '@tiptap/pm': 3.22.3 + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/use-sync-external-store': 0.0.6 + fast-equals: 5.4.0 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + use-sync-external-store: 1.6.0(react@19.2.5) + optionalDependencies: + '@tiptap/extension-bubble-menu': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3) + '@tiptap/extension-floating-menu': 3.22.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3) + transitivePeerDependencies: + - '@floating-ui/dom' + + '@tiptap/starter-kit@3.22.3': + dependencies: + '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3) + '@tiptap/extension-blockquote': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3)) + '@tiptap/extension-bold': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3)) + '@tiptap/extension-bullet-list': 3.22.3(@tiptap/extension-list@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)) + '@tiptap/extension-code': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3)) + '@tiptap/extension-code-block': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3) + '@tiptap/extension-document': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3)) + '@tiptap/extension-dropcursor': 3.22.3(@tiptap/extensions@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)) + '@tiptap/extension-gapcursor': 3.22.3(@tiptap/extensions@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)) + '@tiptap/extension-hard-break': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3)) + '@tiptap/extension-heading': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3)) + '@tiptap/extension-horizontal-rule': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3) + '@tiptap/extension-italic': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3)) + '@tiptap/extension-link': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3) + '@tiptap/extension-list': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3) + '@tiptap/extension-list-item': 3.22.3(@tiptap/extension-list@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)) + '@tiptap/extension-list-keymap': 3.22.3(@tiptap/extension-list@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)) + '@tiptap/extension-ordered-list': 3.22.3(@tiptap/extension-list@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)) + '@tiptap/extension-paragraph': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3)) + '@tiptap/extension-strike': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3)) + '@tiptap/extension-text': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3)) + '@tiptap/extension-underline': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3)) + '@tiptap/extensions': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3) + '@tiptap/pm': 3.22.3 + '@ts-morph/common@0.27.0': dependencies: fast-glob: 3.3.3 @@ -5586,6 +6543,21 @@ snapshots: '@types/json5@0.0.29': {} + '@types/linkify-it@5.0.0': {} + + '@types/lodash.throttle@4.1.9': + dependencies: + '@types/lodash': 4.17.24 + + '@types/lodash@4.17.24': {} + + '@types/markdown-it@14.1.2': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + + '@types/mdurl@2.0.0': {} + '@types/node@22.19.17': dependencies: undici-types: 6.21.0 @@ -5600,6 +6572,8 @@ snapshots: '@types/statuses@2.0.6': {} + '@types/use-sync-external-store@0.0.6': {} + '@types/validate-npm-package-name@4.0.2': {} '@typescript-eslint/eslint-plugin@8.58.1(@typescript-eslint/parser@8.58.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': @@ -5752,7 +6726,7 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true - '@vitejs/plugin-react@5.2.0(vite@8.0.8(@types/node@22.19.17)(jiti@2.6.1))': + '@vitejs/plugin-react@5.2.0(vite@8.0.8(@types/node@22.19.17)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -5760,7 +6734,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.3 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 8.0.8(@types/node@22.19.17)(jiti@2.6.1) + vite: 8.0.8(@types/node@22.19.17)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0) transitivePeerDependencies: - supports-color @@ -5965,6 +6939,11 @@ snapshots: chalk@5.6.2: {} + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + optional: true + class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 @@ -5993,6 +6972,8 @@ snapshots: color-name@1.1.4: {} + colorjs.io@0.5.2: {} + commander@11.1.0: {} commander@14.0.3: {} @@ -6034,6 +7015,8 @@ snapshots: optionalDependencies: typescript: 5.9.3 + crelt@1.0.6: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -6064,6 +7047,8 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 + date-fns@4.1.0: {} + debug@3.2.7: dependencies: ms: 2.1.3 @@ -6141,6 +7126,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.2 + entities@4.5.0: {} + env-paths@2.2.1: {} error-ex@1.3.4: @@ -6510,6 +7497,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-equals@5.4.0: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -6737,6 +7726,8 @@ snapshots: ignore@7.0.5: {} + immutable@5.1.5: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -6979,11 +7970,11 @@ snapshots: dependencies: es-toolkit: 1.45.1 - laravel-vite-plugin@3.0.1(vite@8.0.8(@types/node@22.19.17)(jiti@2.6.1)): + laravel-vite-plugin@3.0.1(vite@8.0.8(@types/node@22.19.17)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)): dependencies: picocolors: 1.1.1 tinyglobby: 0.2.16 - vite: 8.0.8(@types/node@22.19.17)(jiti@2.6.1) + vite: 8.0.8(@types/node@22.19.17)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0) vite-plugin-full-reload: 1.2.0 levn@0.4.1: @@ -7042,12 +8033,20 @@ snapshots: lines-and-columns@1.2.4: {} + linkify-it@5.0.0: + dependencies: + uc.micro: 2.1.0 + + linkifyjs@4.3.2: {} + locate-path@6.0.0: dependencies: p-locate: 5.0.0 lodash.merge@4.6.2: {} + lodash.throttle@4.1.1: {} + log-symbols@6.0.0: dependencies: chalk: 5.6.2 @@ -7069,8 +8068,19 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + markdown-it@14.1.1: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.0 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + math-intrinsics@1.1.0: {} + mdurl@2.0.0: {} + media-typer@1.1.0: {} merge-descriptors@2.0.0: {} @@ -7146,6 +8156,9 @@ snapshots: react: 19.2.5 react-dom: 19.2.5(react@19.2.5) + node-addon-api@7.1.1: + optional: true + node-domexception@1.0.0: {} node-exports-info@1.6.0: @@ -7262,6 +8275,8 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.2.0 + orderedmap@2.1.1: {} + outvariant@1.4.3: {} own-keys@1.0.1: @@ -7353,11 +8368,116 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + prosemirror-changeset@2.4.1: + dependencies: + prosemirror-transform: 1.12.0 + + prosemirror-collab@1.3.1: + dependencies: + prosemirror-state: 1.4.4 + + prosemirror-commands@1.7.1: + dependencies: + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + + prosemirror-dropcursor@1.8.2: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 + + prosemirror-gapcursor@1.4.1: + dependencies: + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-view: 1.41.8 + + prosemirror-history@1.5.0: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 + rope-sequence: 1.3.4 + + prosemirror-inputrules@1.5.1: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + + prosemirror-keymap@1.2.3: + dependencies: + prosemirror-state: 1.4.4 + w3c-keyname: 2.2.8 + + prosemirror-markdown@1.13.4: + dependencies: + '@types/markdown-it': 14.1.2 + markdown-it: 14.1.1 + prosemirror-model: 1.25.4 + + prosemirror-menu@1.3.1: + dependencies: + crelt: 1.0.6 + prosemirror-commands: 1.7.1 + prosemirror-history: 1.5.0 + prosemirror-state: 1.4.4 + + prosemirror-model@1.25.4: + dependencies: + orderedmap: 2.1.1 + + prosemirror-schema-basic@1.2.4: + dependencies: + prosemirror-model: 1.25.4 + + prosemirror-schema-list@1.5.1: + dependencies: + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + + prosemirror-state@1.4.4: + dependencies: + prosemirror-model: 1.25.4 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 + + prosemirror-tables@1.8.5: + dependencies: + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 + + prosemirror-trailing-node@3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8): + dependencies: + '@remirror/core-constants': 3.0.0 + escape-string-regexp: 4.0.0 + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-view: 1.41.8 + + prosemirror-transform@1.12.0: + dependencies: + prosemirror-model: 1.25.4 + + prosemirror-view@1.41.8: + dependencies: + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 ipaddr.js: 1.9.1 + punycode.js@2.3.1: {} + punycode@2.3.1: {} qs@6.15.1: @@ -7443,6 +8563,11 @@ snapshots: react: 19.2.5 scheduler: 0.27.0 + react-hotkeys-hook@5.2.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + dependencies: + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + react-is@16.13.1: {} react-refresh@0.18.0: {} @@ -7476,6 +8601,9 @@ snapshots: react@19.2.5: {} + readdirp@4.1.2: + optional: true + recast@0.23.11: dependencies: ast-types: 0.16.1 @@ -7508,6 +8636,8 @@ snapshots: require-from-string@2.0.2: {} + reselect@5.1.1: {} + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -7551,6 +8681,8 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.15 '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.15 + rope-sequence@1.3.4: {} + router@2.2.0: dependencies: debug: 4.4.3 @@ -7592,6 +8724,102 @@ snapshots: safer-buffer@2.1.2: {} + sass-embedded-all-unknown@1.99.0: + dependencies: + sass: 1.99.0 + optional: true + + sass-embedded-android-arm64@1.99.0: + optional: true + + sass-embedded-android-arm@1.99.0: + optional: true + + sass-embedded-android-riscv64@1.99.0: + optional: true + + sass-embedded-android-x64@1.99.0: + optional: true + + sass-embedded-darwin-arm64@1.99.0: + optional: true + + sass-embedded-darwin-x64@1.99.0: + optional: true + + sass-embedded-linux-arm64@1.99.0: + optional: true + + sass-embedded-linux-arm@1.99.0: + optional: true + + sass-embedded-linux-musl-arm64@1.99.0: + optional: true + + sass-embedded-linux-musl-arm@1.99.0: + optional: true + + sass-embedded-linux-musl-riscv64@1.99.0: + optional: true + + sass-embedded-linux-musl-x64@1.99.0: + optional: true + + sass-embedded-linux-riscv64@1.99.0: + optional: true + + sass-embedded-linux-x64@1.99.0: + optional: true + + sass-embedded-unknown-all@1.99.0: + dependencies: + sass: 1.99.0 + optional: true + + sass-embedded-win32-arm64@1.99.0: + optional: true + + sass-embedded-win32-x64@1.99.0: + optional: true + + sass-embedded@1.99.0: + dependencies: + '@bufbuild/protobuf': 2.11.0 + colorjs.io: 0.5.2 + immutable: 5.1.5 + rxjs: 7.8.2 + supports-color: 8.1.1 + sync-child-process: 1.0.2 + varint: 6.0.0 + optionalDependencies: + sass-embedded-all-unknown: 1.99.0 + sass-embedded-android-arm: 1.99.0 + sass-embedded-android-arm64: 1.99.0 + sass-embedded-android-riscv64: 1.99.0 + sass-embedded-android-x64: 1.99.0 + sass-embedded-darwin-arm64: 1.99.0 + sass-embedded-darwin-x64: 1.99.0 + sass-embedded-linux-arm: 1.99.0 + sass-embedded-linux-arm64: 1.99.0 + sass-embedded-linux-musl-arm: 1.99.0 + sass-embedded-linux-musl-arm64: 1.99.0 + sass-embedded-linux-musl-riscv64: 1.99.0 + sass-embedded-linux-musl-x64: 1.99.0 + sass-embedded-linux-riscv64: 1.99.0 + sass-embedded-linux-x64: 1.99.0 + sass-embedded-unknown-all: 1.99.0 + sass-embedded-win32-arm64: 1.99.0 + sass-embedded-win32-x64: 1.99.0 + + sass@1.99.0: + dependencies: + chokidar: 4.0.3 + immutable: 5.1.5 + source-map-js: 1.2.1 + optionalDependencies: + '@parcel/watcher': 2.5.6 + optional: true + scheduler@0.27.0: {} semver@6.3.1: {} @@ -7842,6 +9070,12 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + sync-child-process@1.0.2: + dependencies: + sync-message-port: 1.2.0 + + sync-message-port@1.2.0: {} + tabbable@6.4.0: {} tagged-tag@1.0.0: {} @@ -7963,6 +9197,8 @@ snapshots: typescript@5.9.3: {} + uc.micro@2.1.0: {} + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 @@ -8037,6 +9273,8 @@ snapshots: validate-npm-package-name@7.0.2: {} + varint@6.0.0: {} + vary@1.1.2: {} vite-plugin-full-reload@1.2.0: @@ -8044,7 +9282,7 @@ snapshots: picocolors: 1.1.1 picomatch: 2.3.2 - vite@8.0.8(@types/node@22.19.17)(jiti@2.6.1): + vite@8.0.8(@types/node@22.19.17)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -8055,6 +9293,10 @@ snapshots: '@types/node': 22.19.17 fsevents: 2.3.3 jiti: 2.6.1 + sass: 1.99.0 + sass-embedded: 1.99.0 + + w3c-keyname@2.2.8: {} web-streams-polyfill@3.3.3: {} diff --git a/resources/css/app.css b/resources/css/app.css index 7015d58..450845e 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1,3 +1,6 @@ +@import '../js/styles/_variables.scss'; +@import '../js/styles/_keyframe-animations.scss'; + @import 'tailwindcss'; @import 'tw-animate-css'; diff --git a/resources/js/components/tiptap-extension/node-background-extension.ts b/resources/js/components/tiptap-extension/node-background-extension.ts new file mode 100644 index 0000000..e26901c --- /dev/null +++ b/resources/js/components/tiptap-extension/node-background-extension.ts @@ -0,0 +1,150 @@ +import type { NodeWithPos } from "@tiptap/core" +import { Extension } from "@tiptap/core" +import type { EditorState, Transaction } from "@tiptap/pm/state" +import { getSelectedNodesOfType } from "@/lib/tiptap-utils" +import { updateNodesAttr } from "@/lib/tiptap-utils" + +declare module "@tiptap/core" { + interface Commands { + nodeBackground: { + setNodeBackgroundColor: (backgroundColor: string) => ReturnType + unsetNodeBackgroundColor: () => ReturnType + toggleNodeBackgroundColor: (backgroundColor: string) => ReturnType + } + } +} + +export interface NodeBackgroundOptions { + /** + * Node types that should support background colors + * @default ["paragraph", "heading", "blockquote", "taskList", "bulletList", "orderedList", "tableCell", "tableHeader"] + */ + types: string[] + /** + * Use inline style instead of data attribute + * @default true + */ + useStyle?: boolean +} + +/** + * Determines the target color for toggle operations + */ +function getToggleColor( + targets: NodeWithPos[], + inputColor: string +): string | null { + if (targets.length === 0) return null + + for (const target of targets) { + const currentColor = target.node.attrs?.backgroundColor ?? null + if (currentColor !== inputColor) { + return inputColor + } + } + + return null +} + +export const NodeBackground = Extension.create({ + name: "nodeBackground", + + addOptions() { + return { + types: [ + "paragraph", + "heading", + "blockquote", + "taskList", + "bulletList", + "orderedList", + "tableCell", + "tableHeader", + ], + useStyle: true, + } + }, + + addGlobalAttributes() { + return [ + { + types: this.options.types, + attributes: { + backgroundColor: { + default: null as string | null, + + parseHTML: (element: HTMLElement) => { + const styleColor = element.style?.backgroundColor + if (styleColor) return styleColor + + const dataColor = element.getAttribute("data-background-color") + return dataColor || null + }, + + renderHTML: (attributes) => { + const color = attributes.backgroundColor as string | null + if (!color) return {} + + if (this.options.useStyle) { + return { + style: `background-color: ${color}`, + } + } else { + return { + "data-background-color": color, + } + } + }, + }, + }, + }, + ] + }, + + addCommands() { + /** + * Generic command executor for background color operations + */ + const executeBackgroundCommand = ( + getTargetColor: ( + targets: NodeWithPos[], + inputColor?: string + ) => string | null + ) => { + return (inputColor?: string) => + ({ state, tr }: { state: EditorState; tr: Transaction }) => { + const targets = getSelectedNodesOfType( + state.selection, + this.options.types + ) + + if (targets.length === 0) return false + + const targetColor = getTargetColor(targets, inputColor) + + return updateNodesAttr(tr, targets, "backgroundColor", targetColor) + } + } + + return { + /** + * Set background color to specific value + */ + setNodeBackgroundColor: executeBackgroundCommand( + (_, inputColor) => inputColor || null + ), + + /** + * Remove background color + */ + unsetNodeBackgroundColor: executeBackgroundCommand(() => null), + + /** + * Toggle background color (set if different/missing, unset if all have it) + */ + toggleNodeBackgroundColor: executeBackgroundCommand( + (targets, inputColor) => getToggleColor(targets, inputColor || "") + ), + } + }, +}) diff --git a/resources/js/components/tiptap-icons/align-center-icon.tsx b/resources/js/components/tiptap-icons/align-center-icon.tsx new file mode 100644 index 0000000..bb72060 --- /dev/null +++ b/resources/js/components/tiptap-icons/align-center-icon.tsx @@ -0,0 +1,38 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const AlignCenterIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + + ) +}) + +AlignCenterIcon.displayName = "AlignCenterIcon" diff --git a/resources/js/components/tiptap-icons/align-justify-icon.tsx b/resources/js/components/tiptap-icons/align-justify-icon.tsx new file mode 100644 index 0000000..61cc6de --- /dev/null +++ b/resources/js/components/tiptap-icons/align-justify-icon.tsx @@ -0,0 +1,38 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const AlignJustifyIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + + ) +}) + +AlignJustifyIcon.displayName = "AlignJustifyIcon" diff --git a/resources/js/components/tiptap-icons/align-left-icon.tsx b/resources/js/components/tiptap-icons/align-left-icon.tsx new file mode 100644 index 0000000..2972bdb --- /dev/null +++ b/resources/js/components/tiptap-icons/align-left-icon.tsx @@ -0,0 +1,38 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const AlignLeftIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + + ) +}) + +AlignLeftIcon.displayName = "AlignLeftIcon" diff --git a/resources/js/components/tiptap-icons/align-right-icon.tsx b/resources/js/components/tiptap-icons/align-right-icon.tsx new file mode 100644 index 0000000..c93fc05 --- /dev/null +++ b/resources/js/components/tiptap-icons/align-right-icon.tsx @@ -0,0 +1,38 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const AlignRightIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + + ) +}) + +AlignRightIcon.displayName = "AlignRightIcon" diff --git a/resources/js/components/tiptap-icons/arrow-left-icon.tsx b/resources/js/components/tiptap-icons/arrow-left-icon.tsx new file mode 100644 index 0000000..7cf04d2 --- /dev/null +++ b/resources/js/components/tiptap-icons/arrow-left-icon.tsx @@ -0,0 +1,24 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const ArrowLeftIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + ) +}) + +ArrowLeftIcon.displayName = "ArrowLeftIcon" diff --git a/resources/js/components/tiptap-icons/ban-icon.tsx b/resources/js/components/tiptap-icons/ban-icon.tsx new file mode 100644 index 0000000..1995de0 --- /dev/null +++ b/resources/js/components/tiptap-icons/ban-icon.tsx @@ -0,0 +1,26 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const BanIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + ) +}) + +BanIcon.displayName = "BanIcon" diff --git a/resources/js/components/tiptap-icons/blockquote-icon.tsx b/resources/js/components/tiptap-icons/blockquote-icon.tsx new file mode 100644 index 0000000..50e665f --- /dev/null +++ b/resources/js/components/tiptap-icons/blockquote-icon.tsx @@ -0,0 +1,44 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const BlockquoteIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + + + ) +}) + +BlockquoteIcon.displayName = "BlockquoteIcon" diff --git a/resources/js/components/tiptap-icons/bold-icon.tsx b/resources/js/components/tiptap-icons/bold-icon.tsx new file mode 100644 index 0000000..a61cca4 --- /dev/null +++ b/resources/js/components/tiptap-icons/bold-icon.tsx @@ -0,0 +1,26 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const BoldIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + ) +}) + +BoldIcon.displayName = "BoldIcon" diff --git a/resources/js/components/tiptap-icons/check-icon.tsx b/resources/js/components/tiptap-icons/check-icon.tsx new file mode 100644 index 0000000..eda6164 --- /dev/null +++ b/resources/js/components/tiptap-icons/check-icon.tsx @@ -0,0 +1,26 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const CheckIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + ) +}) + +CheckIcon.displayName = "CheckIcon" diff --git a/resources/js/components/tiptap-icons/chevron-down-icon.tsx b/resources/js/components/tiptap-icons/chevron-down-icon.tsx new file mode 100644 index 0000000..8f7844d --- /dev/null +++ b/resources/js/components/tiptap-icons/chevron-down-icon.tsx @@ -0,0 +1,26 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const ChevronDownIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + ) +}) + +ChevronDownIcon.displayName = "ChevronDownIcon" diff --git a/resources/js/components/tiptap-icons/close-icon.tsx b/resources/js/components/tiptap-icons/close-icon.tsx new file mode 100644 index 0000000..8b506a9 --- /dev/null +++ b/resources/js/components/tiptap-icons/close-icon.tsx @@ -0,0 +1,24 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const CloseIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + ) +}) + +CloseIcon.displayName = "CloseIcon" diff --git a/resources/js/components/tiptap-icons/code-block-icon.tsx b/resources/js/components/tiptap-icons/code-block-icon.tsx new file mode 100644 index 0000000..9d42238 --- /dev/null +++ b/resources/js/components/tiptap-icons/code-block-icon.tsx @@ -0,0 +1,38 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const CodeBlockIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + + ) +}) + +CodeBlockIcon.displayName = "CodeBlockIcon" diff --git a/resources/js/components/tiptap-icons/code2-icon.tsx b/resources/js/components/tiptap-icons/code2-icon.tsx new file mode 100644 index 0000000..e8d70d8 --- /dev/null +++ b/resources/js/components/tiptap-icons/code2-icon.tsx @@ -0,0 +1,32 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const Code2Icon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + + ) +}) + +Code2Icon.displayName = "Code2Icon" diff --git a/resources/js/components/tiptap-icons/corner-down-left-icon.tsx b/resources/js/components/tiptap-icons/corner-down-left-icon.tsx new file mode 100644 index 0000000..0dea639 --- /dev/null +++ b/resources/js/components/tiptap-icons/corner-down-left-icon.tsx @@ -0,0 +1,26 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const CornerDownLeftIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + ) +}) + +CornerDownLeftIcon.displayName = "CornerDownLeftIcon" diff --git a/resources/js/components/tiptap-icons/external-link-icon.tsx b/resources/js/components/tiptap-icons/external-link-icon.tsx new file mode 100644 index 0000000..a4afe67 --- /dev/null +++ b/resources/js/components/tiptap-icons/external-link-icon.tsx @@ -0,0 +1,28 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const ExternalLinkIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + ) +}) + +ExternalLinkIcon.displayName = "ExternalLinkIcon" diff --git a/resources/js/components/tiptap-icons/heading-five-icon.tsx b/resources/js/components/tiptap-icons/heading-five-icon.tsx new file mode 100644 index 0000000..e1450cf --- /dev/null +++ b/resources/js/components/tiptap-icons/heading-five-icon.tsx @@ -0,0 +1,28 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const HeadingFiveIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + ) +}) + +HeadingFiveIcon.displayName = "HeadingFiveIcon" diff --git a/resources/js/components/tiptap-icons/heading-four-icon.tsx b/resources/js/components/tiptap-icons/heading-four-icon.tsx new file mode 100644 index 0000000..3f35a8b --- /dev/null +++ b/resources/js/components/tiptap-icons/heading-four-icon.tsx @@ -0,0 +1,28 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const HeadingFourIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + ) +}) + +HeadingFourIcon.displayName = "HeadingFourIcon" diff --git a/resources/js/components/tiptap-icons/heading-icon.tsx b/resources/js/components/tiptap-icons/heading-icon.tsx new file mode 100644 index 0000000..ba9aa15 --- /dev/null +++ b/resources/js/components/tiptap-icons/heading-icon.tsx @@ -0,0 +1,24 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const HeadingIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + ) +}) + +HeadingIcon.displayName = "HeadingIcon" diff --git a/resources/js/components/tiptap-icons/heading-one-icon.tsx b/resources/js/components/tiptap-icons/heading-one-icon.tsx new file mode 100644 index 0000000..d35093d --- /dev/null +++ b/resources/js/components/tiptap-icons/heading-one-icon.tsx @@ -0,0 +1,28 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const HeadingOneIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + ) +}) + +HeadingOneIcon.displayName = "HeadingOneIcon" diff --git a/resources/js/components/tiptap-icons/heading-six-icon.tsx b/resources/js/components/tiptap-icons/heading-six-icon.tsx new file mode 100644 index 0000000..9cce22b --- /dev/null +++ b/resources/js/components/tiptap-icons/heading-six-icon.tsx @@ -0,0 +1,30 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const HeadingSixIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + ) +}) + +HeadingSixIcon.displayName = "HeadingSixIcon" diff --git a/resources/js/components/tiptap-icons/heading-three-icon.tsx b/resources/js/components/tiptap-icons/heading-three-icon.tsx new file mode 100644 index 0000000..f244836 --- /dev/null +++ b/resources/js/components/tiptap-icons/heading-three-icon.tsx @@ -0,0 +1,36 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const HeadingThreeIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + + ) +}) + +HeadingThreeIcon.displayName = "HeadingThreeIcon" diff --git a/resources/js/components/tiptap-icons/heading-two-icon.tsx b/resources/js/components/tiptap-icons/heading-two-icon.tsx new file mode 100644 index 0000000..24417ac --- /dev/null +++ b/resources/js/components/tiptap-icons/heading-two-icon.tsx @@ -0,0 +1,28 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const HeadingTwoIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + ) +}) + +HeadingTwoIcon.displayName = "HeadingTwoIcon" diff --git a/resources/js/components/tiptap-icons/highlighter-icon.tsx b/resources/js/components/tiptap-icons/highlighter-icon.tsx new file mode 100644 index 0000000..b6374fe --- /dev/null +++ b/resources/js/components/tiptap-icons/highlighter-icon.tsx @@ -0,0 +1,26 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const HighlighterIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + ) +}) + +HighlighterIcon.displayName = "HighlighterIcon" diff --git a/resources/js/components/tiptap-icons/image-plus-icon.tsx b/resources/js/components/tiptap-icons/image-plus-icon.tsx new file mode 100644 index 0000000..b1fd8c2 --- /dev/null +++ b/resources/js/components/tiptap-icons/image-plus-icon.tsx @@ -0,0 +1,26 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const ImagePlusIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + ) +}) + +ImagePlusIcon.displayName = "ImagePlusIcon" diff --git a/resources/js/components/tiptap-icons/italic-icon.tsx b/resources/js/components/tiptap-icons/italic-icon.tsx new file mode 100644 index 0000000..7c69b63 --- /dev/null +++ b/resources/js/components/tiptap-icons/italic-icon.tsx @@ -0,0 +1,24 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const ItalicIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + ) +}) + +ItalicIcon.displayName = "ItalicIcon" diff --git a/resources/js/components/tiptap-icons/link-icon.tsx b/resources/js/components/tiptap-icons/link-icon.tsx new file mode 100644 index 0000000..27e1574 --- /dev/null +++ b/resources/js/components/tiptap-icons/link-icon.tsx @@ -0,0 +1,28 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const LinkIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + ) +}) + +LinkIcon.displayName = "LinkIcon" diff --git a/resources/js/components/tiptap-icons/list-icon.tsx b/resources/js/components/tiptap-icons/list-icon.tsx new file mode 100644 index 0000000..a3183d2 --- /dev/null +++ b/resources/js/components/tiptap-icons/list-icon.tsx @@ -0,0 +1,56 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const ListIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + + + + + ) +}) + +ListIcon.displayName = "ListIcon" diff --git a/resources/js/components/tiptap-icons/list-ordered-icon.tsx b/resources/js/components/tiptap-icons/list-ordered-icon.tsx new file mode 100644 index 0000000..119da7b --- /dev/null +++ b/resources/js/components/tiptap-icons/list-ordered-icon.tsx @@ -0,0 +1,56 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const ListOrderedIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + + + + + ) +}) + +ListOrderedIcon.displayName = "ListOrderedIcon" diff --git a/resources/js/components/tiptap-icons/list-todo-icon.tsx b/resources/js/components/tiptap-icons/list-todo-icon.tsx new file mode 100644 index 0000000..9b899c1 --- /dev/null +++ b/resources/js/components/tiptap-icons/list-todo-icon.tsx @@ -0,0 +1,50 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const ListTodoIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + + + + ) +}) + +ListTodoIcon.displayName = "ListTodoIcon" diff --git a/resources/js/components/tiptap-icons/moon-star-icon.tsx b/resources/js/components/tiptap-icons/moon-star-icon.tsx new file mode 100644 index 0000000..e2d4422 --- /dev/null +++ b/resources/js/components/tiptap-icons/moon-star-icon.tsx @@ -0,0 +1,30 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const MoonStarIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + ) +}) + +MoonStarIcon.displayName = "MoonStarIcon" diff --git a/resources/js/components/tiptap-icons/redo2-icon.tsx b/resources/js/components/tiptap-icons/redo2-icon.tsx new file mode 100644 index 0000000..0302a16 --- /dev/null +++ b/resources/js/components/tiptap-icons/redo2-icon.tsx @@ -0,0 +1,26 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const Redo2Icon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + ) +}) + +Redo2Icon.displayName = "Redo2Icon" diff --git a/resources/js/components/tiptap-icons/strike-icon.tsx b/resources/js/components/tiptap-icons/strike-icon.tsx new file mode 100644 index 0000000..df4b8c8 --- /dev/null +++ b/resources/js/components/tiptap-icons/strike-icon.tsx @@ -0,0 +1,28 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const StrikeIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + ) +}) + +StrikeIcon.displayName = "StrikeIcon" diff --git a/resources/js/components/tiptap-icons/subscript-icon.tsx b/resources/js/components/tiptap-icons/subscript-icon.tsx new file mode 100644 index 0000000..671030d --- /dev/null +++ b/resources/js/components/tiptap-icons/subscript-icon.tsx @@ -0,0 +1,38 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const SubscriptIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + + ) +}) + +SubscriptIcon.displayName = "SubscriptIcon" diff --git a/resources/js/components/tiptap-icons/sun-icon.tsx b/resources/js/components/tiptap-icons/sun-icon.tsx new file mode 100644 index 0000000..607fd65 --- /dev/null +++ b/resources/js/components/tiptap-icons/sun-icon.tsx @@ -0,0 +1,58 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const SunIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + + + + + + + + ) +}) + +SunIcon.displayName = "SunIcon" diff --git a/resources/js/components/tiptap-icons/superscript-icon.tsx b/resources/js/components/tiptap-icons/superscript-icon.tsx new file mode 100644 index 0000000..6af567b --- /dev/null +++ b/resources/js/components/tiptap-icons/superscript-icon.tsx @@ -0,0 +1,38 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const SuperscriptIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + + ) +}) + +SuperscriptIcon.displayName = "SuperscriptIcon" diff --git a/resources/js/components/tiptap-icons/trash-icon.tsx b/resources/js/components/tiptap-icons/trash-icon.tsx new file mode 100644 index 0000000..ac470bb --- /dev/null +++ b/resources/js/components/tiptap-icons/trash-icon.tsx @@ -0,0 +1,26 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const TrashIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + ) +}) + +TrashIcon.displayName = "TrashIcon" diff --git a/resources/js/components/tiptap-icons/underline-icon.tsx b/resources/js/components/tiptap-icons/underline-icon.tsx new file mode 100644 index 0000000..9765a3b --- /dev/null +++ b/resources/js/components/tiptap-icons/underline-icon.tsx @@ -0,0 +1,26 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const UnderlineIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + ) +}) + +UnderlineIcon.displayName = "UnderlineIcon" diff --git a/resources/js/components/tiptap-icons/undo2-icon.tsx b/resources/js/components/tiptap-icons/undo2-icon.tsx new file mode 100644 index 0000000..bfb643f --- /dev/null +++ b/resources/js/components/tiptap-icons/undo2-icon.tsx @@ -0,0 +1,26 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const Undo2Icon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + ) +}) + +Undo2Icon.displayName = "Undo2Icon" diff --git a/resources/js/components/tiptap-node/blockquote-node/blockquote-node.scss b/resources/js/components/tiptap-node/blockquote-node/blockquote-node.scss new file mode 100644 index 0000000..b49c5e1 --- /dev/null +++ b/resources/js/components/tiptap-node/blockquote-node/blockquote-node.scss @@ -0,0 +1,37 @@ +.tiptap.ProseMirror { + --blockquote-bg-color: var(--tt-gray-light-900); + + .dark & { + --blockquote-bg-color: var(--tt-gray-dark-900); + } +} + +/* ===================== + BLOCKQUOTE + ===================== */ +.tiptap.ProseMirror { + blockquote { + position: relative; + padding-left: 1em; + padding-top: 0.375em; + padding-bottom: 0.375em; + margin: 1.5rem 0; + + p { + margin-top: 0; + } + + &::before, + &.is-empty::before { + position: absolute; + bottom: 0; + left: 0; + top: 0; + height: 100%; + width: 0.25em; + background-color: var(--blockquote-bg-color); + content: ""; + border-radius: 0; + } + } +} diff --git a/resources/js/components/tiptap-node/code-block-node/code-block-node.scss b/resources/js/components/tiptap-node/code-block-node/code-block-node.scss new file mode 100644 index 0000000..d31b312 --- /dev/null +++ b/resources/js/components/tiptap-node/code-block-node/code-block-node.scss @@ -0,0 +1,54 @@ +.tiptap.ProseMirror { + --tt-inline-code-bg-color: var(--tt-gray-light-a-100); + --tt-inline-code-text-color: var(--tt-gray-light-a-700); + --tt-inline-code-border-color: var(--tt-gray-light-a-200); + --tt-codeblock-bg: var(--tt-gray-light-a-50); + --tt-codeblock-text: var(--tt-gray-light-a-800); + --tt-codeblock-border: var(--tt-gray-light-a-200); + + .dark & { + --tt-inline-code-bg-color: var(--tt-gray-dark-a-100); + --tt-inline-code-text-color: var(--tt-gray-dark-a-700); + --tt-inline-code-border-color: var(--tt-gray-dark-a-200); + --tt-codeblock-bg: var(--tt-gray-dark-a-50); + --tt-codeblock-text: var(--tt-gray-dark-a-800); + --tt-codeblock-border: var(--tt-gray-dark-a-200); + } +} + +/* ===================== + CODE FORMATTING + ===================== */ +.tiptap.ProseMirror { + // Inline code + code { + background-color: var(--tt-inline-code-bg-color); + color: var(--tt-inline-code-text-color); + border: 1px solid var(--tt-inline-code-border-color); + font-family: "JetBrains Mono NL", monospace; + font-size: 0.875em; + line-height: 1.4; + border-radius: 6px/0.375rem; + padding: 0.1em 0.2em; + } + + // Code blocks + pre { + background-color: var(--tt-codeblock-bg); + color: var(--tt-codeblock-text); + border: 1px solid var(--tt-codeblock-border); + margin-top: 1.5em; + margin-bottom: 1.5em; + padding: 1em; + font-size: 1rem; + border-radius: 6px/0.375rem; + + code { + background-color: transparent; + border: none; + border-radius: 0; + -webkit-text-fill-color: inherit; + color: inherit; + } + } +} diff --git a/resources/js/components/tiptap-node/heading-node/heading-node.scss b/resources/js/components/tiptap-node/heading-node/heading-node.scss new file mode 100644 index 0000000..4dd908c --- /dev/null +++ b/resources/js/components/tiptap-node/heading-node/heading-node.scss @@ -0,0 +1,45 @@ +.tiptap.ProseMirror { + h1, + h2, + h3, + h4 { + position: relative; + color: inherit; + font-style: inherit; + } + + > h1:first-child, + > h2:first-child, + > h3:first-child, + > h4:first-child, + > .ProseMirror-widget + h1, + > .ProseMirror-widget + h2, + > .ProseMirror-widget + h3, + > .ProseMirror-widget + h4 { + margin-top: 0; + } + + h1 { + font-size: 1.5em; + font-weight: 700; + margin-top: 3em; + } + + h2 { + font-size: 1.25em; + font-weight: 700; + margin-top: 2.5em; + } + + h3 { + font-size: 1.125em; + font-weight: 600; + margin-top: 2em; + } + + h4 { + font-size: 1em; + font-weight: 600; + margin-top: 2em; + } +} diff --git a/resources/js/components/tiptap-node/horizontal-rule-node/horizontal-rule-node-extension.ts b/resources/js/components/tiptap-node/horizontal-rule-node/horizontal-rule-node-extension.ts new file mode 100644 index 0000000..de28208 --- /dev/null +++ b/resources/js/components/tiptap-node/horizontal-rule-node/horizontal-rule-node-extension.ts @@ -0,0 +1,14 @@ +import { mergeAttributes } from "@tiptap/react" +import TiptapHorizontalRule from "@tiptap/extension-horizontal-rule" + +export const HorizontalRule = TiptapHorizontalRule.extend({ + renderHTML() { + return [ + "div", + mergeAttributes(this.options.HTMLAttributes, { "data-type": this.name }), + ["hr"], + ] + }, +}) + +export default HorizontalRule diff --git a/resources/js/components/tiptap-node/horizontal-rule-node/horizontal-rule-node.scss b/resources/js/components/tiptap-node/horizontal-rule-node/horizontal-rule-node.scss new file mode 100644 index 0000000..4626e65 --- /dev/null +++ b/resources/js/components/tiptap-node/horizontal-rule-node/horizontal-rule-node.scss @@ -0,0 +1,25 @@ +.tiptap.ProseMirror { + --horizontal-rule-color: var(--tt-gray-light-a-200); + + .dark & { + --horizontal-rule-color: var(--tt-gray-dark-a-200); + } +} + +/* ===================== + HORIZONTAL RULE + ===================== */ +.tiptap.ProseMirror { + hr { + border: none; + height: 1px; + background-color: var(--horizontal-rule-color); + } + + [data-type="horizontalRule"] { + margin-top: 2.25em; + margin-bottom: 2.25em; + padding-top: 0.75rem; + padding-bottom: 0.75rem; + } +} diff --git a/resources/js/components/tiptap-node/image-node/image-node.scss b/resources/js/components/tiptap-node/image-node/image-node.scss new file mode 100644 index 0000000..ae2239d --- /dev/null +++ b/resources/js/components/tiptap-node/image-node/image-node.scss @@ -0,0 +1,35 @@ +.tiptap.ProseMirror { + img { + max-width: 100%; + height: auto; + display: block; + } + + p > img { + display: inline-block; + } + + > img:not([data-type="emoji"] img) { + margin: 2rem 0; + outline: 0.125rem solid transparent; + border-radius: var(--tt-radius-xs, 0.25rem); + } + + img:not([data-type="emoji"] img).ProseMirror-selectednode { + outline-color: var(--tt-brand-color-500); + } + + // Thread image handling + .tiptap-thread:has(> img) { + margin: 2rem 0; + + img { + outline: 0.125rem solid transparent; + border-radius: var(--tt-radius-xs, 0.25rem); + } + } + + .tiptap-thread img { + margin: 0; + } +} diff --git a/resources/js/components/tiptap-node/image-upload-node/image-upload-node-extension.ts b/resources/js/components/tiptap-node/image-upload-node/image-upload-node-extension.ts new file mode 100644 index 0000000..c282b1b --- /dev/null +++ b/resources/js/components/tiptap-node/image-upload-node/image-upload-node-extension.ts @@ -0,0 +1,162 @@ +import { mergeAttributes, Node } from "@tiptap/react" +import { ReactNodeViewRenderer } from "@tiptap/react" +import { ImageUploadNode as ImageUploadNodeComponent } from "@/components/tiptap-node/image-upload-node/image-upload-node" +import type { NodeType } from "@tiptap/pm/model" + +export type UploadFunction = ( + file: File, + onProgress?: (event: { progress: number }) => void, + abortSignal?: AbortSignal +) => Promise + +export interface ImageUploadNodeOptions { + /** + * The type of the node. + * @default 'image' + */ + type?: string | NodeType | undefined + /** + * Acceptable file types for upload. + * @default 'image/*' + */ + accept?: string + /** + * Maximum number of files that can be uploaded. + * @default 1 + */ + limit?: number + /** + * Maximum file size in bytes (0 for unlimited). + * @default 0 + */ + maxSize?: number + /** + * Function to handle the upload process. + */ + upload?: UploadFunction + /** + * Callback for upload errors. + */ + onError?: (error: Error) => void + /** + * Callback for successful uploads. + */ + onSuccess?: (url: string) => void + /** + * HTML attributes to add to the image element. + * @default {} + * @example { class: 'foo' } + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + HTMLAttributes: Record +} + +declare module "@tiptap/react" { + interface Commands { + imageUpload: { + setImageUploadNode: (options?: ImageUploadNodeOptions) => ReturnType + } + } +} + +/** + * A Tiptap node extension that creates an image upload component. + * @see registry/tiptap-node/image-upload-node/image-upload-node + */ +export const ImageUploadNode = Node.create({ + name: "imageUpload", + + group: "block", + + draggable: true, + + selectable: true, + + atom: true, + + addOptions() { + return { + type: "image", + accept: "image/*", + limit: 1, + maxSize: 0, + upload: undefined, + onError: undefined, + onSuccess: undefined, + HTMLAttributes: {}, + } + }, + + addAttributes() { + return { + accept: { + default: this.options.accept, + }, + limit: { + default: this.options.limit, + }, + maxSize: { + default: this.options.maxSize, + }, + } + }, + + parseHTML() { + return [{ tag: 'div[data-type="image-upload"]' }] + }, + + renderHTML({ HTMLAttributes }) { + return [ + "div", + mergeAttributes({ "data-type": "image-upload" }, HTMLAttributes), + ] + }, + + addNodeView() { + return ReactNodeViewRenderer(ImageUploadNodeComponent) + }, + + addCommands() { + return { + setImageUploadNode: + (options) => + ({ commands }) => { + return commands.insertContent({ + type: this.name, + attrs: options, + }) + }, + } + }, + + /** + * Adds Enter key handler to trigger the upload component when it's selected. + */ + addKeyboardShortcuts() { + return { + Enter: ({ editor }) => { + const { selection } = editor.state + const { nodeAfter } = selection.$from + + if ( + nodeAfter && + nodeAfter.type.name === "imageUpload" && + editor.isActive("imageUpload") + ) { + const nodeEl = editor.view.nodeDOM(selection.$from.pos) + if (nodeEl && nodeEl instanceof HTMLElement) { + // Since NodeViewWrapper is wrapped with a div, we need to click the first child + const firstChild = nodeEl.firstChild + if (firstChild && firstChild instanceof HTMLElement) { + firstChild.click() + return true + } + } + } + return false + }, + } + }, +}) + +export default ImageUploadNode diff --git a/resources/js/components/tiptap-node/image-upload-node/image-upload-node.scss b/resources/js/components/tiptap-node/image-upload-node/image-upload-node.scss new file mode 100644 index 0000000..b85e1e3 --- /dev/null +++ b/resources/js/components/tiptap-node/image-upload-node/image-upload-node.scss @@ -0,0 +1,249 @@ +:root { + --tiptap-image-upload-active: var(--tt-brand-color-500); + --tiptap-image-upload-progress-bg: var(--tt-brand-color-50); + --tiptap-image-upload-icon-bg: var(--tt-brand-color-500); + + --tiptap-image-upload-text-color: var(--tt-gray-light-a-700); + --tiptap-image-upload-subtext-color: var(--tt-gray-light-a-400); + --tiptap-image-upload-border: var(--tt-gray-light-a-300); + --tiptap-image-upload-border-hover: var(--tt-gray-light-a-400); + --tiptap-image-upload-border-active: var(--tt-brand-color-500); + + --tiptap-image-upload-icon-doc-bg: var(--tt-gray-light-a-200); + --tiptap-image-upload-icon-doc-border: var(--tt-gray-light-300); + --tiptap-image-upload-icon-color: var(--white); +} + +.dark { + --tiptap-image-upload-active: var(--tt-brand-color-400); + --tiptap-image-upload-progress-bg: var(--tt-brand-color-900); + --tiptap-image-upload-icon-bg: var(--tt-brand-color-400); + + --tiptap-image-upload-text-color: var(--tt-gray-dark-a-700); + --tiptap-image-upload-subtext-color: var(--tt-gray-dark-a-400); + --tiptap-image-upload-border: var(--tt-gray-dark-a-300); + --tiptap-image-upload-border-hover: var(--tt-gray-dark-a-400); + --tiptap-image-upload-border-active: var(--tt-brand-color-400); + + --tiptap-image-upload-icon-doc-bg: var(--tt-gray-dark-a-200); + --tiptap-image-upload-icon-doc-border: var(--tt-gray-dark-300); + --tiptap-image-upload-icon-color: var(--black); +} + +.tiptap-image-upload { + margin: 2rem 0; + + input[type="file"] { + display: none; + } + + .tiptap-image-upload-dropzone { + position: relative; + width: 3.125rem; + height: 3.75rem; + display: inline-flex; + align-items: flex-start; + justify-content: center; + -webkit-user-select: none; /* Safari */ + -ms-user-select: none; /* IE 10 and IE 11 */ + user-select: none; + } + + .tiptap-image-upload-icon-container { + position: absolute; + width: 1.75rem; + height: 1.75rem; + bottom: 0; + right: 0; + background-color: var(--tiptap-image-upload-icon-bg); + border-radius: var(--tt-radius-lg, 0.75rem); + display: flex; + align-items: center; + justify-content: center; + } + + .tiptap-image-upload-icon { + width: 0.875rem; + height: 0.875rem; + color: var(--tiptap-image-upload-icon-color); + } + + .tiptap-image-upload-dropzone-rect-primary { + color: var(--tiptap-image-upload-icon-doc-bg); + position: absolute; + } + + .tiptap-image-upload-dropzone-rect-secondary { + position: absolute; + top: 0; + right: 0.25rem; + bottom: 0; + color: var(--tiptap-image-upload-icon-doc-border); + } + + .tiptap-image-upload-text { + color: var(--tiptap-image-upload-text-color); + font-weight: 500; + font-size: 0.875rem; + line-height: normal; + + em { + font-style: normal; + text-decoration: underline; + } + } + + .tiptap-image-upload-subtext { + color: var(--tiptap-image-upload-subtext-color); + font-weight: 600; + line-height: normal; + font-size: 0.75rem; + } + + .tiptap-image-upload-drag-area { + padding: 2rem 1.5rem; + border: 1.5px dashed var(--tiptap-image-upload-border); + border-radius: var(--tt-radius-md, 0.5rem); + text-align: center; + cursor: pointer; + position: relative; + overflow: hidden; + transition: all 0.2s ease; + + &:hover { + border-color: var(--tiptap-image-upload-border-hover); + } + + &.drag-active { + border-color: var(--tiptap-image-upload-border-active); + background-color: rgba( + var(--tiptap-image-upload-active-rgb, 0, 123, 255), + 0.05 + ); + } + + &.drag-over { + border-color: var(--tiptap-image-upload-border-active); + background-color: rgba( + var(--tiptap-image-upload-active-rgb, 0, 123, 255), + 0.1 + ); + } + } + + .tiptap-image-upload-content { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 0.25rem; + -webkit-user-select: none; /* Safari */ + -ms-user-select: none; /* IE 10 and IE 11 */ + user-select: none; + } + + .tiptap-image-upload-previews { + display: flex; + flex-direction: column; + gap: 0.75rem; + } + + .tiptap-image-upload-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 0; + border-bottom: 1px solid var(--tiptap-image-upload-border); + margin-bottom: 0.5rem; + + span { + font-size: 0.875rem; + font-weight: 500; + color: var(--tiptap-image-upload-text-color); + } + } + + // === Individual File Preview Styles === + .tiptap-image-upload-preview { + position: relative; + border-radius: var(--tt-radius-md, 0.5rem); + overflow: hidden; + + .tiptap-image-upload-progress { + position: absolute; + inset: 0; + background-color: var(--tiptap-image-upload-progress-bg); + transition: all 300ms ease-out; + } + + .tiptap-image-upload-preview-content { + position: relative; + border: 1px solid var(--tiptap-image-upload-border); + border-radius: var(--tt-radius-md, 0.5rem); + padding: 1rem; + display: flex; + align-items: center; + justify-content: space-between; + } + + .tiptap-image-upload-file-info { + display: flex; + align-items: center; + gap: 0.75rem; + height: 2rem; + + .tiptap-image-upload-file-icon { + padding: 0.5rem; + background-color: var(--tiptap-image-upload-icon-bg); + border-radius: var(--tt-radius-lg, 0.75rem); + + svg { + width: 0.875rem; + height: 0.875rem; + color: var(--tiptap-image-upload-icon-color); + } + } + } + + .tiptap-image-upload-details { + display: flex; + flex-direction: column; + } + + .tiptap-image-upload-actions { + display: flex; + align-items: center; + gap: 0.5rem; + + .tiptap-image-upload-progress-text { + font-size: 0.75rem; + color: var(--tiptap-image-upload-border-active); + font-weight: 600; + } + } + } +} + +.tiptap.ProseMirror.ProseMirror-focused { + .ProseMirror-selectednode .tiptap-image-upload-drag-area { + border-color: var(--tiptap-image-upload-active); + } +} + +@media (max-width: 480px) { + .tiptap-image-upload { + .tiptap-image-upload-drag-area { + padding: 1.5rem 1rem; + } + + .tiptap-image-upload-header { + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + } + + .tiptap-image-upload-preview-content { + padding: 0.75rem; + } + } +} diff --git a/resources/js/components/tiptap-node/image-upload-node/image-upload-node.tsx b/resources/js/components/tiptap-node/image-upload-node/image-upload-node.tsx new file mode 100644 index 0000000..e92b1bc --- /dev/null +++ b/resources/js/components/tiptap-node/image-upload-node/image-upload-node.tsx @@ -0,0 +1,554 @@ +"use client" + +import { useRef, useState } from "react" +import type { NodeViewProps } from "@tiptap/react" +import { NodeViewWrapper } from "@tiptap/react" +import { Button } from "@/components/tiptap-ui-primitive/button" +import { CloseIcon } from "@/components/tiptap-icons/close-icon" +import "@/components/tiptap-node/image-upload-node/image-upload-node.scss" +import { focusNextNode, isValidPosition } from "@/lib/tiptap-utils" + +export interface FileItem { + /** + * Unique identifier for the file item + */ + id: string + /** + * The actual File object being uploaded + */ + file: File + /** + * Current upload progress as a percentage (0-100) + */ + progress: number + /** + * Current status of the file upload process + * @default "uploading" + */ + status: "uploading" | "success" | "error" + + /** + * URL to the uploaded file, available after successful upload + * @optional + */ + url?: string + /** + * Controller that can be used to abort the upload process + * @optional + */ + abortController?: AbortController +} + +export interface UploadOptions { + /** + * Maximum allowed file size in bytes + */ + maxSize: number + /** + * Maximum number of files that can be uploaded + */ + limit: number + /** + * String specifying acceptable file types (MIME types or extensions) + * @example ".jpg,.png,image/jpeg" or "image/*" + */ + accept: string + /** + * Function that handles the actual file upload process + * @param {File} file - The file to be uploaded + * @param {Function} onProgress - Callback function to report upload progress + * @param {AbortSignal} signal - Signal that can be used to abort the upload + * @returns {Promise} Promise resolving to the URL of the uploaded file + */ + upload: ( + file: File, + onProgress: (event: { progress: number }) => void, + signal: AbortSignal + ) => Promise + /** + * Callback triggered when a file is uploaded successfully + * @param {string} url - URL of the successfully uploaded file + * @optional + */ + onSuccess?: (url: string) => void + /** + * Callback triggered when an error occurs during upload + * @param {Error} error - The error that occurred + * @optional + */ + onError?: (error: Error) => void +} + +/** + * Custom hook for managing multiple file uploads with progress tracking and cancellation + */ +function useFileUpload(options: UploadOptions) { + const [fileItems, setFileItems] = useState([]) + + const uploadFile = async (file: File): Promise => { + if (file.size > options.maxSize) { + const error = new Error( + `File size exceeds maximum allowed (${options.maxSize / 1024 / 1024}MB)` + ) + options.onError?.(error) + return null + } + + const abortController = new AbortController() + const fileId = crypto.randomUUID() + + const newFileItem: FileItem = { + id: fileId, + file, + progress: 0, + status: "uploading", + abortController, + } + + setFileItems((prev) => [...prev, newFileItem]) + + try { + if (!options.upload) { + throw new Error("Upload function is not defined") + } + + const url = await options.upload( + file, + (event: { progress: number }) => { + setFileItems((prev) => + prev.map((item) => + item.id === fileId ? { ...item, progress: event.progress } : item + ) + ) + }, + abortController.signal + ) + + if (!url) throw new Error("Upload failed: No URL returned") + + if (!abortController.signal.aborted) { + setFileItems((prev) => + prev.map((item) => + item.id === fileId + ? { ...item, status: "success", url, progress: 100 } + : item + ) + ) + options.onSuccess?.(url) + return url + } + + return null + } catch (error) { + if (!abortController.signal.aborted) { + setFileItems((prev) => + prev.map((item) => + item.id === fileId + ? { ...item, status: "error", progress: 0 } + : item + ) + ) + options.onError?.( + error instanceof Error ? error : new Error("Upload failed") + ) + } + return null + } + } + + const uploadFiles = async (files: File[]): Promise => { + if (!files || files.length === 0) { + options.onError?.(new Error("No files to upload")) + return [] + } + + if (options.limit && files.length > options.limit) { + options.onError?.( + new Error( + `Maximum ${options.limit} file${options.limit === 1 ? "" : "s"} allowed` + ) + ) + return [] + } + + // Upload all files concurrently + const uploadPromises = files.map((file) => uploadFile(file)) + const results = await Promise.all(uploadPromises) + + // Filter out null results (failed uploads) + return results.filter((url): url is string => url !== null) + } + + const removeFileItem = (fileId: string) => { + setFileItems((prev) => { + const fileToRemove = prev.find((item) => item.id === fileId) + if (fileToRemove?.abortController) { + fileToRemove.abortController.abort() + } + if (fileToRemove?.url) { + URL.revokeObjectURL(fileToRemove.url) + } + return prev.filter((item) => item.id !== fileId) + }) + } + + const clearAllFiles = () => { + fileItems.forEach((item) => { + if (item.abortController) { + item.abortController.abort() + } + if (item.url) { + URL.revokeObjectURL(item.url) + } + }) + setFileItems([]) + } + + return { + fileItems, + uploadFiles, + removeFileItem, + clearAllFiles, + } +} + +const CloudUploadIcon: React.FC = () => ( + + + + +) + +const FileIcon: React.FC = () => ( + + + +) + +const FileCornerIcon: React.FC = () => ( + + + +) + +interface ImageUploadDragAreaProps { + /** + * Callback function triggered when files are dropped or selected + * @param {File[]} files - Array of File objects that were dropped or selected + */ + onFile: (files: File[]) => void + /** + * Optional child elements to render inside the drag area + * @optional + * @default undefined + */ + children?: React.ReactNode +} + +/** + * A component that creates a drag-and-drop area for image uploads + */ +const ImageUploadDragArea: React.FC = ({ + onFile, + children, +}) => { + const [isDragOver, setIsDragOver] = useState(false) + const [isDragActive, setIsDragActive] = useState(false) + + const handleDragEnter = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragActive(true) + } + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + if (!e.currentTarget.contains(e.relatedTarget as Node)) { + setIsDragActive(false) + setIsDragOver(false) + } + } + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragOver(true) + } + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragActive(false) + setIsDragOver(false) + + const files = Array.from(e.dataTransfer.files) + if (files.length > 0) { + onFile(files) + } + } + + return ( +
+ {children} +
+ ) +} + +interface ImageUploadPreviewProps { + /** + * The file item to preview + */ + fileItem: FileItem + /** + * Callback to remove this file from upload queue + */ + onRemove: () => void +} + +/** + * Component that displays a preview of an uploading file with progress + */ +const ImageUploadPreview: React.FC = ({ + fileItem, + onRemove, +}) => { + const formatFileSize = (bytes: number) => { + if (bytes === 0) return "0 Bytes" + const k = 1024 + const sizes = ["Bytes", "KB", "MB", "GB"] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}` + } + + return ( +
+ {fileItem.status === "uploading" && ( +
+ )} + +
+
+
+ +
+
+ + {fileItem.file.name} + + + {formatFileSize(fileItem.file.size)} + +
+
+
+ {fileItem.status === "uploading" && ( + + {fileItem.progress}% + + )} + +
+
+
+ ) +} + +const DropZoneContent: React.FC<{ maxSize: number; limit: number }> = ({ + maxSize, + limit, +}) => ( + <> +
+ + +
+ +
+
+ +
+ + Click to upload or drag and drop + + + Maximum {limit} file{limit === 1 ? "" : "s"}, {maxSize / 1024 / 1024}MB + each. + +
+ +) + +export const ImageUploadNode: React.FC = (props) => { + const { accept, limit, maxSize } = props.node.attrs + const inputRef = useRef(null) + const extension = props.extension + + const uploadOptions: UploadOptions = { + maxSize, + limit, + accept, + upload: extension.options.upload, + onSuccess: extension.options.onSuccess, + onError: extension.options.onError, + } + + const { fileItems, uploadFiles, removeFileItem, clearAllFiles } = + useFileUpload(uploadOptions) + + const handleUpload = async (files: File[]) => { + const urls = await uploadFiles(files) + + if (urls.length > 0) { + const pos = props.getPos() + + if (isValidPosition(pos)) { + const imageNodes = urls.map((url, index) => { + const filename = + files[index]?.name.replace(/\.[^/.]+$/, "") || "unknown" + return { + type: extension.options.type, + attrs: { + ...extension.options, + src: url, + alt: filename, + title: filename, + }, + } + }) + + props.editor + .chain() + .focus() + .deleteRange({ from: pos, to: pos + props.node.nodeSize }) + .insertContentAt(pos, imageNodes) + .run() + + focusNextNode(props.editor) + } + } + } + + const handleChange = (e: React.ChangeEvent) => { + const files = e.target.files + if (!files || files.length === 0) { + extension.options.onError?.(new Error("No file selected")) + return + } + handleUpload(Array.from(files)) + } + + const handleClick = () => { + if (inputRef.current && fileItems.length === 0) { + inputRef.current.value = "" + inputRef.current.click() + } + } + + const hasFiles = fileItems.length > 0 + + return ( + + {!hasFiles && ( + + + + )} + + {hasFiles && ( +
+ {fileItems.length > 1 && ( +
+ Uploading {fileItems.length} files + +
+ )} + {fileItems.map((fileItem) => ( + removeFileItem(fileItem.id)} + /> + ))} +
+ )} + + 1} + onChange={handleChange} + onClick={(e: React.MouseEvent) => e.stopPropagation()} + /> +
+ ) +} diff --git a/resources/js/components/tiptap-node/image-upload-node/index.tsx b/resources/js/components/tiptap-node/image-upload-node/index.tsx new file mode 100644 index 0000000..2510a62 --- /dev/null +++ b/resources/js/components/tiptap-node/image-upload-node/index.tsx @@ -0,0 +1 @@ +export * from "./image-upload-node-extension" diff --git a/resources/js/components/tiptap-node/list-node/list-node.scss b/resources/js/components/tiptap-node/list-node/list-node.scss new file mode 100644 index 0000000..8562329 --- /dev/null +++ b/resources/js/components/tiptap-node/list-node/list-node.scss @@ -0,0 +1,208 @@ +.tiptap.ProseMirror { + --tt-checklist-bg-color: var(--tt-gray-light-a-100); + --tt-checklist-bg-active-color: var(--tt-gray-light-a-900); + --tt-checklist-border-color: var(--tt-gray-light-a-200); + --tt-checklist-border-active-color: var(--tt-gray-light-a-900); + --tt-checklist-check-icon-color: var(--white); + --tt-checklist-text-active: var(--tt-gray-light-a-500); + + .dark & { + --tt-checklist-bg-color: var(--tt-gray-dark-a-100); + --tt-checklist-bg-active-color: var(--tt-gray-dark-a-900); + --tt-checklist-border-color: var(--tt-gray-dark-a-200); + --tt-checklist-border-active-color: var(--tt-gray-dark-a-900); + --tt-checklist-check-icon-color: var(--black); + --tt-checklist-text-active: var(--tt-gray-dark-a-500); + } +} + +/* ===================== + LISTS + ===================== */ +.tiptap.ProseMirror { + // Common list styles + ol, + ul { + margin-top: 1.5em; + margin-bottom: 1.5em; + padding-left: 1.5em; + + &:first-child { + margin-top: 0; + } + + &:last-child { + margin-bottom: 0; + } + + ol, + ul { + margin-top: 0; + margin-bottom: 0; + } + } + + li { + p { + margin-top: 0; + line-height: 1.6; + } + } + + // Ordered lists + ol { + list-style: decimal; + + ol { + list-style: lower-alpha; + + ol { + list-style: lower-roman; + + ol { + list-style: decimal; + + ol { + list-style: lower-alpha; + + ol { + list-style: lower-roman; + + ol { + list-style: decimal; + + ol { + list-style: lower-alpha; + + ol { + list-style: lower-roman; + } + } + } + } + } + } + } + } + } + + // Unordered lists + ul:not([data-type="taskList"]) { + list-style: disc; + + ul { + list-style: circle; + + ul { + list-style: square; + + ul { + list-style: disc; + + ul { + list-style: circle; + + ul { + list-style: square; + + ul { + list-style: disc; + + ul { + list-style: circle; + + ul { + list-style: square; + } + } + } + } + } + } + } + } + } + + // Task lists + ul[data-type="taskList"] { + padding-left: 0.25em; + + li { + display: flex; + flex-direction: row; + align-items: flex-start; + + &:not(:has(> p:first-child)) { + list-style-type: none; + } + + &[data-checked="true"] { + > div > p { + opacity: 0.5; + text-decoration: line-through; + } + + > div > p span { + text-decoration: line-through; + } + } + + label { + position: relative; + padding-top: 0.375rem; + padding-right: 0.5rem; + + input[type="checkbox"] { + position: absolute; + opacity: 0; + width: 0; + height: 0; + } + + span { + display: block; + width: 1em; + height: 1em; + border: 1px solid var(--tt-checklist-border-color); + border-radius: var(--tt-radius-xs, 0.25rem); + position: relative; + cursor: pointer; + background-color: var(--tt-checklist-bg-color); + transition: + background-color 80ms ease-out, + border-color 80ms ease-out; + + &::before { + content: ""; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: 0.75em; + height: 0.75em; + background-color: var(--tt-checklist-check-icon-color); + opacity: 0; + -webkit-mask: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22currentColor%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M21.4142%204.58579C22.1953%205.36683%2022.1953%206.63317%2021.4142%207.41421L10.4142%2018.4142C9.63317%2019.1953%208.36684%2019.1953%207.58579%2018.4142L2.58579%2013.4142C1.80474%2012.6332%201.80474%2011.3668%202.58579%2010.5858C3.36683%209.80474%204.63317%209.80474%205.41421%2010.5858L9%2014.1716L18.5858%204.58579C19.3668%203.80474%2020.6332%203.80474%2021.4142%204.58579Z%22%20fill%3D%22currentColor%22%2F%3E%3C%2Fsvg%3E") + center/contain no-repeat; + mask: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22currentColor%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M21.4142%204.58579C22.1953%205.36683%2022.1953%206.63317%2021.4142%207.41421L10.4142%2018.4142C9.63317%2019.1953%208.36684%2019.1953%207.58579%2018.4142L2.58579%2013.4142C1.80474%2012.6332%201.80474%2011.3668%202.58579%2010.5858C3.36683%209.80474%204.63317%209.80474%205.41421%2010.5858L9%2014.1716L18.5858%204.58579C19.3668%203.80474%2020.6332%203.80474%2021.4142%204.58579Z%22%20fill%3D%22currentColor%22%2F%3E%3C%2Fsvg%3E") + center/contain no-repeat; + } + } + + input[type="checkbox"]:checked + span { + background: var(--tt-checklist-bg-active-color); + border-color: var(--tt-checklist-border-active-color); + + &::before { + opacity: 1; + } + } + } + + div { + flex: 1 1 0%; + min-width: 0; + } + } + } +} diff --git a/resources/js/components/tiptap-node/paragraph-node/paragraph-node.scss b/resources/js/components/tiptap-node/paragraph-node/paragraph-node.scss new file mode 100644 index 0000000..5e111b4 --- /dev/null +++ b/resources/js/components/tiptap-node/paragraph-node/paragraph-node.scss @@ -0,0 +1,286 @@ +.tiptap.ProseMirror { + --tt-collaboration-carets-label: var(--tt-gray-light-900); + --link-text-color: var(--tt-brand-color-500); + --thread-text: var(--tt-gray-light-900); + --placeholder-color: var(--tt-gray-light-a-400); + --thread-bg-color: var(--tt-color-yellow-inc-2); + + // ai + --tiptap-ai-insertion-color: var(--tt-brand-color-600); + + .dark & { + --tt-collaboration-carets-label: var(--tt-gray-dark-100); + --link-text-color: var(--tt-brand-color-400); + --thread-text: var(--tt-gray-dark-900); + --placeholder-color: var(--tt-gray-dark-a-400); + --thread-bg-color: var(--tt-color-yellow-dec-2); + + --tiptap-ai-insertion-color: var(--tt-brand-color-400); + } +} + +/* Ensure each top-level node has relative positioning + so absolutely positioned placeholders work correctly */ +.tiptap.ProseMirror > * { + position: relative; +} + +/* ===================== + CORE EDITOR STYLES + ===================== */ +.tiptap.ProseMirror { + white-space: pre-wrap; + outline: none; + caret-color: var(--tt-cursor-color); + + // Paragraph spacing + p:not(:first-child):not(td p):not(th p) { + font-size: 1rem; + line-height: 1.6; + font-weight: normal; + margin-top: 20px; + } + + // Selection styles + &:not(.readonly):not(.ProseMirror-hideselection) { + ::selection { + background-color: var(--tt-selection-color); + } + + .selection::selection { + background: transparent; + } + } + + .selection { + display: inline; + background-color: var(--tt-selection-color); + } + + // Selected node styles + .ProseMirror-selectednode:not(img):not(pre):not(.react-renderer) { + border-radius: var(--tt-radius-md); + background-color: var(--tt-selection-color); + } + + .ProseMirror-hideselection { + caret-color: transparent; + } + + // Resize cursor + &.resize-cursor { + cursor: ew-resize; + cursor: col-resize; + } +} + +/* ===================== + TEXT DECORATION + ===================== */ +.tiptap.ProseMirror { + // Text decoration inheritance for spans + a span { + text-decoration: underline; + } + + s span { + text-decoration: line-through; + } + + u span { + text-decoration: underline; + } + + .tiptap-ai-insertion { + color: var(--tiptap-ai-insertion-color); + } +} + +/* ===================== + COLLABORATION + ===================== */ +.tiptap.ProseMirror { + .collaboration-carets { + &__caret { + border-right: 1px solid transparent; + border-left: 1px solid transparent; + pointer-events: none; + margin-left: -1px; + margin-right: -1px; + position: relative; + word-break: normal; + } + + &__label { + color: var(--tt-collaboration-carets-label); + border-radius: 0.25rem; + border-bottom-left-radius: 0; + font-size: 0.75rem; + font-weight: 600; + left: -1px; + line-height: 1; + padding: 0.125rem 0.375rem; + position: absolute; + top: -1.3em; + user-select: none; + white-space: nowrap; + } + } +} + +/* ===================== + EMOJI + ===================== */ +.tiptap.ProseMirror [data-type="emoji"] img { + display: inline-block; + width: 1.25em; + height: 1.25em; + cursor: text; +} + +/* ===================== + LINKS + ===================== */ +.tiptap.ProseMirror { + a { + color: var(--link-text-color); + text-decoration: underline; + } +} + +/* ===================== + MENTION + ===================== */ +.tiptap.ProseMirror { + [data-type="mention"] { + display: inline-block; + color: var(--tt-brand-color-500); + } +} + +/* ===================== + THREADS + ===================== */ +.tiptap.ProseMirror { + // Base styles for inline threads + .tiptap-thread.tiptap-thread--unresolved.tiptap-thread--inline { + transition: + color 0.2s ease-in-out, + background-color 0.2s ease-in-out; + color: var(--thread-text); + border-bottom: 2px dashed var(--tt-color-yellow-base); + font-weight: 600; + + &.tiptap-thread--selected, + &.tiptap-thread--hovered { + background-color: var(--thread-bg-color); + border-bottom-color: transparent; + } + } + + // Block thread styles with images + .tiptap-thread.tiptap-thread--unresolved.tiptap-thread--block { + &:has(img) { + outline: 0.125rem solid var(--tt-color-yellow-base); + border-radius: var(--tt-radius-xs, 0.25rem); + overflow: hidden; + width: fit-content; + + &.tiptap-thread--selected { + outline-width: 0.25rem; + outline-color: var(--tt-color-yellow-base); + } + + &.tiptap-thread--hovered { + outline-width: 0.25rem; + } + } + + // Block thread styles without images + &:not(:has(img)) { + border-radius: 0.25rem; + border-bottom: 0.125rem dashed var(--tt-color-yellow-base); + border-top: 0.125rem dashed var(--tt-color-yellow-base); + // padding-bottom: 0.5rem; + outline: 0.25rem solid transparent; + + &.tiptap-thread--hovered, + &.tiptap-thread--selected { + background-color: var(--tt-color-yellow-base); + outline-color: var(--tt-color-yellow-base); + } + } + } + + // Resolved thread styles + .tiptap-thread.tiptap-thread--resolved.tiptap-thread--inline.tiptap-thread--selected { + background-color: var(--tt-color-yellow-base); + border-color: transparent; + opacity: 0.5; + } + + // React renderer specific styles + .tiptap-thread.tiptap-thread--block:has(.react-renderer) { + margin-top: 3rem; + margin-bottom: 3rem; + } +} + +/* ===================== + PLACEHOLDER + ===================== */ +.is-empty:not(.with-slash)[data-placeholder]:has( + > .ProseMirror-trailingBreak:only-child + )::before { + content: attr(data-placeholder); +} + +.is-empty.with-slash[data-placeholder]:has( + > .ProseMirror-trailingBreak:only-child + )::before { + content: "Write, type '/' for commands…"; + font-style: italic; +} + +.is-empty[data-placeholder]:has( + > .ProseMirror-trailingBreak:only-child + ):before { + pointer-events: none; + height: 0; + position: absolute; + width: 100%; + text-align: inherit; + left: 0; + right: 0; +} + +.is-empty[data-placeholder]:has(> .ProseMirror-trailingBreak):before { + color: var(--placeholder-color); +} + +/* ===================== + DROPCURSOR + ===================== */ +.prosemirror-dropcursor-block, +.prosemirror-dropcursor-inline { + background: var(--tt-brand-color-400) !important; + border-radius: 0.25rem; + margin-left: -1px; + margin-right: -1px; + width: 100%; + height: 0.188rem; + cursor: grabbing; +} + +/* ===================== + INDENT + ===================== */ + +.tiptap.ProseMirror { + --tt-indent-unit: 24px; + + [data-indent] { + transition: margin-left 0.2s ease-in-out; + margin-left: calc(var(--tt-indent-unit) * var(--tt-indent-level, 0)); + } +} diff --git a/resources/js/components/tiptap-templates/simple/data/content.json b/resources/js/components/tiptap-templates/simple/data/content.json new file mode 100644 index 0000000..4a3c0e8 --- /dev/null +++ b/resources/js/components/tiptap-templates/simple/data/content.json @@ -0,0 +1,477 @@ +{ + "type": "doc", + "content": [ + { + "type": "heading", + "attrs": { + "textAlign": null, + "level": 1 + }, + "content": [ + { + "type": "text", + "text": "Getting started" + } + ] + }, + { + "type": "paragraph", + "attrs": { + "textAlign": null + }, + "content": [ + { + "type": "text", + "text": "Welcome to the " + }, + { + "type": "text", + "marks": [ + { + "type": "italic" + }, + { + "type": "highlight", + "attrs": { + "color": "var(--tt-color-highlight-yellow)" + } + } + ], + "text": "Simple Editor" + }, + { + "type": "text", + "text": " template! This template integrates " + }, + { + "type": "text", + "marks": [ + { + "type": "bold" + } + ], + "text": "open source" + }, + { + "type": "text", + "text": " UI components and Tiptap extensions licensed under " + }, + { + "type": "text", + "marks": [ + { + "type": "bold" + } + ], + "text": "MIT" + }, + { + "type": "text", + "text": "." + } + ] + }, + { + "type": "paragraph", + "attrs": { + "textAlign": null + }, + "content": [ + { + "type": "text", + "text": "Integrate it by following the " + }, + { + "type": "text", + "marks": [ + { + "type": "link", + "attrs": { + "href": "https://tiptap.dev/docs/ui-components/templates/simple-editor", + "target": "_blank", + "rel": "noopener noreferrer nofollow", + "class": null + } + } + ], + "text": "Tiptap UI Components docs" + }, + { + "type": "text", + "text": " or using our CLI tool." + } + ] + }, + { + "type": "codeBlock", + "attrs": { + "language": null + }, + "content": [ + { + "type": "text", + "text": "npx @tiptap/cli init" + } + ] + }, + { + "type": "heading", + "attrs": { + "textAlign": null, + "level": 2 + }, + "content": [ + { + "type": "text", + "text": "Features" + } + ] + }, + { + "type": "blockquote", + "content": [ + { + "type": "paragraph", + "attrs": { + "textAlign": null + }, + "content": [ + { + "type": "text", + "marks": [ + { + "type": "italic" + } + ], + "text": "A fully responsive rich text editor with built-in support for common formatting and layout tools. Type markdown " + }, + { + "type": "text", + "marks": [ + { + "type": "code" + } + ], + "text": "**" + }, + { + "type": "text", + "marks": [ + { + "type": "italic" + } + ], + "text": " or use keyboard shortcuts " + }, + { + "type": "text", + "marks": [ + { + "type": "code" + } + ], + "text": "⌘+B" + }, + { + "type": "text", + "text": " for " + }, + { + "type": "text", + "marks": [ + { + "type": "strike" + } + ], + "text": "most" + }, + { + "type": "text", + "text": " all common markdown marks. 🪄" + } + ] + } + ] + }, + { + "type": "paragraph", + "attrs": { + "textAlign": "left" + }, + "content": [ + { + "type": "text", + "text": "Add images, customize alignment, and apply " + }, + { + "type": "text", + "marks": [ + { + "type": "highlight", + "attrs": { + "color": "var(--tt-color-highlight-blue)" + } + } + ], + "text": "advanced formatting" + }, + { + "type": "text", + "text": " to make your writing more engaging and professional." + } + ] + }, + { + "type": "image", + "attrs": { + "src": "/images/tiptap-ui-placeholder-image.jpg", + "alt": "placeholder-image", + "title": "placeholder-image" + } + }, + { + "type": "bulletList", + "content": [ + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "attrs": { + "textAlign": "left" + }, + "content": [ + { + "type": "text", + "marks": [ + { + "type": "bold" + } + ], + "text": "Superscript" + }, + { + "type": "text", + "text": " (x" + }, + { + "type": "text", + "marks": [ + { + "type": "superscript" + } + ], + "text": "2" + }, + { + "type": "text", + "text": ") and " + }, + { + "type": "text", + "marks": [ + { + "type": "bold" + } + ], + "text": "Subscript" + }, + { + "type": "text", + "text": " (H" + }, + { + "type": "text", + "marks": [ + { + "type": "subscript" + } + ], + "text": "2" + }, + { + "type": "text", + "text": "O) for precision." + } + ] + } + ] + }, + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "attrs": { + "textAlign": "left" + }, + "content": [ + { + "type": "text", + "marks": [ + { + "type": "bold" + } + ], + "text": "Typographic conversion" + }, + { + "type": "text", + "text": ": automatically convert to " + }, + { + "type": "text", + "marks": [ + { + "type": "code" + } + ], + "text": "->" + }, + { + "type": "text", + "text": " an arrow " + }, + { + "type": "text", + "marks": [ + { + "type": "bold" + } + ], + "text": "→" + }, + { + "type": "text", + "text": "." + } + ] + } + ] + } + ] + }, + { + "type": "paragraph", + "attrs": { + "textAlign": "left" + }, + "content": [ + { + "type": "text", + "marks": [ + { + "type": "italic" + } + ], + "text": "→ " + }, + { + "type": "text", + "marks": [ + { + "type": "link", + "attrs": { + "href": "https://tiptap.dev/docs/ui-components/templates/simple-editor#features", + "target": "_blank", + "rel": "noopener noreferrer nofollow", + "class": null + } + } + ], + "text": "Learn more" + } + ] + }, + { + "type": "horizontalRule" + }, + { + "type": "heading", + "attrs": { + "textAlign": "left", + "level": 2 + }, + "content": [ + { + "type": "text", + "text": "Make it your own" + } + ] + }, + { + "type": "paragraph", + "attrs": { + "textAlign": "left" + }, + "content": [ + { + "type": "text", + "text": "Switch between light and dark modes, and tailor the editor's appearance with customizable CSS to match your style." + } + ] + }, + { + "type": "taskList", + "content": [ + { + "type": "taskItem", + "attrs": { + "checked": true + }, + "content": [ + { + "type": "paragraph", + "attrs": { + "textAlign": "left" + }, + "content": [ + { + "type": "text", + "text": "Test template" + } + ] + } + ] + }, + { + "type": "taskItem", + "attrs": { + "checked": false + }, + "content": [ + { + "type": "paragraph", + "attrs": { + "textAlign": "left" + }, + "content": [ + { + "type": "text", + "marks": [ + { + "type": "link", + "attrs": { + "href": "https://tiptap.dev/docs/ui-components/templates/simple-editor", + "target": "_blank", + "rel": "noopener noreferrer nofollow", + "class": null + } + } + ], + "text": "Integrate the free template" + } + ] + } + ] + } + ] + }, + { + "type": "paragraph", + "attrs": { + "textAlign": "left" + } + } + ] +} diff --git a/resources/js/components/tiptap-templates/simple/simple-editor.scss b/resources/js/components/tiptap-templates/simple/simple-editor.scss new file mode 100644 index 0000000..9414be5 --- /dev/null +++ b/resources/js/components/tiptap-templates/simple/simple-editor.scss @@ -0,0 +1,59 @@ +@import url("https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap"); + +.simple-editor-wrapper { + --tt-toolbar-height: 44px; + --tt-theme-text: var(--tt-gray-light-900); + + .dark & { + --tt-theme-text: var(--tt-gray-dark-900); + } +} + + +.tiptap.ProseMirror { + font-family: "DM Sans", sans-serif; +} + +.simple-editor-wrapper { + display: flex; + flex-direction: column; + width: 100%; + height: auto; + min-height: 200px; + border: 1px solid var(--border); + border-radius: var(--radius); + background-color: var(--background); + overflow: hidden; // Prevents border being covered by content +} + +.simple-editor-content { + flex: 1; + display: flex; + flex-direction: column; + overflow-y: auto; + min-height: 150px; +} + +.simple-editor-content .tiptap.ProseMirror.simple-editor { + flex: 1; + padding: 1rem; + outline: none; + min-height: 150px; +} + +// Ensure toolbar is visible and at the top on mobile +.simple-editor-wrapper .tiptap-toolbar[data-variant="fixed"] { + @media screen and (max-width: 480px) { + position: sticky; + top: 0; + bottom: auto; + border-top: none; + border-bottom: 1px solid var(--tt-toolbar-border-color); + } +} + +@media screen and (max-width: 480px) { + .simple-editor-content .tiptap.ProseMirror.simple-editor { + padding: 1rem; + } +} diff --git a/resources/js/components/tiptap-templates/simple/simple-editor.tsx b/resources/js/components/tiptap-templates/simple/simple-editor.tsx new file mode 100644 index 0000000..de909ab --- /dev/null +++ b/resources/js/components/tiptap-templates/simple/simple-editor.tsx @@ -0,0 +1,259 @@ +"use client" + +import { useEffect, useRef, useState } from "react" +import { EditorContent, EditorContext, useEditor } from "@tiptap/react" + +// --- Tiptap Core Extensions --- +import { StarterKit } from "@tiptap/starter-kit" +import { Image } from "@tiptap/extension-image" +import { TaskItem, TaskList } from "@tiptap/extension-list" +import { TextAlign } from "@tiptap/extension-text-align" +import { Typography } from "@tiptap/extension-typography" +import { Highlight } from "@tiptap/extension-highlight" +import { Subscript } from "@tiptap/extension-subscript" +import { Superscript } from "@tiptap/extension-superscript" +import { Selection } from "@tiptap/extensions" + +// --- UI Primitives --- +import { Button } from "@/components/tiptap-ui-primitive/button" +import { Spacer } from "@/components/tiptap-ui-primitive/spacer" +import { + Toolbar, + ToolbarGroup, + ToolbarSeparator, +} from "@/components/tiptap-ui-primitive/toolbar" + +// --- Tiptap Node --- +import { ImageUploadNode } from "@/components/tiptap-node/image-upload-node/image-upload-node-extension" +import { HorizontalRule } from "@/components/tiptap-node/horizontal-rule-node/horizontal-rule-node-extension" +import "@/components/tiptap-node/blockquote-node/blockquote-node.scss" +import "@/components/tiptap-node/code-block-node/code-block-node.scss" +import "@/components/tiptap-node/horizontal-rule-node/horizontal-rule-node.scss" +import "@/components/tiptap-node/list-node/list-node.scss" +import "@/components/tiptap-node/image-node/image-node.scss" +import "@/components/tiptap-node/heading-node/heading-node.scss" +import "@/components/tiptap-node/paragraph-node/paragraph-node.scss" + +// --- Tiptap UI --- +import { HeadingDropdownMenu } from "@/components/tiptap-ui/heading-dropdown-menu" +import { ImageUploadButton } from "@/components/tiptap-ui/image-upload-button" +import { ListDropdownMenu } from "@/components/tiptap-ui/list-dropdown-menu" +import { BlockquoteButton } from "@/components/tiptap-ui/blockquote-button" +import { CodeBlockButton } from "@/components/tiptap-ui/code-block-button" +import { + ColorHighlightPopover, + ColorHighlightPopoverContent, + ColorHighlightPopoverButton, +} from "@/components/tiptap-ui/color-highlight-popover" +import { + LinkPopover, + LinkContent, + LinkButton, +} from "@/components/tiptap-ui/link-popover" +import { MarkButton } from "@/components/tiptap-ui/mark-button" +import { TextAlignButton } from "@/components/tiptap-ui/text-align-button" +import { UndoRedoButton } from "@/components/tiptap-ui/undo-redo-button" + +// --- Icons --- +import { ArrowLeftIcon } from "@/components/tiptap-icons/arrow-left-icon" +import { HighlighterIcon } from "@/components/tiptap-icons/highlighter-icon" +import { LinkIcon } from "@/components/tiptap-icons/link-icon" + +// --- Hooks --- +import { useIsBreakpoint } from "@/hooks/use-is-breakpoint" + + +// --- Lib --- +import { handleImageUpload, MAX_FILE_SIZE } from "@/lib/tiptap-utils" + +// --- Styles --- +import "@/components/tiptap-templates/simple/simple-editor.scss" + + + +const MainToolbarContent = ({ + onHighlighterClick, + onLinkClick, + isMobile, +}: { + onHighlighterClick: () => void + onLinkClick: () => void + isMobile: boolean +}) => { + return ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + {!isMobile ? ( + + ) : ( + + )} + {!isMobile ? : } + + + + + + + + + + + + + + + + + + + + + + + + + + + {isMobile && } + + ) +} + +const MobileToolbarContent = ({ + type, + onBack, +}: { + type: "highlighter" | "link" + onBack: () => void +}) => ( + <> + + + + + + + {type === "highlighter" ? ( + + ) : ( + + )} + +) + +export function SimpleEditor() { + const isMobile = useIsBreakpoint() + const [mobileView, setMobileView] = useState<"main" | "highlighter" | "link">( + "main" + ) + const toolbarRef = useRef(null) + + const editor = useEditor({ + immediatelyRender: false, + editorProps: { + attributes: { + autocomplete: "off", + autocorrect: "off", + autocapitalize: "off", + "aria-label": "Main content area, start typing to enter text.", + class: "simple-editor", + }, + }, + extensions: [ + StarterKit.configure({ + horizontalRule: false, + link: { + openOnClick: false, + enableClickSelection: true, + }, + }), + HorizontalRule, + TextAlign.configure({ types: ["heading", "paragraph"] }), + TaskList, + TaskItem.configure({ nested: true }), + Highlight.configure({ multicolor: true }), + Image, + Typography, + Superscript, + Subscript, + Selection, + ImageUploadNode.configure({ + accept: "image/*", + maxSize: MAX_FILE_SIZE, + limit: 3, + upload: handleImageUpload, + onError: (error) => console.error("Upload failed:", error), + }), + ], + content: "", + }) + + + + useEffect(() => { + if (!isMobile && mobileView !== "main") { + setMobileView("main") + } + }, [isMobile, mobileView]) + + return ( +
+ + + {mobileView === "main" ? ( + setMobileView("highlighter")} + onLinkClick={() => setMobileView("link")} + isMobile={isMobile} + /> + ) : ( + setMobileView("main")} + /> + )} + + + + +
+ ) +} diff --git a/resources/js/components/tiptap-templates/simple/theme-toggle.tsx b/resources/js/components/tiptap-templates/simple/theme-toggle.tsx new file mode 100644 index 0000000..45355b8 --- /dev/null +++ b/resources/js/components/tiptap-templates/simple/theme-toggle.tsx @@ -0,0 +1,44 @@ +import { Button } from "@/components/tiptap-ui-primitive/button" + +// --- Icons --- +import { MoonStarIcon } from "@/components/tiptap-icons/moon-star-icon" +import { SunIcon } from "@/components/tiptap-icons/sun-icon" +import { useEffect, useState } from "react" + +export function ThemeToggle() { + const [isDarkMode, setIsDarkMode] = useState(false) + + useEffect(() => { + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)") + const handleChange = () => setIsDarkMode(mediaQuery.matches) + mediaQuery.addEventListener("change", handleChange) + return () => mediaQuery.removeEventListener("change", handleChange) + }, []) + + useEffect(() => { + const initialDarkMode = + !!document.querySelector('meta[name="color-scheme"][content="dark"]') || + window.matchMedia("(prefers-color-scheme: dark)").matches + setIsDarkMode(initialDarkMode) + }, []) + + useEffect(() => { + document.documentElement.classList.toggle("dark", isDarkMode) + }, [isDarkMode]) + + const toggleDarkMode = () => setIsDarkMode((isDark) => !isDark) + + return ( + + ) +} diff --git a/resources/js/components/tiptap-ui-primitive/badge/badge-colors.scss b/resources/js/components/tiptap-ui-primitive/badge/badge-colors.scss new file mode 100644 index 0000000..8f8a988 --- /dev/null +++ b/resources/js/components/tiptap-ui-primitive/badge/badge-colors.scss @@ -0,0 +1,395 @@ +.tiptap-badge { + /************************************************** + Default + **************************************************/ + + /* Light mode */ + --tt-badge-border-color: var(--tt-gray-light-a-200); + --tt-badge-border-color-subdued: var(--tt-gray-light-a-200); + --tt-badge-border-color-emphasized: var(--tt-gray-light-a-600); + --tt-badge-text-color: var(--tt-gray-light-a-500); + --tt-badge-text-color-subdued: var( + --tt-gray-light-a-400 + ); //less important badge + --tt-badge-text-color-emphasized: var( + --tt-gray-light-a-600 + ); //more important badge + --tt-badge-bg-color: var(--white); + --tt-badge-bg-color-subdued: var(--white); //less important badge + --tt-badge-bg-color-emphasized: var(--white); //more important badge + --tt-badge-icon-color: var(--tt-gray-light-a-500); + --tt-badge-icon-color-subdued: var( + --tt-gray-light-a-400 + ); //less important badge + --tt-badge-icon-color-emphasized: var( + --tt-brand-color-600 + ); //more important badge + + /* Dark mode */ + .dark & { + --tt-badge-border-color: var(--tt-gray-dark-a-200); + --tt-badge-border-color-subdued: var(--tt-gray-dark-a-200); + --tt-badge-border-color-emphasized: var(--tt-gray-dark-a-500); + --tt-badge-text-color: var(--tt-gray-dark-a-500); + --tt-badge-text-color-subdued: var( + --tt-gray-dark-a-400 + ); //less important badge + --tt-badge-text-color-emphasized: var( + --tt-gray-dark-a-600 + ); //more important badge + --tt-badge-bg-color: var(--black); + --tt-badge-bg-color-subdued: var(--black); //less important badge + --tt-badge-bg-color-emphasized: var(--black); //more important badge + --tt-badge-icon-color: var(--tt-gray-dark-a-500); + --tt-badge-icon-color-subdued: var( + --tt-gray-dark-a-400 + ); //less important badge + --tt-badge-icon-color-emphasized: var( + --tt-brand-color-400 + ); //more important badge + } + + /************************************************** + Ghost + **************************************************/ + + &[data-style="ghost"] { + /* Light mode */ + --tt-badge-border-color: var(--tt-gray-light-a-200); + --tt-badge-border-color-subdued: var(--tt-gray-light-a-200); + --tt-badge-border-color-emphasized: var(--tt-gray-light-a-600); + --tt-badge-text-color: var(--tt-gray-light-a-500); + --tt-badge-text-color-subdued: var( + --tt-gray-light-a-400 + ); //less important badge + --tt-badge-text-color-emphasized: var( + --tt-gray-light-a-600 + ); //more important badge + --tt-badge-bg-color: var(--transparent); + --tt-badge-bg-color-subdued: var(--transparent); //less important badge + --tt-badge-bg-color-emphasized: var(--transparent); //more important badge + --tt-badge-icon-color: var(--tt-gray-light-a-500); + --tt-badge-icon-color-subdued: var( + --tt-gray-light-a-400 + ); //less important badge + --tt-badge-icon-color-emphasized: var( + --tt-brand-color-600 + ); //more important badge + + /* Dark mode */ + .dark & { + --tt-badge-border-color: var(--tt-gray-dark-a-200); + --tt-badge-border-color-subdued: var(--tt-gray-dark-a-200); + --tt-badge-border-color-emphasized: var(--tt-gray-dark-a-500); + --tt-badge-text-color: var(--tt-gray-dark-a-500); + --tt-badge-text-color-subdued: var( + --tt-gray-dark-a-400 + ); //less important badge + --tt-badge-text-color-emphasized: var( + --tt-gray-dark-a-600 + ); //more important badge + --tt-badge-bg-color: var(--transparent); + --tt-badge-bg-color-subdued: var(--transparent); //less important badge + --tt-badge-bg-color-emphasized: var(--transparent); //more important badge + --tt-badge-icon-color: var(--tt-gray-dark-a-500); + --tt-badge-icon-color-subdued: var( + --tt-gray-dark-a-400 + ); //less important badge + --tt-badge-icon-color-emphasized: var( + --tt-brand-color-400 + ); //more important badge + } + } + + /************************************************** + Gray + **************************************************/ + + &[data-style="gray"] { + /* Light mode */ + --tt-badge-border-color: var(--tt-gray-light-a-200); + --tt-badge-border-color-subdued: var(--tt-gray-light-a-200); + --tt-badge-border-color-emphasized: var(--tt-gray-light-a-500); + --tt-badge-text-color: var(--tt-gray-light-a-500); + --tt-badge-text-color-subdued: var( + --tt-gray-light-a-400 + ); //less important badge + --tt-badge-text-color-emphasized: var(--white); //more important badge + --tt-badge-bg-color: var(--tt-gray-light-a-100); + --tt-badge-bg-color-subdued: var( + --tt-gray-light-a-50 + ); //less important badge + --tt-badge-bg-color-emphasized: var( + --tt-gray-light-a-700 + ); //more important badge + --tt-badge-icon-color: var(--tt-gray-light-a-500); + --tt-badge-icon-color-subdued: var( + --tt-gray-light-a-400 + ); //less important badge + --tt-badge-icon-color-emphasized: var(--white); //more important badge + + /* Dark mode */ + .dark & { + --tt-badge-border-color: var(--tt-gray-dark-a-200); + --tt-badge-border-color-subdued: var(--tt-gray-dark-a-200); + --tt-badge-border-color-emphasized: var(--tt-gray-dark-a-500); + --tt-badge-text-color: var(--tt-gray-dark-a-500); + --tt-badge-text-color-subdued: var( + --tt-gray-dark-a-400 + ); //less important badge + --tt-badge-text-color-emphasized: var(--black); //more important badge + --tt-badge-bg-color: var(--tt-gray-dark-a-100); + --tt-badge-bg-color-subdued: var( + --tt-gray-dark-a-50 + ); //less important badge + --tt-badge-bg-color-emphasized: var( + --tt-gray-dark-a-800 + ); //more important badge + --tt-badge-icon-color: var(--tt-gray-dark-a-500); + --tt-badge-icon-color-subdued: var( + --tt-gray-dark-a-400 + ); //less important badge + --tt-badge-icon-color-emphasized: var(--black); //more important badge + } + } + + /************************************************** + Green + **************************************************/ + + &[data-style="green"] { + /* Light mode */ + --tt-badge-border-color: var(--tt-color-green-inc-2); + --tt-badge-border-color-subdued: var(--tt-color-green-inc-3); + --tt-badge-border-color-emphasized: var(--tt-color-green-dec-2); + --tt-badge-text-color: var(--tt-color-green-dec-3); + --tt-badge-text-color-subdued: var( + --tt-color-green-dec-2 + ); //less important badge + --tt-badge-text-color-emphasized: var( + --tt-color-green-inc-5 + ); //more important badge + --tt-badge-bg-color: var(--tt-color-green-inc-4); + --tt-badge-bg-color-subdued: var( + --tt-color-green-inc-5 + ); //less important badge + --tt-badge-bg-color-emphasized: var( + --tt-color-green-dec-1 + ); //more important badge + --tt-badge-icon-color: var(--tt-color-green-dec-3); + --tt-badge-icon-color-subdued: var( + --tt-color-green-dec-2 + ); //less important badge + --tt-badge-icon-color-emphasized: var( + --tt-color-green-inc-5 + ); //more important badge + + /* Dark mode */ + .dark & { + --tt-badge-border-color: var(--tt-color-green-dec-2); + --tt-badge-border-color-subdued: var(--tt-color-green-dec-3); + --tt-badge-border-color-emphasized: var(--tt-color-green-base); + --tt-badge-text-color: var(--tt-color-green-inc-3); + --tt-badge-text-color-subdued: var( + --tt-color-green-inc-2 + ); //less important badge + --tt-badge-text-color-emphasized: var( + --tt-color-green-dec-5 + ); //more important badge + --tt-badge-bg-color: var(--tt-color-green-dec-4); + --tt-badge-bg-color-subdued: var( + --tt-color-green-dec-5 + ); //less important badge + --tt-badge-bg-color-emphasized: var( + --tt-color-green-inc-1 + ); //more important badge + --tt-badge-icon-color: var(--tt-color-green-inc-3); + --tt-badge-icon-color-subdued: var( + --tt-color-green-inc-2 + ); //less important badge + --tt-badge-icon-color-emphasized: var( + --tt-color-green-dec-5 + ); //more important badge + } + } + + /************************************************** + Yellow + **************************************************/ + + &[data-style="yellow"] { + /* Light mode */ + --tt-badge-border-color: var(--tt-color-yellow-inc-2); + --tt-badge-border-color-subdued: var(--tt-color-yellow-inc-3); + --tt-badge-border-color-emphasized: var(--tt-color-yellow-dec-1); + --tt-badge-text-color: var(--tt-color-yellow-dec-3); + --tt-badge-text-color-subdued: var( + --tt-color-yellow-dec-2 + ); //less important badge + --tt-badge-text-color-emphasized: var( + --tt-color-yellow-dec-3 + ); //more important badge + --tt-badge-bg-color: var(--tt-color-yellow-inc-4); + --tt-badge-bg-color-subdued: var( + --tt-color-yellow-inc-5 + ); //less important badge + --tt-badge-bg-color-emphasized: var( + --tt-color-yellow-base + ); //more important badge + --tt-badge-icon-color: var(--tt-color-yellow-dec-3); + --tt-badge-icon-color-subdued: var( + --tt-color-yellow-dec-2 + ); //less important badge + --tt-badge-icon-color-emphasized: var( + --tt-color-yellow-dec-3 + ); //more important badge + + /* Dark mode */ + .dark & { + --tt-badge-border-color: var(--tt-color-yellow-dec-2); + --tt-badge-border-color-subdued: var(--tt-color-yellow-dec-3); + --tt-badge-border-color-emphasized: var(--tt-color-yellow-inc-1); + --tt-badge-text-color: var(--tt-color-yellow-inc-3); + --tt-badge-text-color-subdued: var( + --tt-color-yellow-inc-2 + ); //less important badge + --tt-badge-text-color-emphasized: var( + --tt-color-yellow-dec-3 + ); //more important badge + --tt-badge-bg-color: var(--tt-color-yellow-dec-4); + --tt-badge-bg-color-subdued: var( + --tt-color-yellow-dec-5 + ); //less important badge + --tt-badge-bg-color-emphasized: var( + --tt-color-yellow-base + ); //more important badge + --tt-badge-icon-color: var(--tt-color-yellow-inc-3); + --tt-badge-icon-color-subdued: var( + --tt-color-yellow-inc-2 + ); //less important badge + --tt-badge-icon-color-emphasized: var( + --tt-color-yellow-dec-3 + ); //more important badge + } + } + + /************************************************** + Red + **************************************************/ + + &[data-style="red"] { + /* Light mode */ + --tt-badge-border-color: var(--tt-color-red-inc-2); + --tt-badge-border-color-subdued: var(--tt-color-red-inc-3); + --tt-badge-border-color-emphasized: var(--tt-color-red-dec-2); + --tt-badge-text-color: var(--tt-color-red-dec-3); + --tt-badge-text-color-subdued: var( + --tt-color-red-dec-2 + ); //less important badge + --tt-badge-text-color-emphasized: var( + --tt-color-red-inc-5 + ); //more important badge + --tt-badge-bg-color: var(--tt-color-red-inc-4); + --tt-badge-bg-color-subdued: var( + --tt-color-red-inc-5 + ); //less important badge + --tt-badge-bg-color-emphasized: var( + --tt-color-red-dec-1 + ); //more important badge + --tt-badge-icon-color: var(--tt-color-red-dec-3); + --tt-badge-icon-color-subdued: var( + --tt-color-red-dec-2 + ); //less important badge + --tt-badge-icon-color-emphasized: var( + --tt-color-red-inc-5 + ); //more important badge + + /* Dark mode */ + .dark & { + --tt-badge-border-color: var(--tt-color-red-dec-2); + --tt-badge-border-color-subdued: var(--tt-color-red-dec-3); + --tt-badge-border-color-emphasized: var(--tt-color-red-base); + --tt-badge-text-color: var(--tt-color-red-inc-3); + --tt-badge-text-color-subdued: var( + --tt-color-red-inc-2 + ); //less important badge + --tt-badge-text-color-emphasized: var( + --tt-color-red-dec-5 + ); //more important badge + --tt-badge-bg-color: var(--tt-color-red-dec-4); + --tt-badge-bg-color-subdued: var( + --tt-color-red-dec-5 + ); //less important badge + --tt-badge-bg-color-emphasized: var( + --tt-color-red-inc-1 + ); //more important badge + --tt-badge-icon-color: var(--tt-color-red-inc-3); + --tt-badge-icon-color-subdued: var( + --tt-color-red-inc-2 + ); //less important badge + --tt-badge-icon-color-emphasized: var( + --tt-color-red-dec-5 + ); //more important badge + } + } + + /************************************************** + Brand + **************************************************/ + + &[data-style="brand"] { + /* Light mode */ + --tt-badge-border-color: var(--tt-brand-color-300); + --tt-badge-border-color-subdued: var(--tt-brand-color-200); + --tt-badge-border-color-emphasized: var(--tt-brand-color-600); + --tt-badge-text-color: var(--tt-brand-color-800); + --tt-badge-text-color-subdued: var( + --tt-brand-color-700 + ); //less important badge + --tt-badge-text-color-emphasized: var( + --tt-brand-color-50 + ); //more important badge + --tt-badge-bg-color: var(--tt-brand-color-100); + --tt-badge-bg-color-subdued: var( + --tt-brand-color-50 + ); //less important badge + --tt-badge-bg-color-emphasized: var( + --tt-brand-color-600 + ); //more important badge + --tt-badge-icon-color: var(--tt-brand-color-800); + --tt-badge-icon-color-subdued: var( + --tt-brand-color-700 + ); //less important badge + --tt-badge-icon-color-emphasized: var( + --tt-brand-color-100 + ); //more important badge + + /* Dark mode */ + .dark & { + --tt-badge-border-color: var(--tt-brand-color-700); + --tt-badge-border-color-subdued: var(--tt-brand-color-800); + --tt-badge-border-color-emphasized: var(--tt-brand-color-400); + --tt-badge-text-color: var(--tt-brand-color-200); + --tt-badge-text-color-subdued: var( + --tt-brand-color-300 + ); //less important badge + --tt-badge-text-color-emphasized: var( + --tt-brand-color-950 + ); //more important badge + --tt-badge-bg-color: var(--tt-brand-color-900); + --tt-badge-bg-color-subdued: var( + --tt-brand-color-950 + ); //less important badge + --tt-badge-bg-color-emphasized: var( + --tt-brand-color-400 + ); //more important badge + --tt-badge-icon-color: var(--tt-brand-color-200); + --tt-badge-icon-color-subdued: var( + --tt-brand-color-300 + ); //less important badge + --tt-badge-icon-color-emphasized: var( + --tt-brand-color-900 + ); //more important badge + } + } +} diff --git a/resources/js/components/tiptap-ui-primitive/badge/badge-group.scss b/resources/js/components/tiptap-ui-primitive/badge/badge-group.scss new file mode 100644 index 0000000..91bd45b --- /dev/null +++ b/resources/js/components/tiptap-ui-primitive/badge/badge-group.scss @@ -0,0 +1,16 @@ +.tiptap-badge-group { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 0.25rem; +} + +.tiptap-badge-group { + [data-orientation="vertical"] { + flex-direction: column; + } + + [data-orientation="horizontal"] { + flex-direction: row; + } +} diff --git a/resources/js/components/tiptap-ui-primitive/badge/badge.scss b/resources/js/components/tiptap-ui-primitive/badge/badge.scss new file mode 100644 index 0000000..b2ca9a8 --- /dev/null +++ b/resources/js/components/tiptap-ui-primitive/badge/badge.scss @@ -0,0 +1,99 @@ +.tiptap-badge { + font-size: 0.625rem; + font-weight: 700; + font-feature-settings: + "salt" on, + "cv01" on; + line-height: 1.15; + height: 1.25rem; + min-width: 1.25rem; + padding: 0.25rem; + display: flex; + align-items: center; + justify-content: center; + border: solid 1px; + border-radius: var(--tt-radius-sm, 0.375rem); + transition-property: background, color, opacity; + transition-duration: var(--tt-transition-duration-default); + transition-timing-function: var(--tt-transition-easing-default); + + /* button size large */ + &[data-size="large"] { + font-size: 0.75rem; + height: 1.5rem; + min-width: 1.5rem; + padding: 0.375rem; + border-radius: var(--tt-radius-md, 0.375rem); + } + + /* button size small */ + &[data-size="small"] { + height: 1rem; + min-width: 1rem; + padding: 0.125rem; + border-radius: var(--tt-radius-xs, 0.25rem); + } + + /* trim / expand text of the button */ + .tiptap-badge-text { + padding: 0 0.125rem; + flex-grow: 1; + text-align: left; + } + + &[data-text-trim="on"] { + .tiptap-badge-text { + text-overflow: ellipsis; + overflow: hidden; + } + } + + /* standard icon, what is used */ + .tiptap-badge-icon { + pointer-events: none; + flex-shrink: 0; + width: 0.625rem; + height: 0.625rem; + } + + &[data-size="large"] .tiptap-badge-icon { + width: 0.75rem; + height: 0.75rem; + } +} + +/* -------------------------------------------- +----------- BADGE COLOR SETTINGS ------------- +-------------------------------------------- */ + +.tiptap-badge { + background-color: var(--tt-badge-bg-color); + border-color: var(--tt-badge-border-color); + color: var(--tt-badge-text-color); + + .tiptap-badge-icon { + color: var(--tt-badge-icon-color); + } + + /* Emphasized */ + &[data-appearance="emphasized"] { + background-color: var(--tt-badge-bg-color-emphasized); + border-color: var(--tt-badge-border-color-emphasized); + color: var(--tt-badge-text-color-emphasized); + + .tiptap-badge-icon { + color: var(--tt-badge-icon-color-emphasized); + } + } + + /* Subdued */ + &[data-appearance="subdued"] { + background-color: var(--tt-badge-bg-color-subdued); + border-color: var(--tt-badge-border-color-subdued); + color: var(--tt-badge-text-color-subdued); + + .tiptap-badge-icon { + color: var(--tt-badge-icon-color-subdued); + } + } +} diff --git a/resources/js/components/tiptap-ui-primitive/badge/badge.tsx b/resources/js/components/tiptap-ui-primitive/badge/badge.tsx new file mode 100644 index 0000000..ecb247e --- /dev/null +++ b/resources/js/components/tiptap-ui-primitive/badge/badge.tsx @@ -0,0 +1,44 @@ +import { forwardRef } from "react" +import "@/components/tiptap-ui-primitive/badge/badge-colors.scss" +import "@/components/tiptap-ui-primitive/badge/badge-group.scss" +import "@/components/tiptap-ui-primitive/badge/badge.scss" + +export interface BadgeProps extends React.HTMLAttributes { + variant?: "ghost" | "white" | "gray" | "green" | "yellow" | "default" + size?: "default" | "small" + appearance?: "default" | "subdued" | "emphasized" + trimText?: boolean +} + +export const Badge = forwardRef( + ( + { + variant, + size = "default", + appearance = "default", + trimText = false, + className, + children, + ...props + }, + ref + ) => { + return ( +
+ {children} +
+ ) + } +) + +Badge.displayName = "Badge" + +export default Badge diff --git a/resources/js/components/tiptap-ui-primitive/badge/index.tsx b/resources/js/components/tiptap-ui-primitive/badge/index.tsx new file mode 100644 index 0000000..051fa6e --- /dev/null +++ b/resources/js/components/tiptap-ui-primitive/badge/index.tsx @@ -0,0 +1 @@ +export * from "./badge" diff --git a/resources/js/components/tiptap-ui-primitive/button-group/button-group.scss b/resources/js/components/tiptap-ui-primitive/button-group/button-group.scss new file mode 100644 index 0000000..00a6d12 --- /dev/null +++ b/resources/js/components/tiptap-ui-primitive/button-group/button-group.scss @@ -0,0 +1,79 @@ +.tiptap-button-group { + display: flex; + width: fit-content; + align-items: stretch; + + /* nested tiptap-button-group gets a gap */ + &:has(> [data-slot="tiptap-button-group"]) { + gap: 0.125rem; + } + + /* focus-visible children stack correctly */ + & > *:focus-visible { + position: relative; + z-index: 10; + } + + /* select trigger without explicit width becomes fit-content */ + & > [data-slot="select-trigger"]:not([class*="w-"]) { + width: fit-content; + } + + /* input children fill remaining space */ + & > input { + flex: 1; + } +} + +/* ─── Horizontal ────────────────────────────────────────── */ +.tiptap-button-group-horizontal { + /* all [data-slot] children: remove right radius */ + & > [data-slot] { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + + /* last [data-slot] child: restore right radius */ + & > [data-slot]:not(:has(~ [data-slot])) { + border-top-right-radius: var(--tt-radius-lg) !important; + border-bottom-right-radius: var(--tt-radius-lg) !important; + } + + /* non-first [data-slot] children: remove left radius + left border */ + & > [data-slot] ~ [data-slot] { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + border-left-width: 0; + } + + /* select trigger last of type when followed by hidden select */ + &:has(select[aria-hidden="true"]:last-child) + > [data-slot="select-trigger"]:last-of-type { + border-top-right-radius: var(--tt-radius-lg); + border-bottom-right-radius: var(--tt-radius-lg); + } +} + +/* ─── Vertical ──────────────────────────────────────────── */ +.tiptap-button-group-vertical { + flex-direction: column; + + /* all [data-slot] children: remove bottom radius */ + & > [data-slot] { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } + + /* last [data-slot] child: restore bottom radius */ + & > [data-slot]:not(:has(~ [data-slot])) { + border-bottom-left-radius: var(--tt-radius-lg) !important; + border-bottom-right-radius: var(--tt-radius-lg) !important; + } + + /* non-first [data-slot] children: remove top radius + top border */ + & > [data-slot] ~ [data-slot] { + border-top-left-radius: 0; + border-top-right-radius: 0; + border-top-width: 0; + } +} diff --git a/resources/js/components/tiptap-ui-primitive/button-group/button-group.tsx b/resources/js/components/tiptap-ui-primitive/button-group/button-group.tsx new file mode 100644 index 0000000..ebb013e --- /dev/null +++ b/resources/js/components/tiptap-ui-primitive/button-group/button-group.tsx @@ -0,0 +1,72 @@ +import { mergeProps } from "@base-ui/react/merge-props" +import { useRender } from "@base-ui/react/use-render" +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "@/lib/tiptap-utils" +import { Separator } from "@/components/tiptap-ui-primitive/separator" +import "./button-group.scss" + +const buttonGroupVariants = cva("tiptap-button-group", { + variants: { + orientation: { + horizontal: "tiptap-button-group-horizontal", + vertical: "tiptap-button-group-vertical", + }, + }, + defaultVariants: { + orientation: "horizontal", + }, +}) + +function ButtonGroup({ + className, + orientation, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function ButtonGroupText({ + className, + render, + ...props +}: useRender.ComponentProps<"div">) { + return useRender({ + defaultTagName: "div", + props: mergeProps<"div">( + { className: cn("tiptap-button-group-text", className) }, + props + ), + render, + state: { slot: "tiptap-button-group-text" }, + }) +} + +function ButtonGroupSeparator({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + ButtonGroup, + ButtonGroupSeparator, + ButtonGroupText, + buttonGroupVariants, +} diff --git a/resources/js/components/tiptap-ui-primitive/button-group/index.tsx b/resources/js/components/tiptap-ui-primitive/button-group/index.tsx new file mode 100644 index 0000000..eaf4512 --- /dev/null +++ b/resources/js/components/tiptap-ui-primitive/button-group/index.tsx @@ -0,0 +1 @@ +export * from "./button-group" diff --git a/resources/js/components/tiptap-ui-primitive/button/button-colors.scss b/resources/js/components/tiptap-ui-primitive/button/button-colors.scss new file mode 100644 index 0000000..fc0dd35 --- /dev/null +++ b/resources/js/components/tiptap-ui-primitive/button/button-colors.scss @@ -0,0 +1,429 @@ +.tiptap-button { + /************************************************** + Default button background color + **************************************************/ + + /* Light mode */ + --tt-button-default-bg-color: var(--tt-gray-light-a-100); + --tt-button-hover-bg-color: var(--tt-gray-light-200); + --tt-button-active-bg-color: var(--tt-gray-light-a-200); + --tt-button-active-bg-color-emphasized: var( + --tt-brand-color-100 + ); //more important active state + --tt-button-active-bg-color-subdued: var( + --tt-gray-light-a-200 + ); //less important active state + --tt-button-active-hover-bg-color: var(--tt-gray-light-300); + --tt-button-active-hover-bg-color-emphasized: var( + --tt-brand-color-200 + ); //more important active state hover + --tt-button-active-hover-bg-color-subdued: var( + --tt-gray-light-a-300 + ); //less important active state hover + --tt-button-disabled-bg-color: var(--tt-gray-light-a-50); + + /* Dark mode */ + .dark & { + --tt-button-default-bg-color: var(--tt-gray-dark-a-100); + --tt-button-hover-bg-color: var(--tt-gray-dark-200); + --tt-button-active-bg-color: var(--tt-gray-dark-a-200); + --tt-button-active-bg-color-emphasized: var( + --tt-brand-color-900 + ); //more important active state + --tt-button-active-bg-color-subdued: var( + --tt-gray-dark-a-200 + ); //less important active state + --tt-button-active-hover-bg-color: var(--tt-gray-dark-300); + --tt-button-active-hover-bg-color-emphasized: var( + --tt-brand-color-800 + ); //more important active state hover + --tt-button-active-hover-bg-color-subdued: var( + --tt-gray-dark-a-300 + ); //less important active state hover + --tt-button-disabled-bg-color: var(--tt-gray-dark-a-50); + } + + /************************************************** + Default button text color + **************************************************/ + + /* Light mode */ + --tt-button-default-text-color: var(--tt-gray-light-a-600); + --tt-button-hover-text-color: var(--tt-gray-light-a-900); + --tt-button-active-text-color: var(--tt-gray-light-a-900); + --tt-button-active-text-color-emphasized: var(--tt-gray-light-a-900); + --tt-button-active-text-color-subdued: var(--tt-gray-light-a-900); + --tt-button-disabled-text-color: var(--tt-gray-light-a-400); + + /* Dark mode */ + .dark & { + --tt-button-default-text-color: var(--tt-gray-dark-a-600); + --tt-button-hover-text-color: var(--tt-gray-dark-a-900); + --tt-button-active-text-color: var(--tt-gray-dark-a-900); + --tt-button-active-text-color-emphasized: var(--tt-gray-dark-a-900); + --tt-button-active-text-color-subdued: var(--tt-gray-dark-a-900); + --tt-button-disabled-text-color: var(--tt-gray-dark-a-300); + } + + /************************************************** + Default button icon color + **************************************************/ + + /* Light mode */ + --tt-button-default-icon-color: var(--tt-gray-light-a-600); + --tt-button-hover-icon-color: var(--tt-gray-light-a-900); + --tt-button-active-icon-color: var(--tt-brand-color-500); + --tt-button-active-icon-color-emphasized: var(--tt-brand-color-600); + --tt-button-active-icon-color-subdued: var(--tt-gray-light-a-900); + --tt-button-disabled-icon-color: var(--tt-gray-light-a-400); + + /* Dark mode */ + .dark & { + --tt-button-default-icon-color: var(--tt-gray-dark-a-600); + --tt-button-hover-icon-color: var(--tt-gray-dark-a-900); + --tt-button-active-icon-color: var(--tt-brand-color-400); + --tt-button-active-icon-color-emphasized: var(--tt-brand-color-400); + --tt-button-active-icon-color-subdued: var(--tt-gray-dark-a-900); + --tt-button-disabled-icon-color: var(--tt-gray-dark-a-400); + } + + /************************************************** + Default button subicon color + **************************************************/ + + /* Light mode */ + --tt-button-default-icon-sub-color: var(--tt-gray-light-a-400); + --tt-button-hover-icon-sub-color: var(--tt-gray-light-a-500); + --tt-button-active-icon-sub-color: var(--tt-gray-light-a-400); + --tt-button-active-icon-sub-color-emphasized: var(--tt-gray-light-a-500); + --tt-button-active-icon-sub-color-subdued: var(--tt-gray-light-a-400); + --tt-button-disabled-icon-sub-color: var(--tt-gray-light-a-100); + + /* Dark mode */ + .dark & { + --tt-button-default-icon-sub-color: var(--tt-gray-dark-a-300); + --tt-button-hover-icon-sub-color: var(--tt-gray-dark-a-400); + --tt-button-active-icon-sub-color: var(--tt-gray-dark-a-300); + --tt-button-active-icon-sub-color-emphasized: var(--tt-gray-dark-a-400); + --tt-button-active-icon-sub-color-subdued: var(--tt-gray-dark-a-300); + --tt-button-disabled-icon-sub-color: var(--tt-gray-dark-a-100); + } + + /************************************************** + Default button dropdown / arrows color + **************************************************/ + + /* Light mode */ + --tt-button-default-dropdown-arrows-color: var(--tt-gray-light-a-600); + --tt-button-hover-dropdown-arrows-color: var(--tt-gray-light-a-700); + --tt-button-active-dropdown-arrows-color: var(--tt-gray-light-a-600); + --tt-button-active-dropdown-arrows-color-emphasized: var( + --tt-gray-light-a-700 + ); + --tt-button-active-dropdown-arrows-color-subdued: var(--tt-gray-light-a-600); + --tt-button-disabled-dropdown-arrows-color: var(--tt-gray-light-a-400); + + /* Dark mode */ + .dark & { + --tt-button-default-dropdown-arrows-color: var(--tt-gray-dark-a-600); + --tt-button-hover-dropdown-arrows-color: var(--tt-gray-dark-a-700); + --tt-button-active-dropdown-arrows-color: var(--tt-gray-dark-a-600); + --tt-button-active-dropdown-arrows-color-emphasized: var( + --tt-gray-dark-a-700 + ); + --tt-button-active-dropdown-arrows-color-subdued: var(--tt-gray-dark-a-600); + --tt-button-disabled-dropdown-arrows-color: var(--tt-gray-dark-a-400); + } + + /* ---------------------------------------------------------------- + --------------------------- GHOST BUTTON -------------------------- + ---------------------------------------------------------------- */ + + &[data-style="ghost"] { + /************************************************** + Ghost button background color + **************************************************/ + + /* Light mode */ + --tt-button-default-bg-color: var(--transparent); + --tt-button-hover-bg-color: var(--tt-gray-light-200); + --tt-button-active-bg-color: var(--tt-gray-light-a-100); + --tt-button-active-bg-color-emphasized: var( + --tt-brand-color-100 + ); //more important active state + --tt-button-active-bg-color-subdued: var( + --tt-gray-light-a-100 + ); //less important active state + --tt-button-active-hover-bg-color: var(--tt-gray-light-200); + --tt-button-active-hover-bg-color-emphasized: var( + --tt-brand-color-200 + ); //more important active state hover + --tt-button-active-hover-bg-color-subdued: var( + --tt-gray-light-a-200 + ); //less important active state hover + --tt-button-disabled-bg-color: var(--transparent); + + /* Dark mode */ + .dark & { + --tt-button-default-bg-color: var(--transparent); + --tt-button-hover-bg-color: var(--tt-gray-dark-200); + --tt-button-active-bg-color: var(--tt-gray-dark-a-100); + --tt-button-active-bg-color-emphasized: var( + --tt-brand-color-900 + ); //more important active state + --tt-button-active-bg-color-subdued: var( + --tt-gray-dark-a-100 + ); //less important active state + --tt-button-active-hover-bg-color: var(--tt-gray-dark-200); + --tt-button-active-hover-bg-color-emphasized: var( + --tt-brand-color-800 + ); //more important active state hover + --tt-button-active-hover-bg-color-subdued: var( + --tt-gray-dark-a-200 + ); //less important active state hover + --tt-button-disabled-bg-color: var(--transparent); + } + + /************************************************** + Ghost button text color + **************************************************/ + + /* Light mode */ + --tt-button-default-text-color: var(--tt-gray-light-a-600); + --tt-button-hover-text-color: var(--tt-gray-light-a-900); + --tt-button-active-text-color: var(--tt-gray-light-a-900); + --tt-button-active-text-color-emphasized: var(--tt-gray-light-a-900); + --tt-button-active-text-color-subdued: var(--tt-gray-light-a-900); + --tt-button-disabled-text-color: var(--tt-gray-light-a-400); + + /* Dark mode */ + .dark & { + --tt-button-default-text-color: var(--tt-gray-dark-a-600); + --tt-button-hover-text-color: var(--tt-gray-dark-a-900); + --tt-button-active-text-color: var(--tt-gray-dark-a-900); + --tt-button-active-text-color-emphasized: var(--tt-gray-dark-a-900); + --tt-button-active-text-color-subdued: var(--tt-gray-dark-a-900); + --tt-button-disabled-text-color: var(--tt-gray-dark-a-300); + } + + /************************************************** + Ghost button icon color + **************************************************/ + + /* Light mode */ + --tt-button-default-icon-color: var(--tt-gray-light-a-600); + --tt-button-hover-icon-color: var(--tt-gray-light-a-900); + --tt-button-active-icon-color: var(--tt-brand-color-500); + --tt-button-active-icon-color-emphasized: var(--tt-brand-color-600); + --tt-button-active-icon-color-subdued: var(--tt-gray-light-a-900); + --tt-button-disabled-icon-color: var(--tt-gray-light-a-400); + + /* Dark mode */ + .dark & { + --tt-button-default-icon-color: var(--tt-gray-dark-a-600); + --tt-button-hover-icon-color: var(--tt-gray-dark-a-900); + --tt-button-active-icon-color: var(--tt-brand-color-400); + --tt-button-active-icon-color-emphasized: var(--tt-brand-color-300); + --tt-button-active-icon-color-subdued: var(--tt-gray-dark-a-900); + --tt-button-disabled-icon-color: var(--tt-gray-dark-a-400); + } + + /************************************************** + Ghost button subicon color + **************************************************/ + + /* Light mode */ + --tt-button-default-icon-sub-color: var(--tt-gray-light-a-400); + --tt-button-hover-icon-sub-color: var(--tt-gray-light-a-500); + --tt-button-active-icon-sub-color: var(--tt-gray-light-a-400); + --tt-button-active-icon-sub-color-emphasized: var(--tt-gray-light-a-500); + --tt-button-active-icon-sub-color-subdued: var(--tt-gray-light-a-400); + --tt-button-disabled-icon-sub-color: var(--tt-gray-light-a-100); + + /* Dark mode */ + .dark & { + --tt-button-default-icon-sub-color: var(--tt-gray-dark-a-300); + --tt-button-hover-icon-sub-color: var(--tt-gray-dark-a-400); + --tt-button-active-icon-sub-color: var(--tt-gray-dark-a-300); + --tt-button-active-icon-sub-color-emphasized: var(--tt-gray-dark-a-400); + --tt-button-active-icon-sub-color-subdued: var(--tt-gray-dark-a-300); + --tt-button-disabled-icon-sub-color: var(--tt-gray-dark-a-100); + } + + /************************************************** + Ghost button dropdown / arrows color + **************************************************/ + + /* Light mode */ + --tt-button-default-dropdown-arrows-color: var(--tt-gray-light-a-600); + --tt-button-hover-dropdown-arrows-color: var(--tt-gray-light-a-700); + --tt-button-active-dropdown-arrows-color: var(--tt-gray-light-a-600); + --tt-button-active-dropdown-arrows-color-emphasized: var( + --tt-gray-light-a-700 + ); + --tt-button-active-dropdown-arrows-color-subdued: var( + --tt-gray-light-a-600 + ); + --tt-button-disabled-dropdown-arrows-color: var(--tt-gray-light-a-400); + + /* Dark mode */ + .dark & { + --tt-button-default-dropdown-arrows-color: var(--tt-gray-dark-a-600); + --tt-button-hover-dropdown-arrows-color: var(--tt-gray-dark-a-700); + --tt-button-active-dropdown-arrows-color: var(--tt-gray-dark-a-600); + --tt-button-active-dropdown-arrows-color-emphasized: var( + --tt-gray-dark-a-700 + ); + --tt-button-active-dropdown-arrows-color-subdued: var( + --tt-gray-dark-a-600 + ); + --tt-button-disabled-dropdown-arrows-color: var(--tt-gray-dark-a-400); + } + } + + /* ---------------------------------------------------------------- + -------------------------- PRIMARY BUTTON ------------------------- + ---------------------------------------------------------------- */ + + &[data-style="primary"] { + /************************************************** + Primary button background color + **************************************************/ + + /* Light mode */ + --tt-button-default-bg-color: var(--tt-brand-color-500); + --tt-button-hover-bg-color: var(--tt-brand-color-600); + --tt-button-active-bg-color: var(--tt-brand-color-100); + --tt-button-active-bg-color-emphasized: var( + --tt-brand-color-100 + ); //more important active state + --tt-button-active-bg-color-subdued: var( + --tt-brand-color-100 + ); //less important active state + --tt-button-active-hover-bg-color: var(--tt-brand-color-200); + --tt-button-active-hover-bg-color-emphasized: var( + --tt-brand-color-200 + ); //more important active state hover + --tt-button-active-hover-bg-color-subdued: var( + --tt-brand-color-200 + ); //less important active state hover + --tt-button-disabled-bg-color: var(--tt-gray-light-a-100); + + /* Dark mode */ + .dark & { + --tt-button-default-bg-color: var(--tt-brand-color-500); + --tt-button-hover-bg-color: var(--tt-brand-color-600); + --tt-button-active-bg-color: var(--tt-brand-color-900); + --tt-button-active-bg-color-emphasized: var( + --tt-brand-color-900 + ); //more important active state + --tt-button-active-bg-color-subdued: var( + --tt-brand-color-900 + ); //less important active state + --tt-button-active-hover-bg-color: var(--tt-brand-color-800); + --tt-button-active-hover-bg-color-emphasized: var( + --tt-brand-color-800 + ); //more important active state hover + --tt-button-active-hover-bg-color-subdued: var( + --tt-brand-color-800 + ); //less important active state hover + --tt-button-disabled-bg-color: var(--tt-gray-dark-a-100); + } + + /************************************************** + Primary button text color + **************************************************/ + + /* Light mode */ + --tt-button-default-text-color: var(--white); + --tt-button-hover-text-color: var(--white); + --tt-button-active-text-color: var(--tt-gray-light-a-900); + --tt-button-active-text-color-emphasized: var(--tt-gray-light-a-900); + --tt-button-active-text-color-subdued: var(--tt-gray-light-a-900); + --tt-button-disabled-text-color: var(--tt-gray-light-a-400); + + /* Dark mode */ + .dark & { + --tt-button-default-text-color: var(--white); + --tt-button-hover-text-color: var(--white); + --tt-button-active-text-color: var(--tt-gray-dark-a-900); + --tt-button-active-text-color-emphasized: var(--tt-gray-dark-a-900); + --tt-button-active-text-color-subdued: var(--tt-gray-dark-a-900); + --tt-button-disabled-text-color: var(--tt-gray-dark-a-300); + } + + /************************************************** + Primary button icon color + **************************************************/ + + /* Light mode */ + --tt-button-default-icon-color: var(--white); + --tt-button-hover-icon-color: var(--white); + --tt-button-active-icon-color: var(--tt-brand-color-600); + --tt-button-active-icon-color-emphasized: var(--tt-brand-color-600); + --tt-button-active-icon-color-subdued: var(--tt-brand-color-600); + --tt-button-disabled-icon-color: var(--tt-gray-light-a-400); + + /* Dark mode */ + .dark & { + --tt-button-default-icon-color: var(--white); + --tt-button-hover-icon-color: var(--white); + --tt-button-active-icon-color: var(--tt-brand-color-400); + --tt-button-active-icon-color-emphasized: var(--tt-brand-color-400); + --tt-button-active-icon-color-subdued: var(--tt-brand-color-400); + --tt-button-disabled-icon-color: var(--tt-gray-dark-a-300); + } + + /************************************************** + Primary button subicon color + **************************************************/ + + /* Light mode */ + --tt-button-default-icon-sub-color: var(--tt-gray-dark-a-500); + --tt-button-hover-icon-sub-color: var(--tt-gray-dark-a-500); + --tt-button-active-icon-sub-color: var(--tt-gray-light-a-500); + --tt-button-active-icon-sub-color-emphasized: var(--tt-gray-light-a-500); + --tt-button-active-icon-sub-color-subdued: var(--tt-gray-light-a-500); + --tt-button-disabled-icon-sub-color: var(--tt-gray-light-a-100); + + /* Dark mode */ + .dark & { + --tt-button-default-icon-sub-color: var(--tt-gray-dark-a-400); + --tt-button-hover-icon-sub-color: var(--tt-gray-dark-a-500); + --tt-button-active-icon-sub-color: var(--tt-gray-dark-a-300); + --tt-button-active-icon-sub-color-emphasized: var(--tt-gray-dark-a-400); + --tt-button-active-icon-sub-color-subdued: var(--tt-gray-dark-a-300); + --tt-button-disabled-icon-sub-color: var(--tt-gray-dark-a-100); + } + + /************************************************** + Primary button dropdown / arrows color + **************************************************/ + + /* Light mode */ + --tt-button-default-dropdown-arrows-color: var(--white); + --tt-button-hover-dropdown-arrows-color: var(--white); + --tt-button-active-dropdown-arrows-color: var(--tt-gray-light-a-700); + --tt-button-active-dropdown-arrows-color-emphasized: var( + --tt-gray-light-a-700 + ); + --tt-button-active-dropdown-arrows-color-subdued: var( + --tt-gray-light-a-700 + ); + --tt-button-disabled-dropdown-arrows-color: var(--tt-gray-light-a-400); + + /* Dark mode */ + .dark & { + --tt-button-default-dropdown-arrows-color: var(--white); + --tt-button-hover-dropdown-arrows-color: var(--white); + --tt-button-active-dropdown-arrows-color: var(--tt-gray-dark-a-600); + --tt-button-active-dropdown-arrows-color-emphasized: var( + --tt-gray-dark-a-600 + ); + --tt-button-active-dropdown-arrows-color-subdued: var( + --tt-gray-dark-a-600 + ); + --tt-button-disabled-dropdown-arrows-color: var(--tt-gray-dark-a-400); + } + } +} diff --git a/resources/js/components/tiptap-ui-primitive/button/button.scss b/resources/js/components/tiptap-ui-primitive/button/button.scss new file mode 100644 index 0000000..32d1499 --- /dev/null +++ b/resources/js/components/tiptap-ui-primitive/button/button.scss @@ -0,0 +1,314 @@ +.tiptap-button { + font-size: 0.875rem; + font-weight: 500; + font-feature-settings: + "salt" on, + "cv01" on; + line-height: 1.15; + height: 2rem; + min-width: 2rem; + border: none; + padding: 0.5rem; + gap: 0.25rem; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--tt-radius-lg, 0.75rem); + transition-property: background, color, opacity; + transition-duration: var(--tt-transition-duration-default); + transition-timing-function: var(--tt-transition-easing-default); + + // focus-visible + &:focus-visible { + outline: none; + } + + &[data-highlighted="true"], + &[data-focus-visible="true"] { + background-color: var(--tt-button-hover-bg-color); + color: var(--tt-button-hover-text-color); + // outline: 2px solid var(--tt-button-active-icon-color); + } + + &[data-weight="small"] { + width: 1.5rem; + min-width: 1.5rem; + padding-right: 0; + padding-left: 0; + } + + /* button size large */ + &[data-size="large"] { + font-size: 0.9375rem; + height: 2.375rem; + min-width: 2.375rem; + padding: 0.625rem; + } + + /* button size small */ + &[data-size="small"] { + font-size: 0.75rem; + line-height: 1.2; + height: 1.5rem; + min-width: 1.5rem; + padding: 0.3125rem; + border-radius: var(--tt-radius-md, 0.5rem); + } + + /* trim / expand text of the button */ + .tiptap-button-text { + padding: 0 0.125rem; + flex-grow: 1; + text-align: left; + line-height: 1.5rem; + } + + &[data-text-trim="on"] { + .tiptap-button-text { + text-overflow: ellipsis; + overflow: hidden; + } + } + + /* global icon settings */ + .tiptap-button-icon, + .tiptap-button-icon-sub, + .tiptap-button-dropdown-arrows, + .tiptap-button-dropdown-small { + flex-shrink: 0; + } + + /* standard icon, what is used */ + .tiptap-button-icon { + width: 1rem; + height: 1rem; + } + + &[data-size="large"] .tiptap-button-icon { + width: 1.125rem; + height: 1.125rem; + } + + &[data-size="small"] .tiptap-button-icon { + width: 0.875rem; + height: 0.875rem; + } + + /* if 2 icons are used and this icon should be more subtle */ + .tiptap-button-icon-sub { + width: 1rem; + height: 1rem; + } + + &[data-size="large"] .tiptap-button-icon-sub { + width: 1.125rem; + height: 1.125rem; + } + + &[data-size="small"] .tiptap-button-icon-sub { + width: 0.875rem; + height: 0.875rem; + } + + /* dropdown menus or arrows that are slightly smaller */ + .tiptap-button-dropdown-arrows { + width: 0.75rem; + height: 0.75rem; + } + + &[data-size="large"] .tiptap-button-dropdown-arrows { + width: 0.875rem; + height: 0.875rem; + } + + &[data-size="small"] .tiptap-button-dropdown-arrows { + width: 0.625rem; + height: 0.625rem; + } + + /* dropdown menu for icon buttons only */ + .tiptap-button-dropdown-small { + width: 0.625rem; + height: 0.625rem; + } + + &[data-size="large"] .tiptap-button-dropdown-small { + width: 0.75rem; + height: 0.75rem; + } + + &[data-size="small"] .tiptap-button-dropdown-small { + width: 0.5rem; + height: 0.5rem; + } + + /* button only has icons */ + &:has(> svg):not(:has(> :not(svg))) { + gap: 0.125rem; + + &[data-size="large"], + &[data-size="small"] { + gap: 0.125rem; + } + } + + /* button only has 2 icons and one of them is dropdown small */ + &:has(> svg:nth-of-type(2)):has(> .tiptap-button-dropdown-small):not( + :has(> svg:nth-of-type(3)) + ):not(:has(> .tiptap-button-text)) { + gap: 0; + padding-right: 0.25rem; + + &[data-size="large"] { + padding-right: 0.375rem; + } + + &[data-size="small"] { + padding-right: 0.25rem; + } + } + + /* Emoji is used in a button */ + .tiptap-button-emoji { + width: 1rem; + display: flex; + justify-content: center; + } + + &[data-size="large"] .tiptap-button-emoji { + width: 1.125rem; + } + + &[data-size="small"] .tiptap-button-emoji { + width: 0.875rem; + } +} + +/* -------------------------------------------- +----------- BUTTON COLOR SETTINGS ------------- +-------------------------------------------- */ + +.tiptap-button { + background-color: var(--tt-button-default-bg-color); + color: var(--tt-button-default-text-color); + + .tiptap-button-icon { + color: var(--tt-button-default-icon-color); + } + + .tiptap-button-icon-sub { + color: var(--tt-button-default-icon-sub-color); + } + + .tiptap-button-dropdown-arrows { + color: var(--tt-button-default-dropdown-arrows-color); + } + + .tiptap-button-dropdown-small { + color: var(--tt-button-default-dropdown-arrows-color); + } + + /* hover state of a button */ + &:hover:not([data-active-item="true"]):not([disabled]), + &[data-active-item="true"]:not([disabled]), + &[data-highlighted]:not([disabled]):not([data-highlighted="false"]) { + background-color: var(--tt-button-hover-bg-color); + color: var(--tt-button-hover-text-color); + + .tiptap-button-icon { + color: var(--tt-button-hover-icon-color); + } + + .tiptap-button-icon-sub { + color: var(--tt-button-hover-icon-sub-color); + } + + .tiptap-button-dropdown-arrows, + .tiptap-button-dropdown-small { + color: var(--tt-button-hover-dropdown-arrows-color); + } + } + + /* Active state of a button */ + &[data-active-state="on"]:not([disabled]), + &[data-state="open"]:not([disabled]) { + background-color: var(--tt-button-active-bg-color); + color: var(--tt-button-active-text-color); + + .tiptap-button-icon { + color: var(--tt-button-active-icon-color); + } + + .tiptap-button-icon-sub { + color: var(--tt-button-active-icon-sub-color); + } + + .tiptap-button-dropdown-arrows, + .tiptap-button-dropdown-small { + color: var(--tt-button-active-dropdown-arrows-color); + } + + &:hover { + background-color: var(--tt-button-active-hover-bg-color); + } + + /* Emphasized */ + &[data-appearance="emphasized"] { + background-color: var(--tt-button-active-bg-color-emphasized); + color: var(--tt-button-active-text-color-emphasized); + + .tiptap-button-icon { + color: var(--tt-button-active-icon-color-emphasized); + } + + .tiptap-button-icon-sub { + color: var(--tt-button-active-icon-sub-color-emphasized); + } + + .tiptap-button-dropdown-arrows, + .tiptap-button-dropdown-small { + color: var(--tt-button-active-dropdown-arrows-color-emphasized); + } + + &:hover { + background-color: var(--tt-button-active-hover-bg-color-emphasized); + } + } + + /* Subdued */ + &[data-appearance="subdued"] { + background-color: var(--tt-button-active-bg-color-subdued); + color: var(--tt-button-active-text-color-subdued); + + .tiptap-button-icon { + color: var(--tt-button-active-icon-color-subdued); + } + + .tiptap-button-icon-sub { + color: var(--tt-button-active-icon-sub-color-subdued); + } + + .tiptap-button-dropdown-arrows, + .tiptap-button-dropdown-small { + color: var(--tt-button-active-dropdown-arrows-color-subdued); + } + + &:hover { + background-color: var(--tt-button-active-hover-bg-color-subdued); + + .tiptap-button-icon { + color: var(--tt-button-active-icon-color-subdued); + } + } + } + } + + &:disabled { + background-color: var(--tt-button-disabled-bg-color); + color: var(--tt-button-disabled-text-color); + + .tiptap-button-icon { + color: var(--tt-button-disabled-icon-color); + } + } +} diff --git a/resources/js/components/tiptap-ui-primitive/button/button.tsx b/resources/js/components/tiptap-ui-primitive/button/button.tsx new file mode 100644 index 0000000..02be4fe --- /dev/null +++ b/resources/js/components/tiptap-ui-primitive/button/button.tsx @@ -0,0 +1,101 @@ +import { forwardRef, Fragment, useMemo } from "react" + +// --- Tiptap UI Primitive --- +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/tiptap-ui-primitive/tooltip" + +// --- Lib --- +import { cn, parseShortcutKeys } from "@/lib/tiptap-utils" + +import "@/components/tiptap-ui-primitive/button/button-colors.scss" +import "@/components/tiptap-ui-primitive/button/button.scss" + +export type ButtonVariant = "ghost" | "primary" +export type ButtonSize = "small" | "default" | "large" + +export interface ButtonProps extends React.ButtonHTMLAttributes { + showTooltip?: boolean + tooltip?: React.ReactNode + shortcutKeys?: string + variant?: ButtonVariant + size?: ButtonSize +} + +export const ShortcutDisplay: React.FC<{ shortcuts: string[] }> = ({ + shortcuts, +}) => { + if (shortcuts.length === 0) return null + + return ( +
+ {shortcuts.map((key, index) => ( + + {index > 0 && +} + {key} + + ))} +
+ ) +} + +export const Button = forwardRef( + ( + { + className, + children, + tooltip, + showTooltip = true, + shortcutKeys, + variant, + size, + ...props + }, + ref + ) => { + const shortcuts = useMemo( + () => parseShortcutKeys({ shortcutKeys }), + [shortcutKeys] + ) + + if (!tooltip || !showTooltip) { + return ( + + ) + } + + return ( + + + {children} + + + {tooltip} + + + + ) + } +) + +Button.displayName = "Button" + +export default Button diff --git a/resources/js/components/tiptap-ui-primitive/button/index.tsx b/resources/js/components/tiptap-ui-primitive/button/index.tsx new file mode 100644 index 0000000..e93d26f --- /dev/null +++ b/resources/js/components/tiptap-ui-primitive/button/index.tsx @@ -0,0 +1 @@ +export * from "./button" diff --git a/resources/js/components/tiptap-ui-primitive/card/card.scss b/resources/js/components/tiptap-ui-primitive/card/card.scss new file mode 100644 index 0000000..2cc382b --- /dev/null +++ b/resources/js/components/tiptap-ui-primitive/card/card.scss @@ -0,0 +1,74 @@ +:root { + --tiptap-card-bg-color: var(--white); + --tiptap-card-border-color: var(--tt-gray-light-a-100); + --tiptap-card-group-label-color: var(--tt-gray-light-a-800); +} + +.dark { + --tiptap-card-bg-color: var(--tt-gray-dark-50); + --tiptap-card-border-color: var(--tt-gray-dark-a-100); + --tiptap-card-group-label-color: var(--tt-gray-dark-a-800); +} + +.tiptap-card { + --padding: 0.375rem; + --border-width: 1px; + + border-radius: calc(var(--padding) + var(--tt-radius-lg)); + box-shadow: var(--tt-shadow-elevated-md); + background-color: var(--tiptap-card-bg-color); + border: 1px solid var(--tiptap-card-border-color); + display: flex; + flex-direction: column; + outline: none; + align-items: center; + + position: relative; + min-width: 0; + word-wrap: break-word; + background-clip: border-box; +} + +.tiptap-card-header { + padding: 0.375rem; + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + border-bottom: var(--border-width) solid var(--tiptap-card-border-color); +} + +.tiptap-card-body { + padding: 0.375rem; + flex: 1 1 auto; + overflow-y: auto; +} + +.tiptap-card-item-group { + position: relative; + display: flex; + vertical-align: middle; + min-width: max-content; + + &[data-orientation="vertical"] { + flex-direction: column; + justify-content: center; + } + + &[data-orientation="horizontal"] { + gap: 0.25rem; + flex-direction: row; + align-items: center; + } +} + +.tiptap-card-group-label { + padding: 0.75rem 0.5rem 0.25rem; + line-height: normal; + font-size: 0.75rem; + font-weight: 600; + line-height: normal; + text-transform: capitalize; + color: var(--tiptap-card-group-label-color); +} diff --git a/resources/js/components/tiptap-ui-primitive/card/card.tsx b/resources/js/components/tiptap-ui-primitive/card/card.tsx new file mode 100644 index 0000000..1e391b2 --- /dev/null +++ b/resources/js/components/tiptap-ui-primitive/card/card.tsx @@ -0,0 +1,77 @@ +import { forwardRef } from "react" +import { cn } from "@/lib/tiptap-utils" +import "@/components/tiptap-ui-primitive/card/card.scss" + +const Card = forwardRef>( + ({ className, ...props }, ref) => { + return
+ } +) +Card.displayName = "Card" + +const CardHeader = forwardRef>( + ({ className, ...props }, ref) => { + return ( +
+ ) + } +) +CardHeader.displayName = "CardHeader" + +const CardBody = forwardRef>( + ({ className, ...props }, ref) => { + return ( +
+ ) + } +) +CardBody.displayName = "CardBody" + +const CardItemGroup = forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + orientation?: "horizontal" | "vertical" + } +>(({ className, orientation = "vertical", ...props }, ref) => { + return ( +
+ ) +}) +CardItemGroup.displayName = "CardItemGroup" + +const CardGroupLabel = forwardRef>( + ({ className, ...props }, ref) => { + return ( +
+ ) + } +) +CardGroupLabel.displayName = "CardGroupLabel" + +const CardFooter = forwardRef>( + ({ className, ...props }, ref) => { + return ( +
+ ) + } +) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardBody, CardItemGroup, CardGroupLabel } diff --git a/resources/js/components/tiptap-ui-primitive/card/index.tsx b/resources/js/components/tiptap-ui-primitive/card/index.tsx new file mode 100644 index 0000000..288c75f --- /dev/null +++ b/resources/js/components/tiptap-ui-primitive/card/index.tsx @@ -0,0 +1 @@ +export * from "./card" diff --git a/resources/js/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.scss b/resources/js/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.scss new file mode 100644 index 0000000..970bc45 --- /dev/null +++ b/resources/js/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.scss @@ -0,0 +1,152 @@ +:root { + --tt-dropdown-menu-bg-color: var(--white); + --tt-dropdown-menu-text-color: var(--tt-gray-light-a-600); + --tt-dropdown-menu-label-color: var(--tt-gray-light-a-800); +} + +:root.dark, +.dark { + --tt-dropdown-menu-bg-color: var(--tt-gray-dark-50); + --tt-dropdown-menu-text-color: var(--tt-gray-dark-a-600); + --tt-dropdown-menu-label-color: var(--tt-gray-dark-a-800); +} + +/* ============================================================ + Dropdown Menu + ============================================================ */ + +/* --- Content --- */ + +.tiptap-dropdown-menu-content { + --slide-x: 0px; + --slide-y: 0px; + --padding: 0.375rem; + + position: relative; + z-index: 50; + min-width: 8rem; + max-height: var(--radix-dropdown-menu-content-available-height); + overflow-x: hidden; + overflow-y: auto; + border-radius: calc(var(--padding) + var(--tt-radius-lg)); + padding: 0.375rem; + background-color: var(--tt-dropdown-menu-bg-color); + color: var(--tt-dropdown-menu-text-color); + box-shadow: var(--tt-shadow-elevated-md); + transform-origin: var(--radix-dropdown-menu-content-transform-origin); + transition-duration: 100ms; +} + +.tiptap-dropdown-menu-content[data-side="bottom"] { + --slide-y: -0.5rem; +} +.tiptap-dropdown-menu-content[data-side="top"] { + --slide-y: 0.5rem; +} +.tiptap-dropdown-menu-content[data-side="left"] { + --slide-x: 0.5rem; +} +.tiptap-dropdown-menu-content[data-side="right"] { + --slide-x: -0.5rem; +} + +.tiptap-dropdown-menu-content[data-state="open"] { + animation: dropdown-in 100ms ease forwards; +} + +.tiptap-dropdown-menu-content[data-state="closed"] { + animation: dropdown-out 100ms ease forwards; + overflow: hidden; +} + +/* --- Sub Content --- */ + +.tiptap-dropdown-menu-sub-content { + --slide-x: 0px; + --slide-y: 0px; + --padding: 0.375rem; + + z-index: 50; + min-width: 6rem; + overflow: hidden; + border-radius: calc(var(--padding) + var(--tt-radius-lg)); + padding: 0.25rem; + background-color: var(--tt-dropdown-menu-bg-color); + box-shadow: var(--tt-shadow-elevated-md); + color: var(--tt-dropdown-menu-text-color); + transform-origin: var(--radix-dropdown-menu-content-transform-origin); + transition-duration: 100ms; +} + +.tiptap-dropdown-menu-sub-content[data-side="bottom"] { + --slide-y: -0.5rem; +} +.tiptap-dropdown-menu-sub-content[data-side="top"] { + --slide-y: 0.5rem; +} +.tiptap-dropdown-menu-sub-content[data-side="left"] { + --slide-x: 0.5rem; +} +.tiptap-dropdown-menu-sub-content[data-side="right"] { + --slide-x: -0.5rem; +} + +.tiptap-dropdown-menu-sub-content[data-state="open"] { + animation: dropdown-in 100ms ease forwards; +} + +.tiptap-dropdown-menu-sub-content[data-state="closed"] { + animation: dropdown-out 100ms ease forwards; +} + +/* Shared enter/exit keyframes — slide direction driven by --slide-x/y on the element */ + +@keyframes dropdown-in { + from { + opacity: 0; + transform: scale(0.95) translate(var(--slide-x), var(--slide-y)); + } + to { + opacity: 1; + transform: scale(1) translate(0, 0); + } +} + +@keyframes dropdown-out { + from { + opacity: 1; + transform: scale(1) translate(0, 0); + } + to { + opacity: 0; + transform: scale(0.95) translate(var(--slide-x), var(--slide-y)); + } +} + +/* --- Label --- */ + +.tiptap-dropdown-menu-label { + padding: 0.75rem 0.5rem 0.25rem; + font-size: 0.75rem; + font-weight: 600; + line-height: normal; + text-transform: capitalize; + color: var(--tt-dropdown-menu-label-color); +} + +.tiptap-dropdown-menu-label[data-inset="true"] { + padding-left: 1.75rem; +} + +.tiptap-dropdown-menu-group, +.tiptap-dropdown-menu-group[data-orientation="vertical"] { + display: flex; + flex-direction: column; +} + +.tiptap-dropdown-menu-group[data-orientation="horizontal"] { + display: flex; + flex-direction: row; + align-items: center; + gap: 0.25rem; +} diff --git a/resources/js/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.tsx b/resources/js/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.tsx new file mode 100644 index 0000000..6851d7b --- /dev/null +++ b/resources/js/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.tsx @@ -0,0 +1,262 @@ +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { cn } from "@/lib/tiptap-utils" +import { CheckIcon } from "@/components/tiptap-icons/check-icon" + +import "@/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.scss" + +function DropdownMenu({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuContent({ + className, + align = "start", + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + e.preventDefault()} + {...props} + /> + + ) +} + +function DropdownMenuGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuRadioItem({ + className, + children, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + ) +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} diff --git a/resources/js/components/tiptap-ui-primitive/dropdown-menu/index.tsx b/resources/js/components/tiptap-ui-primitive/dropdown-menu/index.tsx new file mode 100644 index 0000000..c4adece --- /dev/null +++ b/resources/js/components/tiptap-ui-primitive/dropdown-menu/index.tsx @@ -0,0 +1 @@ +export * from "./dropdown-menu" diff --git a/resources/js/components/tiptap-ui-primitive/input/index.tsx b/resources/js/components/tiptap-ui-primitive/input/index.tsx new file mode 100644 index 0000000..be91c8e --- /dev/null +++ b/resources/js/components/tiptap-ui-primitive/input/index.tsx @@ -0,0 +1 @@ +export * from "./input" diff --git a/resources/js/components/tiptap-ui-primitive/input/input.scss b/resources/js/components/tiptap-ui-primitive/input/input.scss new file mode 100644 index 0000000..eda967b --- /dev/null +++ b/resources/js/components/tiptap-ui-primitive/input/input.scss @@ -0,0 +1,41 @@ +:root { + --tt-input-placeholder: var(--tt-gray-light-a-400); + --tt-input-border: var(--tt-gray-light-a-200); + --tt-input-border-focus: var(--tt-gray-light-a-300); +} + +.dark { + --tt-input-placeholder: var(--tt-gray-dark-a-400); + --tt-input-border: var(--tt-gray-dark-a-200); + --tt-input-border-focus: var(--tt-gray-dark-a-300); +} + +.tiptap-input { + width: 100%; + min-width: 0; + height: 2rem; + padding: 0.25rem 0.625rem; + border-radius: var(--tt-radius-md); + border: 1px solid var(--tt-input-border); + background: transparent; + font-size: 1rem; + outline: none; + transition: + color, + background-color, + border-color 150ms; + + &::placeholder { + color: var(--tt-input-placeholder); + } +} + +.tiptap-input:focus-visible { + border-color: var(--tt-input-border-focus); +} + +@media (min-width: 768px) { + .tiptap-input { + font-size: 0.875rem; + } +} diff --git a/resources/js/components/tiptap-ui-primitive/input/input.tsx b/resources/js/components/tiptap-ui-primitive/input/input.tsx new file mode 100644 index 0000000..6dd115f --- /dev/null +++ b/resources/js/components/tiptap-ui-primitive/input/input.tsx @@ -0,0 +1,15 @@ +import { cn } from "@/lib/tiptap-utils" +import "@/components/tiptap-ui-primitive/input/input.scss" + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ) +} + +export { Input } diff --git a/resources/js/components/tiptap-ui-primitive/popover/index.tsx b/resources/js/components/tiptap-ui-primitive/popover/index.tsx new file mode 100644 index 0000000..137ef5d --- /dev/null +++ b/resources/js/components/tiptap-ui-primitive/popover/index.tsx @@ -0,0 +1 @@ +export * from "./popover" diff --git a/resources/js/components/tiptap-ui-primitive/popover/popover.scss b/resources/js/components/tiptap-ui-primitive/popover/popover.scss new file mode 100644 index 0000000..0962ac1 --- /dev/null +++ b/resources/js/components/tiptap-ui-primitive/popover/popover.scss @@ -0,0 +1,64 @@ +.tiptap-popover { + --tt-popover-bg-color: var(--white); + --tt-popover-border-color: var(--tt-gray-light-a-100); + --tt-popover-text-color: var(--tt-gray-light-a-600); +} + +:root.dark, +.dark { + --tt-popover-border-color: var(--tt-gray-dark-a-50); + --tt-popover-bg-color: var(--tt-gray-dark-50); + --tt-popover-text-color: var(--tt-gray-dark-a-600); +} + +/* -------------------------------------------- + --------- POPOVER STYLING SETTINGS ----------- + -------------------------------------------- */ +.tiptap-popover { + z-index: 50; + outline: none; + transform-origin: var(--radix-popover-content-transform-origin); + max-height: var(--radix-popover-content-available-height); + + > * { + max-height: var(--radix-popover-content-available-height); + } + + /* Animation states */ + &[data-state="open"] { + animation: + fadeIn 150ms cubic-bezier(0.16, 1, 0.3, 1), + zoomIn 150ms cubic-bezier(0.16, 1, 0.3, 1); + } + + &[data-state="closed"] { + animation: + fadeOut 150ms cubic-bezier(0.16, 1, 0.3, 1), + zoomOut 150ms cubic-bezier(0.16, 1, 0.3, 1); + } + + /* Position-based animations */ + &[data-side="top"], + &[data-side="top-start"], + &[data-side="top-end"] { + animation: slideFromBottom 150ms cubic-bezier(0.16, 1, 0.3, 1); + } + + &[data-side="right"], + &[data-side="right-start"], + &[data-side="right-end"] { + animation: slideFromLeft 150ms cubic-bezier(0.16, 1, 0.3, 1); + } + + &[data-side="bottom"], + &[data-side="bottom-start"], + &[data-side="bottom-end"] { + animation: slideFromTop 150ms cubic-bezier(0.16, 1, 0.3, 1); + } + + &[data-side="left"], + &[data-side="left-start"], + &[data-side="left-end"] { + animation: slideFromRight 150ms cubic-bezier(0.16, 1, 0.3, 1); + } +} diff --git a/resources/js/components/tiptap-ui-primitive/popover/popover.tsx b/resources/js/components/tiptap-ui-primitive/popover/popover.tsx new file mode 100644 index 0000000..9bd52f1 --- /dev/null +++ b/resources/js/components/tiptap-ui-primitive/popover/popover.tsx @@ -0,0 +1,35 @@ +import * as PopoverPrimitive from "@radix-ui/react-popover" +import { cn } from "@/lib/tiptap-utils" +import "@/components/tiptap-ui-primitive/popover/popover.scss" + +function Popover({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverContent({ + className, + align = "center", + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { Popover, PopoverTrigger, PopoverContent } diff --git a/resources/js/components/tiptap-ui-primitive/separator/index.tsx b/resources/js/components/tiptap-ui-primitive/separator/index.tsx new file mode 100644 index 0000000..068cfa8 --- /dev/null +++ b/resources/js/components/tiptap-ui-primitive/separator/index.tsx @@ -0,0 +1 @@ +export * from "./separator" diff --git a/resources/js/components/tiptap-ui-primitive/separator/separator.scss b/resources/js/components/tiptap-ui-primitive/separator/separator.scss new file mode 100644 index 0000000..78ec9ac --- /dev/null +++ b/resources/js/components/tiptap-ui-primitive/separator/separator.scss @@ -0,0 +1,23 @@ +.tiptap-separator { + --tt-link-border-color: var(--tt-gray-light-a-200); + + .dark & { + --tt-link-border-color: var(--tt-gray-dark-a-200); + } +} + +.tiptap-separator { + flex-shrink: 0; + background-color: var(--tt-link-border-color); + + &[data-orientation="horizontal"] { + height: 1px; + width: 100%; + margin: 0.5rem 0; + } + + &[data-orientation="vertical"] { + height: 1.5rem; + width: 1px; + } +} diff --git a/resources/js/components/tiptap-ui-primitive/separator/separator.tsx b/resources/js/components/tiptap-ui-primitive/separator/separator.tsx new file mode 100644 index 0000000..6c47833 --- /dev/null +++ b/resources/js/components/tiptap-ui-primitive/separator/separator.tsx @@ -0,0 +1,28 @@ +import "@/components/tiptap-ui-primitive/separator/separator.scss" +import { cn } from "@/lib/tiptap-utils" + +export type Orientation = "horizontal" | "vertical" + +export function Separator({ + decorative, + orientation = "vertical", + className, + ...props +}: React.ComponentProps<"div"> & { + orientation?: Orientation + decorative?: boolean +}) { + const ariaOrientation = orientation === "vertical" ? orientation : undefined + const semanticProps = decorative + ? { role: "none" } + : { "aria-orientation": ariaOrientation, role: "separator" } + + return ( +
+ ) +} diff --git a/resources/js/components/tiptap-ui-primitive/spacer/index.tsx b/resources/js/components/tiptap-ui-primitive/spacer/index.tsx new file mode 100644 index 0000000..b0789bf --- /dev/null +++ b/resources/js/components/tiptap-ui-primitive/spacer/index.tsx @@ -0,0 +1 @@ +export * from "./spacer" diff --git a/resources/js/components/tiptap-ui-primitive/spacer/spacer.tsx b/resources/js/components/tiptap-ui-primitive/spacer/spacer.tsx new file mode 100644 index 0000000..8598155 --- /dev/null +++ b/resources/js/components/tiptap-ui-primitive/spacer/spacer.tsx @@ -0,0 +1,24 @@ +"use client" + +export type SpacerOrientation = "horizontal" | "vertical" + +export function Spacer({ + orientation = "horizontal", + size, + style = {}, + ...props +}: React.ComponentProps<"div"> & { + orientation?: SpacerOrientation + size?: string | number +}) { + const computedStyle = { + ...style, + ...(orientation === "horizontal" && !size && { flex: 1 }), + ...(size && { + width: orientation === "vertical" ? "1px" : size, + height: orientation === "horizontal" ? "1px" : size, + }), + } + + return
+} diff --git a/resources/js/components/tiptap-ui-primitive/toolbar/index.tsx b/resources/js/components/tiptap-ui-primitive/toolbar/index.tsx new file mode 100644 index 0000000..94b1819 --- /dev/null +++ b/resources/js/components/tiptap-ui-primitive/toolbar/index.tsx @@ -0,0 +1 @@ +export * from "./toolbar" diff --git a/resources/js/components/tiptap-ui-primitive/toolbar/toolbar.scss b/resources/js/components/tiptap-ui-primitive/toolbar/toolbar.scss new file mode 100644 index 0000000..0404a82 --- /dev/null +++ b/resources/js/components/tiptap-ui-primitive/toolbar/toolbar.scss @@ -0,0 +1,98 @@ +:root { + --tt-toolbar-height: 2.75rem; + --tt-safe-area-bottom: env(safe-area-inset-bottom, 0px); + --tt-toolbar-bg-color: var(--white); + --tt-toolbar-border-color: var(--tt-gray-light-a-100); +} + +.dark { + --tt-toolbar-bg-color: var(--black); + --tt-toolbar-border-color: var(--tt-gray-dark-a-50); +} + +.tiptap-toolbar { + display: flex; + align-items: center; + gap: 0.25rem; + + &-group { + display: flex; + align-items: center; + gap: 0.125rem; + + &:empty { + display: none; + } + + &:empty + .tiptap-separator, + .tiptap-separator + &:empty { + display: none; + } + } + + &[data-variant="fixed"] { + position: sticky; + top: 0; + z-index: 50; + width: 100%; + min-height: var(--tt-toolbar-height); + background: var(--tt-toolbar-bg-color); + border-bottom: 1px solid var(--tt-toolbar-border-color); + padding: 0 0.5rem; + overflow-x: auto; + overscroll-behavior-x: contain; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + -ms-overflow-style: none; + + &::-webkit-scrollbar { + display: none; + } + + @media (max-width: 480px) { + position: absolute; + top: auto; + height: calc(var(--tt-toolbar-height) + var(--tt-safe-area-bottom)); + border-top: 1px solid var(--tt-toolbar-border-color); + border-bottom: none; + padding: 0 0.5rem var(--tt-safe-area-bottom); + flex-wrap: nowrap; + justify-content: flex-start; + + .tiptap-toolbar-group { + flex: 0 0 auto; + } + } + } + + &[data-variant="floating"] { + --tt-toolbar-padding: 0.125rem; + --tt-toolbar-border-width: 1px; + + padding: 0.188rem; + border-radius: calc( + var(--tt-toolbar-padding) + var(--tt-radius-lg) + + var(--tt-toolbar-border-width) + ); + border: var(--tt-toolbar-border-width) solid var(--tt-toolbar-border-color); + background-color: var(--tt-toolbar-bg-color); + box-shadow: var(--tt-shadow-elevated-md); + outline: none; + overflow: hidden; + + &[data-plain="true"] { + padding: 0; + border-radius: 0; + border: none; + box-shadow: none; + background-color: transparent; + } + + @media screen and (max-width: 480px) { + width: 100%; + border-radius: 0; + border: none; + box-shadow: none; + } + } +} diff --git a/resources/js/components/tiptap-ui-primitive/toolbar/toolbar.tsx b/resources/js/components/tiptap-ui-primitive/toolbar/toolbar.tsx new file mode 100644 index 0000000..b6de1f4 --- /dev/null +++ b/resources/js/components/tiptap-ui-primitive/toolbar/toolbar.tsx @@ -0,0 +1,121 @@ +import { forwardRef, useCallback, useEffect, useRef, useState } from "react" +import { Separator } from "@/components/tiptap-ui-primitive/separator" +import "@/components/tiptap-ui-primitive/toolbar/toolbar.scss" +import { cn } from "@/lib/tiptap-utils" +import { useMenuNavigation } from "@/hooks/use-menu-navigation" +import { useComposedRef } from "@/hooks/use-composed-ref" + +type BaseProps = React.HTMLAttributes + +interface ToolbarProps extends BaseProps { + variant?: "floating" | "fixed" +} + +const useToolbarNavigation = ( + toolbarRef: React.RefObject +) => { + const [items, setItems] = useState([]) + + const collectItems = useCallback(() => { + if (!toolbarRef.current) return [] + return Array.from( + toolbarRef.current.querySelectorAll( + 'button:not([disabled]), [role="button"]:not([disabled]), [tabindex="0"]:not([disabled])' + ) + ) + }, [toolbarRef]) + + useEffect(() => { + const toolbar = toolbarRef.current + if (!toolbar) return + + const updateItems = () => setItems(collectItems()) + + updateItems() + const observer = new MutationObserver(updateItems) + observer.observe(toolbar, { childList: true, subtree: true }) + + return () => observer.disconnect() + }, [collectItems, toolbarRef]) + + const { selectedIndex } = useMenuNavigation({ + containerRef: toolbarRef, + items, + orientation: "horizontal", + onSelect: (el) => el.click(), + autoSelectFirstItem: false, + }) + + useEffect(() => { + const toolbar = toolbarRef.current + if (!toolbar) return + + const handleFocus = (e: FocusEvent) => { + const target = e.target as HTMLElement + if (toolbar.contains(target)) + target.setAttribute("data-focus-visible", "true") + } + + const handleBlur = (e: FocusEvent) => { + const target = e.target as HTMLElement + if (toolbar.contains(target)) target.removeAttribute("data-focus-visible") + } + + toolbar.addEventListener("focus", handleFocus, true) + toolbar.addEventListener("blur", handleBlur, true) + + return () => { + toolbar.removeEventListener("focus", handleFocus, true) + toolbar.removeEventListener("blur", handleBlur, true) + } + }, [toolbarRef]) + + useEffect(() => { + if (selectedIndex !== undefined && items[selectedIndex]) { + items[selectedIndex].focus() + } + }, [selectedIndex, items]) +} + +export const Toolbar = forwardRef( + ({ children, className, variant = "fixed", ...props }, ref) => { + const toolbarRef = useRef(null) + const composedRef = useComposedRef(toolbarRef, ref) + useToolbarNavigation(toolbarRef) + + return ( +
+ {children} +
+ ) + } +) +Toolbar.displayName = "Toolbar" + +export const ToolbarGroup = forwardRef( + ({ children, className, ...props }, ref) => ( +
+ {children} +
+ ) +) +ToolbarGroup.displayName = "ToolbarGroup" + +export const ToolbarSeparator = forwardRef( + ({ ...props }, ref) => ( + + ) +) +ToolbarSeparator.displayName = "ToolbarSeparator" diff --git a/resources/js/components/tiptap-ui-primitive/tooltip/index.tsx b/resources/js/components/tiptap-ui-primitive/tooltip/index.tsx new file mode 100644 index 0000000..e12712a --- /dev/null +++ b/resources/js/components/tiptap-ui-primitive/tooltip/index.tsx @@ -0,0 +1 @@ +export * from "./tooltip" diff --git a/resources/js/components/tiptap-ui-primitive/tooltip/tooltip.scss b/resources/js/components/tiptap-ui-primitive/tooltip/tooltip.scss new file mode 100644 index 0000000..4971b29 --- /dev/null +++ b/resources/js/components/tiptap-ui-primitive/tooltip/tooltip.scss @@ -0,0 +1,43 @@ +.tiptap-tooltip { + --tt-tooltip-bg: var(--tt-gray-light-900); + --tt-tooltip-text: var(--white); + --tt-kbd: var(--tt-gray-dark-a-400); + + .dark & { + --tt-tooltip-bg: var(--white); + --tt-tooltip-text: var(--tt-gray-light-600); + --tt-kbd: var(--tt-gray-light-a-400); + } +} + +.tiptap-tooltip { + z-index: 50; + overflow: hidden; + border-radius: var(--tt-radius-md, 0.375rem); + background-color: var(--tt-tooltip-bg); + padding: 0.375rem 0.5rem; + font-size: 0.75rem; + font-weight: 500; + color: var(--tt-tooltip-text); + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1); + text-align: center; + + kbd { + display: inline-block; + text-align: center; + vertical-align: baseline; + font-family: + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + Roboto, + "Helvetica Neue", + Arial, + "Noto Sans", + sans-serif; + text-transform: capitalize; + color: var(--tt-kbd); + } +} diff --git a/resources/js/components/tiptap-ui-primitive/tooltip/tooltip.tsx b/resources/js/components/tiptap-ui-primitive/tooltip/tooltip.tsx new file mode 100644 index 0000000..59ecc44 --- /dev/null +++ b/resources/js/components/tiptap-ui-primitive/tooltip/tooltip.tsx @@ -0,0 +1,237 @@ +"use client" + +import { + cloneElement, + createContext, + forwardRef, + isValidElement, + useContext, + useMemo, + useState, + version, +} from "react" +import { + useFloating, + autoUpdate, + offset, + flip, + shift, + useHover, + useFocus, + useDismiss, + useRole, + useInteractions, + useMergeRefs, + FloatingPortal, + type Placement, + type UseFloatingReturn, + type ReferenceType, + FloatingDelayGroup, +} from "@floating-ui/react" +import "@/components/tiptap-ui-primitive/tooltip/tooltip.scss" + +interface TooltipProviderProps { + children: React.ReactNode + initialOpen?: boolean + placement?: Placement + open?: boolean + onOpenChange?: (open: boolean) => void + delay?: number + closeDelay?: number + timeout?: number + useDelayGroup?: boolean +} + +interface TooltipTriggerProps + extends Omit, "ref"> { + asChild?: boolean + children: React.ReactNode +} + +interface TooltipContentProps + extends Omit, "ref"> { + children?: React.ReactNode + portal?: boolean + portalProps?: Omit, "children"> +} + +interface TooltipContextValue extends UseFloatingReturn { + open: boolean + setOpen: (open: boolean) => void + getReferenceProps: ( + userProps?: React.HTMLProps + ) => Record + getFloatingProps: ( + userProps?: React.HTMLProps + ) => Record +} + +function useTooltip({ + initialOpen = false, + placement = "top", + open: controlledOpen, + onOpenChange: setControlledOpen, + delay = 600, + closeDelay = 0, +}: Omit = {}) { + const [uncontrolledOpen, setUncontrolledOpen] = useState(initialOpen) + + const open = controlledOpen ?? uncontrolledOpen + const setOpen = setControlledOpen ?? setUncontrolledOpen + + const data = useFloating({ + placement, + open, + onOpenChange: setOpen, + whileElementsMounted: autoUpdate, + middleware: [ + offset(4), + flip({ + crossAxis: placement.includes("-"), + fallbackAxisSideDirection: "start", + padding: 4, + }), + shift({ padding: 4 }), + ], + }) + + const context = data.context + + const hover = useHover(context, { + mouseOnly: true, + move: false, + restMs: delay, + enabled: controlledOpen == null, + delay: { + close: closeDelay, + }, + }) + const focus = useFocus(context, { + enabled: controlledOpen == null, + }) + const dismiss = useDismiss(context) + const role = useRole(context, { role: "tooltip" }) + + const interactions = useInteractions([hover, focus, dismiss, role]) + + return useMemo( + () => ({ + open, + setOpen, + ...interactions, + ...data, + }), + [open, setOpen, interactions, data] + ) +} + +const TooltipContext = createContext(null) + +function useTooltipContext() { + const context = useContext(TooltipContext) + + if (context == null) { + throw new Error("Tooltip components must be wrapped in ") + } + + return context +} + +export function Tooltip({ children, ...props }: TooltipProviderProps) { + const tooltip = useTooltip(props) + + if (!props.useDelayGroup) { + return ( + + {children} + + ) + } + + return ( + + + {children} + + + ) +} + +export const TooltipTrigger = forwardRef( + function TooltipTrigger({ children, asChild = false, ...props }, propRef) { + const context = useTooltipContext() + const childrenRef = isValidElement(children) + ? parseInt(version, 10) >= 19 + ? // eslint-disable-next-line @typescript-eslint/no-explicit-any + (children as { props: { ref?: React.Ref } }).props.ref + : // eslint-disable-next-line @typescript-eslint/no-explicit-any + (children as any).ref + : undefined + const ref = useMergeRefs([context.refs.setReference, propRef, childrenRef]) + + if (asChild && isValidElement(children)) { + const dataAttributes = { + "data-tooltip-state": context.open ? "open" : "closed", + } + + return cloneElement( + children, + context.getReferenceProps({ + ref, + ...props, + ...(typeof children.props === "object" ? children.props : {}), + ...dataAttributes, + }) + ) + } + + return ( + + ) + } +) + +export const TooltipContent = forwardRef( + function TooltipContent( + { style, children, portal = true, portalProps = {}, ...props }, + propRef + ) { + const context = useTooltipContext() + const ref = useMergeRefs([context.refs.setFloating, propRef]) + + if (!context.open) return null + + const content = ( +
+ {children} +
+ ) + + if (portal) { + return {content} + } + + return content + } +) + +Tooltip.displayName = "Tooltip" +TooltipTrigger.displayName = "TooltipTrigger" +TooltipContent.displayName = "TooltipContent" diff --git a/resources/js/components/tiptap-ui/blockquote-button/blockquote-button.tsx b/resources/js/components/tiptap-ui/blockquote-button/blockquote-button.tsx new file mode 100644 index 0000000..2485bb0 --- /dev/null +++ b/resources/js/components/tiptap-ui/blockquote-button/blockquote-button.tsx @@ -0,0 +1,122 @@ +import { forwardRef, useCallback } from "react" + +// --- Tiptap UI --- +import type { UseBlockquoteConfig } from "@/components/tiptap-ui/blockquote-button" +import { + BLOCKQUOTE_SHORTCUT_KEY, + useBlockquote, +} from "@/components/tiptap-ui/blockquote-button" + +// --- Hooks --- +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" + +// --- Lib --- +import { parseShortcutKeys } from "@/lib/tiptap-utils" + +// --- UI Primitives --- +import type { ButtonProps } from "@/components/tiptap-ui-primitive/button" +import { Button } from "@/components/tiptap-ui-primitive/button" +import { Badge } from "@/components/tiptap-ui-primitive/badge" + +export interface BlockquoteButtonProps + extends Omit, UseBlockquoteConfig { + /** + * Optional text to display alongside the icon. + */ + text?: string + /** + * Optional show shortcut keys in the button. + * @default false + */ + showShortcut?: boolean +} + +export function BlockquoteShortcutBadge({ + shortcutKeys = BLOCKQUOTE_SHORTCUT_KEY, +}: { + shortcutKeys?: string +}) { + return {parseShortcutKeys({ shortcutKeys })} +} + +/** + * Button component for toggling blockquote in a Tiptap editor. + * + * For custom button implementations, use the `useBlockquote` hook instead. + */ +export const BlockquoteButton = forwardRef< + HTMLButtonElement, + BlockquoteButtonProps +>( + ( + { + editor: providedEditor, + text, + hideWhenUnavailable = false, + onToggled, + showShortcut = false, + onClick, + children, + ...buttonProps + }, + ref + ) => { + const { editor } = useTiptapEditor(providedEditor) + const { + isVisible, + canToggle, + isActive, + handleToggle, + label, + shortcutKeys, + Icon, + } = useBlockquote({ + editor, + hideWhenUnavailable, + onToggled, + }) + + const handleClick = useCallback( + (event: React.MouseEvent) => { + onClick?.(event) + if (event.defaultPrevented) return + handleToggle() + }, + [handleToggle, onClick] + ) + + if (!isVisible) { + return null + } + + return ( + + ) + } +) + +BlockquoteButton.displayName = "BlockquoteButton" diff --git a/resources/js/components/tiptap-ui/blockquote-button/index.tsx b/resources/js/components/tiptap-ui/blockquote-button/index.tsx new file mode 100644 index 0000000..0b46edf --- /dev/null +++ b/resources/js/components/tiptap-ui/blockquote-button/index.tsx @@ -0,0 +1,2 @@ +export * from "./blockquote-button" +export * from "./use-blockquote" diff --git a/resources/js/components/tiptap-ui/blockquote-button/use-blockquote.ts b/resources/js/components/tiptap-ui/blockquote-button/use-blockquote.ts new file mode 100644 index 0000000..7e4f816 --- /dev/null +++ b/resources/js/components/tiptap-ui/blockquote-button/use-blockquote.ts @@ -0,0 +1,273 @@ +"use client" + +import { useCallback, useEffect, useState } from "react" +import type { Editor } from "@tiptap/react" +import { NodeSelection, TextSelection } from "@tiptap/pm/state" + +// --- Hooks --- +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" + +// --- Icons --- +import { BlockquoteIcon } from "@/components/tiptap-icons/blockquote-icon" + +// --- UI Utils --- +import { + findNodePosition, + getSelectedBlockNodes, + isNodeInSchema, + isNodeTypeSelected, + isValidPosition, + selectionWithinConvertibleTypes, +} from "@/lib/tiptap-utils" + +export const BLOCKQUOTE_SHORTCUT_KEY = "mod+shift+b" + +/** + * Configuration for the blockquote functionality + */ +export interface UseBlockquoteConfig { + /** + * The Tiptap editor instance. + */ + editor?: Editor | null + /** + * Whether the button should hide when blockquote is not available. + * @default false + */ + hideWhenUnavailable?: boolean + /** + * Callback function called after a successful toggle. + */ + onToggled?: () => void +} + +/** + * Checks if blockquote can be toggled in the current editor state + */ +export function canToggleBlockquote( + editor: Editor | null, + turnInto: boolean = true +): boolean { + if (!editor || !editor.isEditable) return false + if ( + !isNodeInSchema("blockquote", editor) || + isNodeTypeSelected(editor, ["image"]) + ) + return false + + if (!turnInto) { + return editor.can().toggleWrap("blockquote") + } + + // Ensure selection is in nodes we're allowed to convert + if ( + !selectionWithinConvertibleTypes(editor, [ + "paragraph", + "heading", + "bulletList", + "orderedList", + "taskList", + "blockquote", + "codeBlock", + ]) + ) + return false + + // Either we can wrap in blockquote directly on the selection, + // or we can clear formatting/nodes to arrive at a blockquote. + return editor.can().toggleWrap("blockquote") || editor.can().clearNodes() +} + +/** + * Toggles blockquote formatting for a specific node or the current selection + */ +export function toggleBlockquote(editor: Editor | null): boolean { + if (!editor || !editor.isEditable) return false + if (!canToggleBlockquote(editor)) return false + + try { + const view = editor.view + let state = view.state + let tr = state.tr + + const blocks = getSelectedBlockNodes(editor) + + // In case a selection contains multiple blocks, we only allow + // toggling to nide if there's exactly one block selected + // we also dont block the canToggle since it will fall back to the bottom logic + const isPossibleToTurnInto = + selectionWithinConvertibleTypes(editor, [ + "paragraph", + "heading", + "bulletList", + "orderedList", + "taskList", + "blockquote", + "codeBlock", + ]) && blocks.length === 1 + + // No selection, find the the cursor position + if ( + (state.selection.empty || state.selection instanceof TextSelection) && + isPossibleToTurnInto + ) { + const pos = findNodePosition({ + editor, + node: state.selection.$anchor.node(1), + })?.pos + if (!isValidPosition(pos)) return false + + tr = tr.setSelection(NodeSelection.create(state.doc, pos)) + view.dispatch(tr) + state = view.state + } + + const selection = state.selection + + let chain = editor.chain().focus() + + // Handle NodeSelection + if (selection instanceof NodeSelection) { + const firstChild = selection.node.firstChild?.firstChild + const lastChild = selection.node.lastChild?.lastChild + + const from = firstChild + ? selection.from + firstChild.nodeSize + : selection.from + 1 + + const to = lastChild + ? selection.to - lastChild.nodeSize + : selection.to - 1 + + const resolvedFrom = state.doc.resolve(from) + const resolvedTo = state.doc.resolve(to) + + chain = chain + .setTextSelection(TextSelection.between(resolvedFrom, resolvedTo)) + .clearNodes() + } + + const toggle = editor.isActive("blockquote") + ? chain.lift("blockquote") + : chain.wrapIn("blockquote") + + toggle.run() + + editor.chain().focus().selectTextblockEnd().run() + + return true + } catch { + return false + } +} + +/** + * Determines if the blockquote button should be shown + */ +export function shouldShowButton(props: { + editor: Editor | null + hideWhenUnavailable: boolean +}): boolean { + const { editor, hideWhenUnavailable } = props + + if (!editor) return false + + if (!hideWhenUnavailable) { + return true + } + + if (!editor.isEditable) return false + + if (!isNodeInSchema("blockquote", editor)) return false + + if (!editor.isActive("code")) { + return canToggleBlockquote(editor) + } + + return true +} + +/** + * Custom hook that provides blockquote functionality for Tiptap editor + * + * @example + * ```tsx + * // Simple usage - no params needed + * function MySimpleBlockquoteButton() { + * const { isVisible, handleToggle, isActive } = useBlockquote() + * + * if (!isVisible) return null + * + * return + * } + * + * // Advanced usage with configuration + * function MyAdvancedBlockquoteButton() { + * const { isVisible, handleToggle, label, isActive } = useBlockquote({ + * editor: myEditor, + * hideWhenUnavailable: true, + * onToggled: () => console.log('Blockquote toggled!') + * }) + * + * if (!isVisible) return null + * + * return ( + * + * Toggle Blockquote + * + * ) + * } + * ``` + */ +export function useBlockquote(config?: UseBlockquoteConfig) { + const { + editor: providedEditor, + hideWhenUnavailable = false, + onToggled, + } = config || {} + + const { editor } = useTiptapEditor(providedEditor) + const [isVisible, setIsVisible] = useState(true) + const canToggle = canToggleBlockquote(editor) + const isActive = editor?.isActive("blockquote") || false + + useEffect(() => { + if (!editor) return + + const handleSelectionUpdate = () => { + setIsVisible(shouldShowButton({ editor, hideWhenUnavailable })) + } + + handleSelectionUpdate() + + editor.on("selectionUpdate", handleSelectionUpdate) + + return () => { + editor.off("selectionUpdate", handleSelectionUpdate) + } + }, [editor, hideWhenUnavailable]) + + const handleToggle = useCallback(() => { + if (!editor) return false + + const success = toggleBlockquote(editor) + if (success) { + onToggled?.() + } + return success + }, [editor, onToggled]) + + return { + isVisible, + isActive, + handleToggle, + canToggle, + label: "Blockquote", + shortcutKeys: BLOCKQUOTE_SHORTCUT_KEY, + Icon: BlockquoteIcon, + } +} diff --git a/resources/js/components/tiptap-ui/code-block-button/code-block-button.tsx b/resources/js/components/tiptap-ui/code-block-button/code-block-button.tsx new file mode 100644 index 0000000..c1e9b79 --- /dev/null +++ b/resources/js/components/tiptap-ui/code-block-button/code-block-button.tsx @@ -0,0 +1,122 @@ +import { forwardRef, useCallback } from "react" + +// --- Hooks --- +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" + +// --- Lib --- +import { parseShortcutKeys } from "@/lib/tiptap-utils" + +// --- Tiptap UI --- +import type { UseCodeBlockConfig } from "@/components/tiptap-ui/code-block-button" +import { + CODE_BLOCK_SHORTCUT_KEY, + useCodeBlock, +} from "@/components/tiptap-ui/code-block-button" + +// --- UI Primitives --- +import type { ButtonProps } from "@/components/tiptap-ui-primitive/button" +import { Button } from "@/components/tiptap-ui-primitive/button" +import { Badge } from "@/components/tiptap-ui-primitive/badge" + +export interface CodeBlockButtonProps + extends Omit, UseCodeBlockConfig { + /** + * Optional text to display alongside the icon. + */ + text?: string + /** + * Optional show shortcut keys in the button. + * @default false + */ + showShortcut?: boolean +} + +export function CodeBlockShortcutBadge({ + shortcutKeys = CODE_BLOCK_SHORTCUT_KEY, +}: { + shortcutKeys?: string +}) { + return {parseShortcutKeys({ shortcutKeys })} +} + +/** + * Button component for toggling code block in a Tiptap editor. + * + * For custom button implementations, use the `useCodeBlock` hook instead. + */ +export const CodeBlockButton = forwardRef< + HTMLButtonElement, + CodeBlockButtonProps +>( + ( + { + editor: providedEditor, + text, + hideWhenUnavailable = false, + onToggled, + showShortcut = false, + onClick, + children, + ...buttonProps + }, + ref + ) => { + const { editor } = useTiptapEditor(providedEditor) + const { + isVisible, + canToggle, + isActive, + handleToggle, + label, + shortcutKeys, + Icon, + } = useCodeBlock({ + editor, + hideWhenUnavailable, + onToggled, + }) + + const handleClick = useCallback( + (event: React.MouseEvent) => { + onClick?.(event) + if (event.defaultPrevented) return + handleToggle() + }, + [handleToggle, onClick] + ) + + if (!isVisible) { + return null + } + + return ( + + ) + } +) + +CodeBlockButton.displayName = "CodeBlockButton" diff --git a/resources/js/components/tiptap-ui/code-block-button/index.tsx b/resources/js/components/tiptap-ui/code-block-button/index.tsx new file mode 100644 index 0000000..77d541f --- /dev/null +++ b/resources/js/components/tiptap-ui/code-block-button/index.tsx @@ -0,0 +1,2 @@ +export * from "./code-block-button" +export * from "./use-code-block" diff --git a/resources/js/components/tiptap-ui/code-block-button/use-code-block.ts b/resources/js/components/tiptap-ui/code-block-button/use-code-block.ts new file mode 100644 index 0000000..8801286 --- /dev/null +++ b/resources/js/components/tiptap-ui/code-block-button/use-code-block.ts @@ -0,0 +1,283 @@ +"use client" + +import { useCallback, useEffect, useState } from "react" +import { type Editor } from "@tiptap/react" +import { NodeSelection, TextSelection } from "@tiptap/pm/state" + +// --- Hooks --- +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" + +// --- Lib --- +import { + findNodePosition, + getSelectedBlockNodes, + isNodeInSchema, + isNodeTypeSelected, + isValidPosition, + selectionWithinConvertibleTypes, +} from "@/lib/tiptap-utils" + +// --- Icons --- +import { CodeBlockIcon } from "@/components/tiptap-icons/code-block-icon" + +export const CODE_BLOCK_SHORTCUT_KEY = "mod+alt+c" + +/** + * Configuration for the code block functionality + */ +export interface UseCodeBlockConfig { + /** + * The Tiptap editor instance. + */ + editor?: Editor | null + /** + * Whether the button should hide when code block is not available. + * @default false + */ + hideWhenUnavailable?: boolean + /** + * Callback function called after a successful code block toggle. + */ + onToggled?: () => void +} + +/** + * Checks if code block can be toggled in the current editor state + */ +export function canToggle( + editor: Editor | null, + turnInto: boolean = true +): boolean { + if (!editor || !editor.isEditable) return false + if ( + !isNodeInSchema("codeBlock", editor) || + isNodeTypeSelected(editor, ["image"]) + ) + return false + + if (!turnInto) { + return editor.can().toggleNode("codeBlock", "paragraph") + } + + // Ensure selection is in nodes we're allowed to convert + if ( + !selectionWithinConvertibleTypes(editor, [ + "paragraph", + "heading", + "bulletList", + "orderedList", + "taskList", + "blockquote", + "codeBlock", + ]) + ) + return false + + // Either we can toggle code block directly on the selection, + // or we can clear formatting/nodes to arrive at a code block. + return ( + editor.can().toggleNode("codeBlock", "paragraph") || + editor.can().clearNodes() + ) +} + +/** + * Toggles code block in the editor + */ +export function toggleCodeBlock(editor: Editor | null): boolean { + if (!editor || !editor.isEditable) return false + if (!canToggle(editor)) return false + + try { + const view = editor.view + let state = view.state + let tr = state.tr + + const blocks = getSelectedBlockNodes(editor) + + // In case a selection contains multiple blocks, we only allow + // toggling to nide if there's exactly one block selected + // we also dont block the canToggle since it will fall back to the bottom logic + const isPossibleToTurnInto = + selectionWithinConvertibleTypes(editor, [ + "paragraph", + "heading", + "bulletList", + "orderedList", + "taskList", + "blockquote", + "codeBlock", + ]) && blocks.length === 1 + + // No selection, find the the cursor position + if ( + (state.selection.empty || state.selection instanceof TextSelection) && + isPossibleToTurnInto + ) { + const pos = findNodePosition({ + editor, + node: state.selection.$anchor.node(1), + })?.pos + if (!isValidPosition(pos)) return false + + tr = tr.setSelection(NodeSelection.create(state.doc, pos)) + view.dispatch(tr) + state = view.state + } + + const selection = state.selection + + let chain = editor.chain().focus() + + // Handle NodeSelection + if (selection instanceof NodeSelection) { + const firstChild = selection.node.firstChild?.firstChild + const lastChild = selection.node.lastChild?.lastChild + + const from = firstChild + ? selection.from + firstChild.nodeSize + : selection.from + 1 + + const to = lastChild + ? selection.to - lastChild.nodeSize + : selection.to - 1 + + const resolvedFrom = state.doc.resolve(from) + const resolvedTo = state.doc.resolve(to) + + chain = chain + .setTextSelection(TextSelection.between(resolvedFrom, resolvedTo)) + .clearNodes() + } + + const toggle = editor.isActive("codeBlock") + ? chain.setNode("paragraph") + : chain.toggleNode("codeBlock", "paragraph") + + toggle.run() + + editor.chain().focus().selectTextblockEnd().run() + + return true + } catch { + return false + } +} + +/** + * Determines if the code block button should be shown + */ +export function shouldShowButton(props: { + editor: Editor | null + hideWhenUnavailable: boolean +}): boolean { + const { editor, hideWhenUnavailable } = props + + if (!editor) return false + + if (!hideWhenUnavailable) { + return true + } + + if (!editor.isEditable) return false + + if (!isNodeInSchema("codeBlock", editor)) return false + + if (!editor.isActive("code")) { + return canToggle(editor) + } + + return true +} + +/** + * Custom hook that provides code block functionality for Tiptap editor + * + * @example + * ```tsx + * // Simple usage - no params needed + * function MySimpleCodeBlockButton() { + * const { isVisible, isActive, handleToggle } = useCodeBlock() + * + * if (!isVisible) return null + * + * return ( + * + * ) + * } + * + * // Advanced usage with configuration + * function MyAdvancedCodeBlockButton() { + * const { isVisible, isActive, handleToggle, label } = useCodeBlock({ + * editor: myEditor, + * hideWhenUnavailable: true, + * onToggled: (isActive) => console.log('Code block toggled:', isActive) + * }) + * + * if (!isVisible) return null + * + * return ( + * + * Toggle Code Block + * + * ) + * } + * ``` + */ +export function useCodeBlock(config?: UseCodeBlockConfig) { + const { + editor: providedEditor, + hideWhenUnavailable = false, + onToggled, + } = config || {} + + const { editor } = useTiptapEditor(providedEditor) + const [isVisible, setIsVisible] = useState(true) + const canToggleState = canToggle(editor) + const isActive = editor?.isActive("codeBlock") || false + + useEffect(() => { + if (!editor) return + + const handleSelectionUpdate = () => { + setIsVisible(shouldShowButton({ editor, hideWhenUnavailable })) + } + + handleSelectionUpdate() + + editor.on("selectionUpdate", handleSelectionUpdate) + + return () => { + editor.off("selectionUpdate", handleSelectionUpdate) + } + }, [editor, hideWhenUnavailable]) + + const handleToggle = useCallback(() => { + if (!editor) return false + + const success = toggleCodeBlock(editor) + if (success) { + onToggled?.() + } + return success + }, [editor, onToggled]) + + return { + isVisible, + isActive, + handleToggle, + canToggle: canToggleState, + label: "Code Block", + shortcutKeys: CODE_BLOCK_SHORTCUT_KEY, + Icon: CodeBlockIcon, + } +} diff --git a/resources/js/components/tiptap-ui/color-highlight-button/color-highlight-button.scss b/resources/js/components/tiptap-ui/color-highlight-button/color-highlight-button.scss new file mode 100644 index 0000000..2c6f387 --- /dev/null +++ b/resources/js/components/tiptap-ui/color-highlight-button/color-highlight-button.scss @@ -0,0 +1,49 @@ +.tiptap-button-highlight { + position: relative; + width: 1.25rem; + height: 1.25rem; + margin: 0 -0.175rem; + border-radius: var(--tt-radius-xl); + background-color: var(--highlight-color); + transition: transform 0.2s ease; + + &::after { + content: ""; + position: absolute; + width: 100%; + height: 100%; + left: 0; + top: 0; + border-radius: inherit; + box-sizing: border-box; + border: 1px solid var(--highlight-color); + filter: brightness(95%); + mix-blend-mode: multiply; + + .dark & { + filter: brightness(140%); + mix-blend-mode: lighten; + } + } +} + +.tiptap-button { + &[data-active-state="on"] { + .tiptap-button-highlight { + &::after { + filter: brightness(80%); + } + } + } + + .dark & { + &[data-active-state="on"] { + .tiptap-button-highlight { + &::after { + // Andere Eigenschaft für .dark Kontext + filter: brightness(180%); + } + } + } + } +} diff --git a/resources/js/components/tiptap-ui/color-highlight-button/color-highlight-button.tsx b/resources/js/components/tiptap-ui/color-highlight-button/color-highlight-button.tsx new file mode 100644 index 0000000..76fbeb4 --- /dev/null +++ b/resources/js/components/tiptap-ui/color-highlight-button/color-highlight-button.tsx @@ -0,0 +1,170 @@ +import { forwardRef, useCallback, useMemo } from "react" + +// --- Lib --- +import { parseShortcutKeys } from "@/lib/tiptap-utils" + +// --- Hooks --- +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" + +// --- Tiptap UI --- +import type { UseColorHighlightConfig } from "@/components/tiptap-ui/color-highlight-button" +import { + COLOR_HIGHLIGHT_SHORTCUT_KEY, + useColorHighlight, +} from "@/components/tiptap-ui/color-highlight-button" + +// --- UI Primitives --- +import type { ButtonProps } from "@/components/tiptap-ui-primitive/button" +import { Button } from "@/components/tiptap-ui-primitive/button" +import { Badge } from "@/components/tiptap-ui-primitive/badge" + +// --- Styles --- +import "@/components/tiptap-ui/color-highlight-button/color-highlight-button.scss" + +export interface ColorHighlightButtonProps + extends Omit, UseColorHighlightConfig { + /** + * Optional text to display alongside the icon. + */ + text?: string + /** + * Optional show shortcut keys in the button. + * @default false + */ + showShortcut?: boolean +} + +export function ColorHighlightShortcutBadge({ + shortcutKeys = COLOR_HIGHLIGHT_SHORTCUT_KEY, +}: { + shortcutKeys?: string +}) { + return {parseShortcutKeys({ shortcutKeys })} +} + +/** + * Button component for applying color highlights in a Tiptap editor. + * + * Supports two highlighting modes: + * - "mark": Uses the highlight mark extension (default) + * - "node": Uses the node background extension + * + * For custom button implementations, use the `useColorHighlight` hook instead. + * + * @example + * ```tsx + * // Mark-based highlighting (default) + * + * + * // Node-based background coloring + * + * + * // With custom callback + * console.log(`Applied ${color} in ${mode} mode`)} + * /> + * ``` + */ +export const ColorHighlightButton = forwardRef< + HTMLButtonElement, + ColorHighlightButtonProps +>( + ( + { + editor: providedEditor, + highlightColor, + text, + hideWhenUnavailable = false, + mode = "mark", + onApplied, + showShortcut = false, + onClick, + children, + style, + useColorValue = false, + ...buttonProps + }, + ref + ) => { + const { editor } = useTiptapEditor(providedEditor) + const { + isVisible, + canColorHighlight, + isActive, + handleColorHighlight, + label, + shortcutKeys, + } = useColorHighlight({ + editor, + highlightColor, + useColorValue, + label: text || `Toggle highlight (${highlightColor})`, + hideWhenUnavailable, + mode, + onApplied, + }) + + const handleClick = useCallback( + (event: React.MouseEvent) => { + onClick?.(event) + if (event.defaultPrevented) return + handleColorHighlight() + }, + [handleColorHighlight, onClick] + ) + + const buttonStyle = useMemo( + () => + ({ + ...style, + "--highlight-color": highlightColor, + }) as React.CSSProperties, + [highlightColor, style] + ) + + if (!isVisible) { + return null + } + + return ( + + ) + } +) + +ColorHighlightButton.displayName = "ColorHighlightButton" diff --git a/resources/js/components/tiptap-ui/color-highlight-button/index.tsx b/resources/js/components/tiptap-ui/color-highlight-button/index.tsx new file mode 100644 index 0000000..c517648 --- /dev/null +++ b/resources/js/components/tiptap-ui/color-highlight-button/index.tsx @@ -0,0 +1,2 @@ +export * from "./color-highlight-button" +export * from "./use-color-highlight" diff --git a/resources/js/components/tiptap-ui/color-highlight-button/use-color-highlight.ts b/resources/js/components/tiptap-ui/color-highlight-button/use-color-highlight.ts new file mode 100644 index 0000000..4c7cfce --- /dev/null +++ b/resources/js/components/tiptap-ui/color-highlight-button/use-color-highlight.ts @@ -0,0 +1,380 @@ +"use client" + +import { useCallback, useEffect, useState } from "react" +import { type Editor } from "@tiptap/react" +import { useHotkeys } from "react-hotkeys-hook" + +// --- Hooks --- +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" +import { useIsBreakpoint } from "@/hooks/use-is-breakpoint" + +// --- Lib --- +import { + isMarkInSchema, + isNodeTypeSelected, + isExtensionAvailable, +} from "@/lib/tiptap-utils" + +// --- Icons --- +import { HighlighterIcon } from "@/components/tiptap-icons/highlighter-icon" + +export const COLOR_HIGHLIGHT_SHORTCUT_KEY = "mod+shift+h" +export const HIGHLIGHT_COLORS = [ + { + label: "Default background", + value: "var(--tt-bg-color)", + colorValue: "#ffffff", + border: "var(--tt-bg-color-contrast)", + }, + { + label: "Gray background", + value: "var(--tt-color-highlight-gray)", + colorValue: "#f8f8f7", + border: "var(--tt-color-highlight-gray-contrast)", + }, + { + label: "Brown background", + value: "var(--tt-color-highlight-brown)", + colorValue: "#f4eeee", + border: "var(--tt-color-highlight-brown-contrast)", + }, + { + label: "Orange background", + value: "var(--tt-color-highlight-orange)", + colorValue: "#fbecdd", + border: "var(--tt-color-highlight-orange-contrast)", + }, + { + label: "Yellow background", + value: "var(--tt-color-highlight-yellow)", + colorValue: "#fef9c3", + border: "var(--tt-color-highlight-yellow-contrast)", + }, + { + label: "Green background", + value: "var(--tt-color-highlight-green)", + colorValue: "#dcfce7", + border: "var(--tt-color-highlight-green-contrast)", + }, + { + label: "Blue background", + value: "var(--tt-color-highlight-blue)", + colorValue: "#e0f2fe", + border: "var(--tt-color-highlight-blue-contrast)", + }, + { + label: "Purple background", + value: "var(--tt-color-highlight-purple)", + colorValue: "#f3e8ff", + border: "var(--tt-color-highlight-purple-contrast)", + }, + { + label: "Pink background", + value: "var(--tt-color-highlight-pink)", + colorValue: "#fcf1f6", + border: "var(--tt-color-highlight-pink-contrast)", + }, + { + label: "Red background", + value: "var(--tt-color-highlight-red)", + colorValue: "#ffe4e6", + border: "var(--tt-color-highlight-red-contrast)", + }, +] +export type HighlightColor = (typeof HIGHLIGHT_COLORS)[number] + +export type HighlightMode = "mark" | "node" + +/** + * Configuration for the color highlight functionality + */ +export interface UseColorHighlightConfig { + /** + * The Tiptap editor instance. + */ + editor?: Editor | null + /** + * The color to apply when toggling the highlight. + */ + highlightColor?: string + /** + * Optional label to display alongside the icon. + */ + label?: string + /** + * Whether the button should hide when the mark is not available. + * @default false + */ + hideWhenUnavailable?: boolean + /** + * The highlighting mode to use. + * - "mark": Uses the highlight mark extension (default) + * - "node": Uses the node background extension + * @default "mark" + */ + mode?: HighlightMode + /** + * When true, uses the actual color value (colorValue) instead of CSS variable (value). + * @default false + */ + useColorValue?: boolean + /** + * Called when the highlight is applied. + */ + onApplied?: ({ + color, + label, + mode, + }: { + color: string + label: string + mode: HighlightMode + }) => void +} + +export function pickHighlightColorsByValue(values: string[]) { + const colorMap = new Map( + HIGHLIGHT_COLORS.map((color) => [color.value, color]) + ) + return values + .map((value) => colorMap.get(value)) + .filter((color): color is (typeof HIGHLIGHT_COLORS)[number] => !!color) +} + +/** + * Gets the appropriate color value based on configuration + */ +export function getHighlightColorValue( + color: string, + useColorValue: boolean = false +): string { + if (!useColorValue) return color + + const colorItem = HIGHLIGHT_COLORS.find( + (c) => c.value === color || c.colorValue === color + ) + return colorItem?.colorValue || color +} + +/** + * Checks if highlight can be applied based on the mode and current editor state + */ +export function canColorHighlight( + editor: Editor | null, + mode: HighlightMode = "mark" +): boolean { + if (!editor || !editor.isEditable) return false + + if (mode === "mark") { + if ( + !isMarkInSchema("highlight", editor) || + isNodeTypeSelected(editor, ["image"]) + ) + return false + + return editor.can().setMark("highlight") + } else { + if (!isExtensionAvailable(editor, ["nodeBackground"])) return false + + try { + return editor.can().toggleNodeBackgroundColor("test") + } catch { + return false + } + } +} + +/** + * Checks if highlight is currently active + */ +export function isColorHighlightActive( + editor: Editor | null, + highlightColor?: string, + mode: HighlightMode = "mark" +): boolean { + if (!editor || !editor.isEditable) return false + + if (mode === "mark") { + return highlightColor + ? editor.isActive("highlight", { color: highlightColor }) + : editor.isActive("highlight") + } else { + if (!highlightColor) return false + + try { + const { state } = editor + const { selection } = state + + const $pos = selection.$anchor + for (let depth = $pos.depth; depth >= 0; depth--) { + const node = $pos.node(depth) + if (node && node.attrs?.backgroundColor === highlightColor) { + return true + } + } + return false + } catch { + return false + } + } +} + +/** + * Removes highlight based on the mode + */ +export function removeHighlight( + editor: Editor | null, + mode: HighlightMode = "mark" +): boolean { + if (!editor || !editor.isEditable) return false + if (!canColorHighlight(editor, mode)) return false + + if (mode === "mark") { + return editor.chain().focus().unsetMark("highlight").run() + } else { + return editor.chain().focus().unsetNodeBackgroundColor().run() + } +} + +/** + * Determines if the highlight button should be shown + */ +export function shouldShowButton(props: { + editor: Editor | null + hideWhenUnavailable: boolean + mode: HighlightMode +}): boolean { + const { editor, hideWhenUnavailable, mode } = props + + if (!editor) return false + + if (!hideWhenUnavailable) { + return true + } + + if (!editor.isEditable) return false + + // hideWhenUnavailable=true: check schema/extension availability + if (mode === "mark") { + if (!isMarkInSchema("highlight", editor)) return false + } else { + if (!isExtensionAvailable(editor, ["nodeBackground"])) return false + } + + if (!editor.isActive("code")) { + return canColorHighlight(editor, mode) + } + + return true +} + +export function useColorHighlight(config: UseColorHighlightConfig) { + const { + editor: providedEditor, + label, + highlightColor, + hideWhenUnavailable = false, + mode = "mark", + useColorValue = false, + onApplied, + } = config + + const { editor } = useTiptapEditor(providedEditor) + const isMobile = useIsBreakpoint() + const [isVisible, setIsVisible] = useState(true) + const canColorHighlightState = canColorHighlight(editor, mode) + const actualColor = highlightColor + ? getHighlightColorValue(highlightColor, useColorValue) + : highlightColor + const isActive = isColorHighlightActive(editor, actualColor, mode) + + useEffect(() => { + if (!editor) return + + const handleSelectionUpdate = () => { + setIsVisible(shouldShowButton({ editor, hideWhenUnavailable, mode })) + } + + handleSelectionUpdate() + + editor.on("selectionUpdate", handleSelectionUpdate) + + return () => { + editor.off("selectionUpdate", handleSelectionUpdate) + } + }, [editor, hideWhenUnavailable, mode]) + + const handleColorHighlight = useCallback(() => { + if (!editor || !canColorHighlightState || !actualColor || !label) + return false + + if (mode === "mark") { + if (editor.state.storedMarks) { + const highlightMarkType = editor.schema.marks.highlight + if (highlightMarkType) { + editor.view.dispatch( + editor.state.tr.removeStoredMark(highlightMarkType) + ) + } + } + + setTimeout(() => { + const success = editor + .chain() + .focus() + .toggleHighlight({ color: actualColor }) + .run() + if (success) { + onApplied?.({ color: actualColor, label, mode }) + } + return success + }, 0) + + return true + } else { + const success = editor + .chain() + .focus() + .toggleNodeBackgroundColor(actualColor) + .run() + + if (success) { + onApplied?.({ color: actualColor, label, mode }) + } + return success + } + }, [canColorHighlightState, actualColor, editor, label, onApplied, mode]) + + const handleRemoveHighlight = useCallback(() => { + const success = removeHighlight(editor, mode) + if (success) { + onApplied?.({ color: "", label: "Remove highlight", mode }) + } + return success + }, [editor, onApplied, mode]) + + useHotkeys( + COLOR_HIGHLIGHT_SHORTCUT_KEY, + (event) => { + event.preventDefault() + handleColorHighlight() + }, + { + enabled: isVisible && canColorHighlightState, + enableOnContentEditable: !isMobile, + enableOnFormTags: true, + } + ) + + return { + isVisible, + isActive, + handleColorHighlight, + handleRemoveHighlight, + canColorHighlight: canColorHighlightState, + label: label || `Highlight`, + shortcutKeys: COLOR_HIGHLIGHT_SHORTCUT_KEY, + Icon: HighlighterIcon, + mode, + } +} diff --git a/resources/js/components/tiptap-ui/color-highlight-popover/color-highlight-popover.tsx b/resources/js/components/tiptap-ui/color-highlight-popover/color-highlight-popover.tsx new file mode 100644 index 0000000..5a92f2c --- /dev/null +++ b/resources/js/components/tiptap-ui/color-highlight-popover/color-highlight-popover.tsx @@ -0,0 +1,231 @@ +import { forwardRef, useMemo, useRef, useState } from "react" +import { type Editor } from "@tiptap/react" + +// --- Hooks --- +import { useMenuNavigation } from "@/hooks/use-menu-navigation" +import { useIsBreakpoint } from "@/hooks/use-is-breakpoint" +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" + +// --- Icons --- +import { BanIcon } from "@/components/tiptap-icons/ban-icon" +import { HighlighterIcon } from "@/components/tiptap-icons/highlighter-icon" + +// --- UI Primitives --- +import type { ButtonProps } from "@/components/tiptap-ui-primitive/button" +import { Button } from "@/components/tiptap-ui-primitive/button" +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "@/components/tiptap-ui-primitive/popover" +import { Separator } from "@/components/tiptap-ui-primitive/separator" +import { + Card, + CardBody, + CardItemGroup, +} from "@/components/tiptap-ui-primitive/card" + +// --- Tiptap UI --- +import type { + HighlightColor, + UseColorHighlightConfig, +} from "@/components/tiptap-ui/color-highlight-button" +import { + ColorHighlightButton, + pickHighlightColorsByValue, + useColorHighlight, +} from "@/components/tiptap-ui/color-highlight-button" +import { ButtonGroup } from "@/components/tiptap-ui-primitive/button-group" + +export interface ColorHighlightPopoverContentProps { + /** + * The Tiptap editor instance. + */ + editor?: Editor | null + /** + * Optional colors to use in the highlight popover. + * If not provided, defaults to a predefined set of colors. + */ + colors?: HighlightColor[] + /** + * When true, uses the actual color value (colorValue) instead of CSS variable (value). + * @default false + */ + useColorValue?: boolean +} + +export interface ColorHighlightPopoverProps + extends + Omit, + Pick< + UseColorHighlightConfig, + "editor" | "hideWhenUnavailable" | "onApplied" + > { + /** + * Optional colors to use in the highlight popover. + * If not provided, defaults to a predefined set of colors. + */ + colors?: HighlightColor[] + /** + * When true, uses the actual color value (colorValue) instead of CSS variable (value). + * @default false + */ + useColorValue?: boolean +} + +export const ColorHighlightPopoverButton = forwardRef< + HTMLButtonElement, + ButtonProps +>(({ className, children, ...props }, ref) => ( + +)) + +ColorHighlightPopoverButton.displayName = "ColorHighlightPopoverButton" + +export function ColorHighlightPopoverContent({ + editor, + colors = pickHighlightColorsByValue([ + "var(--tt-color-highlight-green)", + "var(--tt-color-highlight-blue)", + "var(--tt-color-highlight-red)", + "var(--tt-color-highlight-purple)", + "var(--tt-color-highlight-yellow)", + ]), + useColorValue = false, +}: ColorHighlightPopoverContentProps) { + const { handleRemoveHighlight } = useColorHighlight({ editor }) + const isMobile = useIsBreakpoint() + const containerRef = useRef(null) + + const menuItems = useMemo( + () => [...colors, { label: "Remove highlight", value: "none" }], + [colors] + ) + + const { selectedIndex } = useMenuNavigation({ + containerRef, + items: menuItems, + orientation: "both", + onSelect: (item) => { + if (!containerRef.current) return false + const highlightedElement = containerRef.current.querySelector( + '[data-highlighted="true"]' + ) as HTMLElement + if (highlightedElement) highlightedElement.click() + if (item.value === "none") handleRemoveHighlight() + return true + }, + autoSelectFirstItem: false, + }) + + return ( + + + + + {colors.map((color, index) => ( + + + + ))} + + + + + + + + + ) +} + +export function ColorHighlightPopover({ + editor: providedEditor, + colors = pickHighlightColorsByValue([ + "var(--tt-color-highlight-green)", + "var(--tt-color-highlight-blue)", + "var(--tt-color-highlight-red)", + "var(--tt-color-highlight-purple)", + "var(--tt-color-highlight-yellow)", + ]), + hideWhenUnavailable = false, + useColorValue = false, + onApplied, + ...props +}: ColorHighlightPopoverProps) { + const { editor } = useTiptapEditor(providedEditor) + const [isOpen, setIsOpen] = useState(false) + const { isVisible, canColorHighlight, isActive, label, Icon } = + useColorHighlight({ + editor, + hideWhenUnavailable, + onApplied, + }) + + if (!isVisible) return null + + return ( + + + + + + + + + + + ) +} + +export default ColorHighlightPopover diff --git a/resources/js/components/tiptap-ui/color-highlight-popover/index.tsx b/resources/js/components/tiptap-ui/color-highlight-popover/index.tsx new file mode 100644 index 0000000..626b81f --- /dev/null +++ b/resources/js/components/tiptap-ui/color-highlight-popover/index.tsx @@ -0,0 +1 @@ +export * from "./color-highlight-popover" diff --git a/resources/js/components/tiptap-ui/heading-button/heading-button.tsx b/resources/js/components/tiptap-ui/heading-button/heading-button.tsx new file mode 100644 index 0000000..b0b5394 --- /dev/null +++ b/resources/js/components/tiptap-ui/heading-button/heading-button.tsx @@ -0,0 +1,124 @@ +import { forwardRef, useCallback } from "react" + +// --- Lib --- +import { parseShortcutKeys } from "@/lib/tiptap-utils" + +// --- Tiptap UI --- +import type { + Level, + UseHeadingConfig, +} from "@/components/tiptap-ui/heading-button" +import { + HEADING_SHORTCUT_KEYS, + useHeading, +} from "@/components/tiptap-ui/heading-button" + +// --- UI Primitives --- +import type { ButtonProps } from "@/components/tiptap-ui-primitive/button" +import { Button } from "@/components/tiptap-ui-primitive/button" +import { Badge } from "@/components/tiptap-ui-primitive/badge" +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" + +export interface HeadingButtonProps + extends Omit, UseHeadingConfig { + /** + * Optional text to display alongside the icon. + */ + text?: string + /** + * Optional show shortcut keys in the button. + * @default false + */ + showShortcut?: boolean +} + +export function HeadingShortcutBadge({ + level, + shortcutKeys = HEADING_SHORTCUT_KEYS[level], +}: { + level: Level + shortcutKeys?: string +}) { + return {parseShortcutKeys({ shortcutKeys })} +} + +/** + * Button component for toggling heading in a Tiptap editor. + * + * For custom button implementations, use the `useHeading` hook instead. + */ +export const HeadingButton = forwardRef( + ( + { + editor: providedEditor, + level, + text, + hideWhenUnavailable = false, + onToggled, + showShortcut = false, + onClick, + children, + ...buttonProps + }, + ref + ) => { + const { editor } = useTiptapEditor(providedEditor) + const { + isVisible, + canToggle, + isActive, + handleToggle, + label, + Icon, + shortcutKeys, + } = useHeading({ + editor, + level, + hideWhenUnavailable, + onToggled, + }) + + const handleClick = useCallback( + (event: React.MouseEvent) => { + onClick?.(event) + if (event.defaultPrevented) return + handleToggle() + }, + [handleToggle, onClick] + ) + + if (!isVisible) { + return null + } + + return ( + + ) + } +) + +HeadingButton.displayName = "HeadingButton" diff --git a/resources/js/components/tiptap-ui/heading-button/index.tsx b/resources/js/components/tiptap-ui/heading-button/index.tsx new file mode 100644 index 0000000..009a700 --- /dev/null +++ b/resources/js/components/tiptap-ui/heading-button/index.tsx @@ -0,0 +1,2 @@ +export * from "./heading-button" +export * from "./use-heading" diff --git a/resources/js/components/tiptap-ui/heading-button/use-heading.ts b/resources/js/components/tiptap-ui/heading-button/use-heading.ts new file mode 100644 index 0000000..7d76cdc --- /dev/null +++ b/resources/js/components/tiptap-ui/heading-button/use-heading.ts @@ -0,0 +1,348 @@ +"use client" + +import { useCallback, useEffect, useState } from "react" +import { type Editor } from "@tiptap/react" +import { NodeSelection, TextSelection } from "@tiptap/pm/state" + +// --- Hooks --- +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" + +// --- Lib --- +import { + findNodePosition, + getSelectedBlockNodes, + isNodeInSchema, + isNodeTypeSelected, + isValidPosition, + selectionWithinConvertibleTypes, +} from "@/lib/tiptap-utils" + +// --- Icons --- +import { HeadingOneIcon } from "@/components/tiptap-icons/heading-one-icon" +import { HeadingTwoIcon } from "@/components/tiptap-icons/heading-two-icon" +import { HeadingThreeIcon } from "@/components/tiptap-icons/heading-three-icon" +import { HeadingFourIcon } from "@/components/tiptap-icons/heading-four-icon" +import { HeadingFiveIcon } from "@/components/tiptap-icons/heading-five-icon" +import { HeadingSixIcon } from "@/components/tiptap-icons/heading-six-icon" + +export type Level = 1 | 2 | 3 | 4 | 5 | 6 + +/** + * Configuration for the heading functionality + */ +export interface UseHeadingConfig { + /** + * The Tiptap editor instance. + */ + editor?: Editor | null + /** + * The heading level. + */ + level: Level + /** + * Whether the button should hide when heading is not available. + * @default false + */ + hideWhenUnavailable?: boolean + /** + * Callback function called after a successful heading toggle. + */ + onToggled?: () => void +} + +export const headingIcons = { + 1: HeadingOneIcon, + 2: HeadingTwoIcon, + 3: HeadingThreeIcon, + 4: HeadingFourIcon, + 5: HeadingFiveIcon, + 6: HeadingSixIcon, +} + +export const HEADING_SHORTCUT_KEYS: Record = { + 1: "ctrl+alt+1", + 2: "ctrl+alt+2", + 3: "ctrl+alt+3", + 4: "ctrl+alt+4", + 5: "ctrl+alt+5", + 6: "ctrl+alt+6", +} + +/** + * Checks if heading can be toggled in the current editor state + */ +export function canToggle( + editor: Editor | null, + level?: Level, + turnInto: boolean = true +): boolean { + if (!editor || !editor.isEditable) return false + if ( + !isNodeInSchema("heading", editor) || + isNodeTypeSelected(editor, ["image"]) + ) + return false + + if (!turnInto) { + return level + ? editor.can().setNode("heading", { level }) + : editor.can().setNode("heading") + } + + // Ensure selection is in nodes we're allowed to convert + if ( + !selectionWithinConvertibleTypes(editor, [ + "paragraph", + "heading", + "bulletList", + "orderedList", + "taskList", + "blockquote", + "codeBlock", + ]) + ) + return false + + // Either we can set heading directly on the selection, + // or we can clear formatting/nodes to arrive at a heading. + return level + ? editor.can().setNode("heading", { level }) || editor.can().clearNodes() + : editor.can().setNode("heading") || editor.can().clearNodes() +} + +/** + * Checks if heading is currently active + */ +export function isHeadingActive( + editor: Editor | null, + level?: Level | Level[] +): boolean { + if (!editor || !editor.isEditable) return false + + if (Array.isArray(level)) { + return level.some((l) => editor.isActive("heading", { level: l })) + } + + return level + ? editor.isActive("heading", { level }) + : editor.isActive("heading") +} + +/** + * Toggles heading in the editor + */ +export function toggleHeading( + editor: Editor | null, + level: Level | Level[] +): boolean { + if (!editor || !editor.isEditable) return false + + const levels = Array.isArray(level) ? level : [level] + const toggleLevel = levels.find((l) => canToggle(editor, l)) + + if (!toggleLevel) return false + + try { + const view = editor.view + let state = view.state + let tr = state.tr + + const blocks = getSelectedBlockNodes(editor) + + // In case a selection contains multiple blocks, we only allow + // toggling to nide if there's exactly one block selected + // we also dont block the canToggle since it will fall back to the bottom logic + const isPossibleToTurnInto = + selectionWithinConvertibleTypes(editor, [ + "paragraph", + "heading", + "bulletList", + "orderedList", + "taskList", + "blockquote", + "codeBlock", + ]) && blocks.length === 1 + + // No selection, find the the cursor position + if ( + (state.selection.empty || state.selection instanceof TextSelection) && + isPossibleToTurnInto + ) { + const pos = findNodePosition({ + editor, + node: state.selection.$anchor.node(1), + })?.pos + if (!isValidPosition(pos)) return false + + tr = tr.setSelection(NodeSelection.create(state.doc, pos)) + view.dispatch(tr) + state = view.state + } + + const selection = state.selection + let chain = editor.chain().focus() + + // Handle NodeSelection + if (selection instanceof NodeSelection) { + const firstChild = selection.node.firstChild?.firstChild + const lastChild = selection.node.lastChild?.lastChild + + const from = firstChild + ? selection.from + firstChild.nodeSize + : selection.from + 1 + + const to = lastChild + ? selection.to - lastChild.nodeSize + : selection.to - 1 + + const resolvedFrom = state.doc.resolve(from) + const resolvedTo = state.doc.resolve(to) + + chain = chain + .setTextSelection(TextSelection.between(resolvedFrom, resolvedTo)) + .clearNodes() + } + + const isActive = levels.some((l) => + editor.isActive("heading", { level: l }) + ) + + const toggle = isActive + ? chain.setNode("paragraph") + : chain.setNode("heading", { level: toggleLevel }) + + toggle.run() + + editor.chain().focus().selectTextblockEnd().run() + + return true + } catch { + return false + } +} + +/** + * Determines if the heading button should be shown + */ +export function shouldShowButton(props: { + editor: Editor | null + level?: Level | Level[] + hideWhenUnavailable: boolean +}): boolean { + const { editor, level, hideWhenUnavailable } = props + + if (!editor) return false + + if (!hideWhenUnavailable) { + return true + } + + if (!editor.isEditable) return false + + if (!isNodeInSchema("heading", editor)) return false + + if (!editor.isActive("code")) { + if (Array.isArray(level)) { + return level.some((l) => canToggle(editor, l)) + } + return canToggle(editor, level) + } + + return true +} + +/** + * Custom hook that provides heading functionality for Tiptap editor + * + * @example + * ```tsx + * // Simple usage + * function MySimpleHeadingButton() { + * const { isVisible, isActive, handleToggle, Icon } = useHeading({ level: 1 }) + * + * if (!isVisible) return null + * + * return ( + * + * ) + * } + * + * // Advanced usage with configuration + * function MyAdvancedHeadingButton() { + * const { isVisible, isActive, handleToggle, label, Icon } = useHeading({ + * level: 2, + * editor: myEditor, + * hideWhenUnavailable: true, + * onToggled: (isActive) => console.log('Heading toggled:', isActive) + * }) + * + * if (!isVisible) return null + * + * return ( + * + * + * Toggle Heading 2 + * + * ) + * } + * ``` + */ +export function useHeading(config: UseHeadingConfig) { + const { + editor: providedEditor, + level, + hideWhenUnavailable = false, + onToggled, + } = config + + const { editor } = useTiptapEditor(providedEditor) + const [isVisible, setIsVisible] = useState(true) + const canToggleState = canToggle(editor, level) + const isActive = isHeadingActive(editor, level) + + useEffect(() => { + if (!editor) return + + const handleSelectionUpdate = () => { + setIsVisible(shouldShowButton({ editor, level, hideWhenUnavailable })) + } + + handleSelectionUpdate() + + editor.on("selectionUpdate", handleSelectionUpdate) + + return () => { + editor.off("selectionUpdate", handleSelectionUpdate) + } + }, [editor, level, hideWhenUnavailable]) + + const handleToggle = useCallback(() => { + if (!editor) return false + + const success = toggleHeading(editor, level) + if (success) { + onToggled?.() + } + return success + }, [editor, level, onToggled]) + + return { + isVisible, + isActive, + handleToggle, + canToggle: canToggleState, + label: `Heading ${level}`, + shortcutKeys: HEADING_SHORTCUT_KEYS[level], + Icon: headingIcons[level], + } +} diff --git a/resources/js/components/tiptap-ui/heading-dropdown-menu/heading-dropdown-menu.tsx b/resources/js/components/tiptap-ui/heading-dropdown-menu/heading-dropdown-menu.tsx new file mode 100644 index 0000000..8a43836 --- /dev/null +++ b/resources/js/components/tiptap-ui/heading-dropdown-menu/heading-dropdown-menu.tsx @@ -0,0 +1,128 @@ +import { forwardRef, useCallback, useState } from "react" + +// --- Icons --- +import { ChevronDownIcon } from "@/components/tiptap-icons/chevron-down-icon" + +// --- Hooks --- +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" + +// --- Tiptap UI --- +import { HeadingButton } from "@/components/tiptap-ui/heading-button" +import type { UseHeadingDropdownMenuConfig } from "@/components/tiptap-ui/heading-dropdown-menu" +import { useHeadingDropdownMenu } from "@/components/tiptap-ui/heading-dropdown-menu" + +// --- UI Primitives --- +import type { ButtonProps } from "@/components/tiptap-ui-primitive/button" +import { Button } from "@/components/tiptap-ui-primitive/button" +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuGroup, +} from "@/components/tiptap-ui-primitive/dropdown-menu" + +export interface HeadingDropdownMenuProps + extends Omit, UseHeadingDropdownMenuConfig { + /** + * Callback for when the dropdown opens or closes + */ + onOpenChange?: (isOpen: boolean) => void + /** + * Whether the dropdown should use a modal + */ + modal?: boolean +} + +/** + * Dropdown menu component for selecting heading levels in a Tiptap editor. + * + * For custom dropdown implementations, use the `useHeadingDropdownMenu` hook instead. + */ +export const HeadingDropdownMenu = forwardRef< + HTMLButtonElement, + HeadingDropdownMenuProps +>( + ( + { + editor: providedEditor, + levels = [1, 2, 3, 4, 5, 6], + hideWhenUnavailable = false, + onOpenChange, + children, + modal = true, + ...buttonProps + }, + ref + ) => { + const { editor } = useTiptapEditor(providedEditor) + const [isOpen, setIsOpen] = useState(false) + const { isVisible, isActive, canToggle, Icon } = useHeadingDropdownMenu({ + editor, + levels, + hideWhenUnavailable, + }) + + const handleOpenChange = useCallback( + (open: boolean) => { + if (!editor || !canToggle) return + setIsOpen(open) + onOpenChange?.(open) + }, + [canToggle, editor, onOpenChange] + ) + + if (!isVisible) { + return null + } + + return ( + + + + + + + + {levels.map((level) => ( + + + + ))} + + + + ) + } +) + +HeadingDropdownMenu.displayName = "HeadingDropdownMenu" + +export default HeadingDropdownMenu diff --git a/resources/js/components/tiptap-ui/heading-dropdown-menu/index.tsx b/resources/js/components/tiptap-ui/heading-dropdown-menu/index.tsx new file mode 100644 index 0000000..33b9679 --- /dev/null +++ b/resources/js/components/tiptap-ui/heading-dropdown-menu/index.tsx @@ -0,0 +1,2 @@ +export * from "./heading-dropdown-menu" +export * from "./use-heading-dropdown-menu" diff --git a/resources/js/components/tiptap-ui/heading-dropdown-menu/use-heading-dropdown-menu.ts b/resources/js/components/tiptap-ui/heading-dropdown-menu/use-heading-dropdown-menu.ts new file mode 100644 index 0000000..b25ce63 --- /dev/null +++ b/resources/js/components/tiptap-ui/heading-dropdown-menu/use-heading-dropdown-menu.ts @@ -0,0 +1,132 @@ +"use client" + +import { useEffect, useState } from "react" +import type { Editor } from "@tiptap/react" + +// --- Hooks --- +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" + +// --- Icons --- +import { HeadingIcon } from "@/components/tiptap-icons/heading-icon" + +// --- Tiptap UI --- +import { + headingIcons, + type Level, + isHeadingActive, + canToggle, + shouldShowButton, +} from "@/components/tiptap-ui/heading-button" + +/** + * Configuration for the heading dropdown menu functionality + */ +export interface UseHeadingDropdownMenuConfig { + /** + * The Tiptap editor instance. + */ + editor?: Editor | null + /** + * Available heading levels to show in the dropdown + * @default [1, 2, 3, 4, 5, 6] + */ + levels?: Level[] + /** + * Whether the dropdown should hide when headings are not available. + * @default false + */ + hideWhenUnavailable?: boolean +} + +/** + * Gets the currently active heading level from the available levels + */ +export function getActiveHeadingLevel( + editor: Editor | null, + levels: Level[] = [1, 2, 3, 4, 5, 6] +): Level | undefined { + if (!editor || !editor.isEditable) return undefined + return levels.find((level) => isHeadingActive(editor, level)) +} + +/** + * Custom hook that provides heading dropdown menu functionality for Tiptap editor + * + * @example + * ```tsx + * // Simple usage + * function MyHeadingDropdown() { + * const { + * isVisible, + * activeLevel, + * isAnyHeadingActive, + * canToggle, + * levels, + * } = useHeadingDropdownMenu() + * + * if (!isVisible) return null + * + * return ( + * + * // dropdown content + * + * ) + * } + * + * // Advanced usage with configuration + * function MyAdvancedHeadingDropdown() { + * const { + * isVisible, + * activeLevel, + * } = useHeadingDropdownMenu({ + * editor: myEditor, + * levels: [1, 2, 3], + * hideWhenUnavailable: true, + * }) + * + * // component implementation + * } + * ``` + */ +export function useHeadingDropdownMenu(config?: UseHeadingDropdownMenuConfig) { + const { + editor: providedEditor, + levels = [1, 2, 3, 4, 5, 6], + hideWhenUnavailable = false, + } = config || {} + + const { editor } = useTiptapEditor(providedEditor) + const [isVisible, setIsVisible] = useState(true) + + const activeLevel = getActiveHeadingLevel(editor, levels) + const isActive = isHeadingActive(editor) + const canToggleState = canToggle(editor) + + useEffect(() => { + if (!editor) return + + const handleSelectionUpdate = () => { + setIsVisible( + shouldShowButton({ editor, hideWhenUnavailable, level: levels }) + ) + } + + handleSelectionUpdate() + + editor.on("selectionUpdate", handleSelectionUpdate) + + return () => { + editor.off("selectionUpdate", handleSelectionUpdate) + } + }, [editor, hideWhenUnavailable, levels]) + + return { + isVisible, + activeLevel, + isActive, + canToggle: canToggleState, + levels, + label: "Heading", + Icon: activeLevel ? headingIcons[activeLevel] : HeadingIcon, + } +} diff --git a/resources/js/components/tiptap-ui/image-upload-button/image-upload-button.tsx b/resources/js/components/tiptap-ui/image-upload-button/image-upload-button.tsx new file mode 100644 index 0000000..3ee2ef4 --- /dev/null +++ b/resources/js/components/tiptap-ui/image-upload-button/image-upload-button.tsx @@ -0,0 +1,130 @@ +import { forwardRef, useCallback } from "react" + +// --- Lib --- +import { parseShortcutKeys } from "@/lib/tiptap-utils" + +// --- Hooks --- +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" + +// --- Tiptap UI --- +import type { UseImageUploadConfig } from "@/components/tiptap-ui/image-upload-button" +import { + IMAGE_UPLOAD_SHORTCUT_KEY, + useImageUpload, +} from "@/components/tiptap-ui/image-upload-button" + +// --- UI Primitives --- +import type { ButtonProps } from "@/components/tiptap-ui-primitive/button" +import { Button } from "@/components/tiptap-ui-primitive/button" +import { Badge } from "@/components/tiptap-ui-primitive/badge" + +type IconProps = React.SVGProps +type IconComponent = ({ className, ...props }: IconProps) => React.ReactElement + +export interface ImageUploadButtonProps + extends Omit, UseImageUploadConfig { + /** + * Optional text to display alongside the icon. + */ + text?: string + /** + * Optional show shortcut keys in the button. + * @default false + */ + showShortcut?: boolean + /** + * Optional custom icon component to render instead of the default. + */ + icon?: React.MemoExoticComponent | React.FC +} + +export function ImageShortcutBadge({ + shortcutKeys = IMAGE_UPLOAD_SHORTCUT_KEY, +}: { + shortcutKeys?: string +}) { + return {parseShortcutKeys({ shortcutKeys })} +} + +/** + * Button component for uploading/inserting images in a Tiptap editor. + * + * For custom button implementations, use the `useImage` hook instead. + */ +export const ImageUploadButton = forwardRef< + HTMLButtonElement, + ImageUploadButtonProps +>( + ( + { + editor: providedEditor, + text, + hideWhenUnavailable = false, + onInserted, + showShortcut = false, + onClick, + icon: CustomIcon, + children, + ...buttonProps + }, + ref + ) => { + const { editor } = useTiptapEditor(providedEditor) + const { + isVisible, + canInsert, + handleImage, + label, + isActive, + shortcutKeys, + Icon, + } = useImageUpload({ + editor, + hideWhenUnavailable, + onInserted, + }) + + const handleClick = useCallback( + (event: React.MouseEvent) => { + onClick?.(event) + if (event.defaultPrevented) return + handleImage() + }, + [handleImage, onClick] + ) + + if (!isVisible) { + return null + } + + const RenderIcon = CustomIcon ?? Icon + + return ( + + ) + } +) + +ImageUploadButton.displayName = "ImageUploadButton" diff --git a/resources/js/components/tiptap-ui/image-upload-button/index.tsx b/resources/js/components/tiptap-ui/image-upload-button/index.tsx new file mode 100644 index 0000000..815d5bb --- /dev/null +++ b/resources/js/components/tiptap-ui/image-upload-button/index.tsx @@ -0,0 +1,2 @@ +export * from "./image-upload-button" +export * from "./use-image-upload" diff --git a/resources/js/components/tiptap-ui/image-upload-button/use-image-upload.ts b/resources/js/components/tiptap-ui/image-upload-button/use-image-upload.ts new file mode 100644 index 0000000..f956ab1 --- /dev/null +++ b/resources/js/components/tiptap-ui/image-upload-button/use-image-upload.ts @@ -0,0 +1,197 @@ +"use client" + +import { useCallback, useEffect, useState } from "react" +import { useHotkeys } from "react-hotkeys-hook" +import { type Editor } from "@tiptap/react" + +// --- Hooks --- +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" +import { useIsBreakpoint } from "@/hooks/use-is-breakpoint" + +// --- Lib --- +import { isExtensionAvailable } from "@/lib/tiptap-utils" + +// --- Icons --- +import { ImagePlusIcon } from "@/components/tiptap-icons/image-plus-icon" + +export const IMAGE_UPLOAD_SHORTCUT_KEY = "mod+shift+i" + +/** + * Configuration for the image upload functionality + */ +export interface UseImageUploadConfig { + /** + * The Tiptap editor instance. + */ + editor?: Editor | null + /** + * Whether the button should hide when insertion is not available. + * @default false + */ + hideWhenUnavailable?: boolean + /** + * Callback function called after a successful image insertion. + */ + onInserted?: () => void +} + +/** + * Checks if image can be inserted in the current editor state + */ +export function canInsertImage(editor: Editor | null): boolean { + if (!editor || !editor.isEditable) return false + if (!isExtensionAvailable(editor, "imageUpload")) return false + + return editor.can().insertContent({ type: "imageUpload" }) +} + +/** + * Checks if image is currently active + */ +export function isImageActive(editor: Editor | null): boolean { + if (!editor || !editor.isEditable) return false + return editor.isActive("imageUpload") +} + +/** + * Inserts an image in the editor + */ +export function insertImage(editor: Editor | null): boolean { + if (!editor || !editor.isEditable) return false + if (!canInsertImage(editor)) return false + + try { + return editor + .chain() + .focus() + .insertContent({ + type: "imageUpload", + }) + .run() + } catch { + return false + } +} + +/** + * Determines if the image button should be shown + */ +export function shouldShowButton(props: { + editor: Editor | null + hideWhenUnavailable: boolean +}): boolean { + const { editor, hideWhenUnavailable } = props + + if (!editor || !editor.isEditable) return false + + if (!hideWhenUnavailable) { + return true + } + + if (!isExtensionAvailable(editor, "imageUpload")) return false + + if (!editor.isActive("code")) { + return canInsertImage(editor) + } + + return true +} + +/** + * Custom hook that provides image functionality for Tiptap editor + * + * @example + * ```tsx + * // Simple usage - no params needed + * function MySimpleImageButton() { + * const { isVisible, handleImage } = useImage() + * + * if (!isVisible) return null + * + * return + * } + * + * // Advanced usage with configuration + * function MyAdvancedImageButton() { + * const { isVisible, handleImage, label, isActive } = useImage({ + * editor: myEditor, + * hideWhenUnavailable: true, + * onInserted: () => console.log('Image inserted!') + * }) + * + * if (!isVisible) return null + * + * return ( + * + * Add Image + * + * ) + * } + * ``` + */ +export function useImageUpload(config?: UseImageUploadConfig) { + const { + editor: providedEditor, + hideWhenUnavailable = false, + onInserted, + } = config || {} + + const { editor } = useTiptapEditor(providedEditor) + const isMobile = useIsBreakpoint() + const [isVisible, setIsVisible] = useState(true) + const canInsert = canInsertImage(editor) + const isActive = isImageActive(editor) + + useEffect(() => { + if (!editor) return + + const handleSelectionUpdate = () => { + setIsVisible(shouldShowButton({ editor, hideWhenUnavailable })) + } + + handleSelectionUpdate() + + editor.on("selectionUpdate", handleSelectionUpdate) + + return () => { + editor.off("selectionUpdate", handleSelectionUpdate) + } + }, [editor, hideWhenUnavailable]) + + const handleImage = useCallback(() => { + if (!editor) return false + + const success = insertImage(editor) + if (success) { + onInserted?.() + } + return success + }, [editor, onInserted]) + + useHotkeys( + IMAGE_UPLOAD_SHORTCUT_KEY, + (event) => { + event.preventDefault() + handleImage() + }, + { + enabled: isVisible && canInsert, + enableOnContentEditable: !isMobile, + enableOnFormTags: true, + } + ) + + return { + isVisible, + isActive, + handleImage, + canInsert, + label: "Add image", + shortcutKeys: IMAGE_UPLOAD_SHORTCUT_KEY, + Icon: ImagePlusIcon, + } +} diff --git a/resources/js/components/tiptap-ui/link-popover/index.tsx b/resources/js/components/tiptap-ui/link-popover/index.tsx new file mode 100644 index 0000000..e725ea8 --- /dev/null +++ b/resources/js/components/tiptap-ui/link-popover/index.tsx @@ -0,0 +1,2 @@ +export * from "./link-popover" +export * from "./use-link-popover" diff --git a/resources/js/components/tiptap-ui/link-popover/link-popover.scss b/resources/js/components/tiptap-ui/link-popover/link-popover.scss new file mode 100644 index 0000000..9c00136 --- /dev/null +++ b/resources/js/components/tiptap-ui/link-popover/link-popover.scss @@ -0,0 +1,16 @@ +.tiptap-link-input { + font-size: 0.875rem; + border: none; + + /* Clamp the input */ + min-width: 12rem; + padding-right: 0; + + text-overflow: ellipsis; + white-space: nowrap; + + &:focus { + text-overflow: clip; + overflow: visible; + } +} diff --git a/resources/js/components/tiptap-ui/link-popover/link-popover.tsx b/resources/js/components/tiptap-ui/link-popover/link-popover.tsx new file mode 100644 index 0000000..65d0233 --- /dev/null +++ b/resources/js/components/tiptap-ui/link-popover/link-popover.tsx @@ -0,0 +1,312 @@ +"use client" + +import { forwardRef, useCallback, useEffect, useState } from "react" +import type { Editor } from "@tiptap/react" + +// --- Hooks --- +import { useIsBreakpoint } from "@/hooks/use-is-breakpoint" +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" + +// --- Icons --- +import { CornerDownLeftIcon } from "@/components/tiptap-icons/corner-down-left-icon" +import { ExternalLinkIcon } from "@/components/tiptap-icons/external-link-icon" +import { LinkIcon } from "@/components/tiptap-icons/link-icon" +import { TrashIcon } from "@/components/tiptap-icons/trash-icon" + +// --- Tiptap UI --- +import type { UseLinkPopoverConfig } from "@/components/tiptap-ui/link-popover" +import { useLinkPopover } from "@/components/tiptap-ui/link-popover" + +// --- UI Primitives --- +import type { ButtonProps } from "@/components/tiptap-ui-primitive/button" +import { Button } from "@/components/tiptap-ui-primitive/button" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/tiptap-ui-primitive/popover" +import { Separator } from "@/components/tiptap-ui-primitive/separator" +import { + Card, + CardBody, + CardItemGroup, +} from "@/components/tiptap-ui-primitive/card" +import { Input } from "@/components/tiptap-ui-primitive/input" +import { ButtonGroup } from "@/components/tiptap-ui-primitive/button-group" + +import "./link-popover.scss" + +export interface LinkMainProps { + /** + * The URL to set for the link. + */ + url: string + /** + * Function to update the URL state. + */ + setUrl: React.Dispatch> + /** + * Function to set the link in the editor. + */ + setLink: () => void + /** + * Function to remove the link from the editor. + */ + removeLink: () => void + /** + * Function to open the link. + */ + openLink: () => void + /** + * Whether the link is currently active in the editor. + */ + isActive: boolean +} + +export interface LinkPopoverProps + extends Omit, UseLinkPopoverConfig { + /** + * Callback for when the popover opens or closes. + */ + onOpenChange?: (isOpen: boolean) => void + /** + * Whether to automatically open the popover when a link is active. + * @default true + */ + autoOpenOnLinkActive?: boolean +} + +/** + * Link button component for triggering the link popover + */ +export const LinkButton = forwardRef( + ({ className, children, ...props }, ref) => { + return ( + + ) + } +) + +LinkButton.displayName = "LinkButton" + +/** + * Main content component for the link popover + */ +const LinkMain: React.FC = ({ + url, + setUrl, + setLink, + removeLink, + openLink, + isActive, +}) => { + const isMobile = useIsBreakpoint() + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Enter") { + event.preventDefault() + setLink() + } + } + + return ( + + + + setUrl(e.target.value)} + onKeyDown={handleKeyDown} + autoFocus + autoComplete="off" + autoCorrect="off" + autoCapitalize="off" + className="tiptap-link-input" + /> + + + + + + + + + + + + + + + + + + + + ) +} + +/** + * Link content component for standalone use + */ +export const LinkContent: React.FC<{ + editor?: Editor | null +}> = ({ editor }) => { + const linkPopover = useLinkPopover({ + editor, + }) + + return +} + +/** + * Link popover component for Tiptap editors. + * + * For custom popover implementations, use the `useLinkPopover` hook instead. + */ +export const LinkPopover = forwardRef( + ( + { + editor: providedEditor, + hideWhenUnavailable = false, + onSetLink, + onOpenChange, + autoOpenOnLinkActive = true, + onClick, + children, + ...buttonProps + }, + ref + ) => { + const { editor } = useTiptapEditor(providedEditor) + const [isOpen, setIsOpen] = useState(false) + + const { + isVisible, + canSet, + isActive, + url, + setUrl, + setLink, + removeLink, + openLink, + label, + Icon, + } = useLinkPopover({ + editor, + hideWhenUnavailable, + onSetLink, + }) + + const handleOnOpenChange = useCallback( + (nextIsOpen: boolean) => { + setIsOpen(nextIsOpen) + onOpenChange?.(nextIsOpen) + }, + [onOpenChange] + ) + + const handleSetLink = useCallback(() => { + setLink() + setIsOpen(false) + }, [setLink]) + + const handleClick = useCallback( + (event: React.MouseEvent) => { + onClick?.(event) + if (event.defaultPrevented) return + setIsOpen(!isOpen) + }, + [onClick, isOpen] + ) + + useEffect(() => { + if (autoOpenOnLinkActive && isActive) { + setIsOpen(true) + } + }, [autoOpenOnLinkActive, isActive]) + + if (!isVisible) { + return null + } + + return ( + + + + {children ?? } + + + + + + + + ) + } +) + +LinkPopover.displayName = "LinkPopover" + +export default LinkPopover diff --git a/resources/js/components/tiptap-ui/link-popover/use-link-popover.ts b/resources/js/components/tiptap-ui/link-popover/use-link-popover.ts new file mode 100644 index 0000000..31aef60 --- /dev/null +++ b/resources/js/components/tiptap-ui/link-popover/use-link-popover.ts @@ -0,0 +1,297 @@ +import { useCallback, useEffect, useState } from "react" +import type { Editor } from "@tiptap/react" + +// --- Hooks --- +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" + +// --- Icons --- +import { LinkIcon } from "@/components/tiptap-icons/link-icon" + +// --- Lib --- +import { + isMarkInSchema, + isNodeTypeSelected, + sanitizeUrl, +} from "@/lib/tiptap-utils" + +/** + * Configuration for the link popover functionality + */ +export interface UseLinkPopoverConfig { + /** + * The Tiptap editor instance. + */ + editor?: Editor | null + /** + * Whether to hide the link popover when not available. + * @default false + */ + hideWhenUnavailable?: boolean + /** + * Callback function called when the link is set. + */ + onSetLink?: () => void +} + +/** + * Configuration for the link handler functionality + */ +export interface LinkHandlerProps { + /** + * The Tiptap editor instance. + */ + editor: Editor | null + /** + * Callback function called when the link is set. + */ + onSetLink?: () => void +} + +/** + * Checks if a link can be set in the current editor state + */ +export function canSetLink(editor: Editor | null): boolean { + if (!editor || !editor.isEditable) return false + + // The third argument 'true' checks whether the current selection is inside an image caption, and prevents setting a link there + // If the selection is inside an image caption, we can't set a link + if (isNodeTypeSelected(editor, ["image"], true)) return false + try { + return editor.can().setMark("link") + } catch { + return false + } +} + +/** + * Checks if a link is currently active in the editor + */ +export function isLinkActive(editor: Editor | null): boolean { + if (!editor || !editor.isEditable) return false + return editor.isActive("link") +} + +/** + * Determines if the link button should be shown + */ +export function shouldShowLinkButton(props: { + editor: Editor | null + hideWhenUnavailable: boolean +}): boolean { + const { editor, hideWhenUnavailable } = props + + if (!editor || !editor.isEditable) return false + + const linkInSchema = isMarkInSchema("link", editor) + + // If hideWhenUnavailable is false, always show the button (even if disabled) + if (!hideWhenUnavailable) { + return true + } + + // hideWhenUnavailable is true: hide if link is not in schema + if (!linkInSchema) { + return false + } + + // hideWhenUnavailable is true: hide if we can't set a link (unless in code block) + if (!editor.isActive("code")) { + return canSetLink(editor) + } + + return true +} + +/** + * Custom hook for handling link operations in a Tiptap editor + */ +export function useLinkHandler(props: LinkHandlerProps) { + const { editor, onSetLink } = props + const [url, setUrl] = useState(null) + + useEffect(() => { + if (!editor) return + + // Get URL immediately on mount + const { href } = editor.getAttributes("link") + + if (isLinkActive(editor) && url === null) { + setUrl(href || "") + } + }, [editor, url]) + + useEffect(() => { + if (!editor) return + + const updateLinkState = () => { + const { href } = editor.getAttributes("link") + setUrl(href || "") + } + + editor.on("selectionUpdate", updateLinkState) + return () => { + editor.off("selectionUpdate", updateLinkState) + } + }, [editor]) + + const setLink = useCallback(() => { + if (!url || !editor) return + + const { selection } = editor.state + const isEmpty = selection.empty + + let chain = editor.chain().focus() + + chain = chain.extendMarkRange("link").setLink({ href: url }) + + if (isEmpty) { + chain = chain.insertContent({ type: "text", text: url }) + } + + chain.run() + + setUrl(null) + + onSetLink?.() + }, [editor, onSetLink, url]) + + const removeLink = useCallback(() => { + if (!editor) return + editor + .chain() + .focus() + .extendMarkRange("link") + .unsetLink() + .setMeta("preventAutolink", true) + .run() + setUrl("") + }, [editor]) + + const openLink = useCallback( + (target: string = "_blank", features: string = "noopener,noreferrer") => { + if (!url) return + + const safeUrl = sanitizeUrl(url, window.location.href) + if (safeUrl !== "#") { + window.open(safeUrl, target, features) + } + }, + [url] + ) + + return { + url: url || "", + setUrl, + setLink, + removeLink, + openLink, + } +} + +/** + * Custom hook for link popover state management + */ +export function useLinkState(props: { + editor: Editor | null + hideWhenUnavailable: boolean +}) { + const { editor, hideWhenUnavailable = false } = props + + const canSet = canSetLink(editor) + const isActive = isLinkActive(editor) + + const [isVisible, setIsVisible] = useState(true) + + useEffect(() => { + if (!editor) return + + const handleSelectionUpdate = () => { + setIsVisible( + shouldShowLinkButton({ + editor, + hideWhenUnavailable, + }) + ) + } + + handleSelectionUpdate() + + editor.on("selectionUpdate", handleSelectionUpdate) + + return () => { + editor.off("selectionUpdate", handleSelectionUpdate) + } + }, [editor, hideWhenUnavailable]) + + return { + isVisible, + canSet, + isActive, + } +} + +/** + * Main hook that provides link popover functionality for Tiptap editor + * + * @example + * ```tsx + * // Simple usage + * function MyLinkButton() { + * const { isVisible, canSet, isActive, Icon, label } = useLinkPopover() + * + * if (!isVisible) return null + * + * return + * } + * + * // Advanced usage with configuration + * function MyAdvancedLinkButton() { + * const { isVisible, canSet, isActive, Icon, label } = useLinkPopover({ + * editor: myEditor, + * hideWhenUnavailable: true, + * onSetLink: () => console.log('Link set!') + * }) + * + * if (!isVisible) return null + * + * return ( + * + * + * {label} + * + * ) + * } + * ``` + */ +export function useLinkPopover(config?: UseLinkPopoverConfig) { + const { + editor: providedEditor, + hideWhenUnavailable = false, + onSetLink, + } = config || {} + + const { editor } = useTiptapEditor(providedEditor) + + const { isVisible, canSet, isActive } = useLinkState({ + editor, + hideWhenUnavailable, + }) + + const linkHandler = useLinkHandler({ + editor, + onSetLink, + }) + + return { + isVisible, + canSet, + isActive, + label: "Link", + Icon: LinkIcon, + ...linkHandler, + } +} diff --git a/resources/js/components/tiptap-ui/list-button/index.tsx b/resources/js/components/tiptap-ui/list-button/index.tsx new file mode 100644 index 0000000..9f3d066 --- /dev/null +++ b/resources/js/components/tiptap-ui/list-button/index.tsx @@ -0,0 +1,2 @@ +export * from "./list-button" +export * from "./use-list" diff --git a/resources/js/components/tiptap-ui/list-button/list-button.tsx b/resources/js/components/tiptap-ui/list-button/list-button.tsx new file mode 100644 index 0000000..13a3909 --- /dev/null +++ b/resources/js/components/tiptap-ui/list-button/list-button.tsx @@ -0,0 +1,120 @@ +import { forwardRef, useCallback } from "react" + +// --- Lib --- +import { parseShortcutKeys } from "@/lib/tiptap-utils" + +// --- Hooks --- +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" + +// --- UI Primitives --- +import type { ButtonProps } from "@/components/tiptap-ui-primitive/button" +import { Button } from "@/components/tiptap-ui-primitive/button" +import { Badge } from "@/components/tiptap-ui-primitive/badge" + +// --- Tiptap UI --- +import type { ListType, UseListConfig } from "@/components/tiptap-ui/list-button" +import { LIST_SHORTCUT_KEYS, useList } from "@/components/tiptap-ui/list-button" + +export interface ListButtonProps + extends Omit, UseListConfig { + /** + * Optional text to display alongside the icon. + */ + text?: string + /** + * Optional show shortcut keys in the button. + * @default false + */ + showShortcut?: boolean +} + +export function ListShortcutBadge({ + type, + shortcutKeys = LIST_SHORTCUT_KEYS[type], +}: { + type: ListType + shortcutKeys?: string +}) { + return {parseShortcutKeys({ shortcutKeys })} +} + +/** + * Button component for toggling lists in a Tiptap editor. + * + * For custom button implementations, use the `useList` hook instead. + */ +export const ListButton = forwardRef( + ( + { + editor: providedEditor, + type, + text, + hideWhenUnavailable = false, + onToggled, + showShortcut = false, + onClick, + children, + ...buttonProps + }, + ref + ) => { + const { editor } = useTiptapEditor(providedEditor) + const { + isVisible, + canToggle, + isActive, + handleToggle, + label, + shortcutKeys, + Icon, + } = useList({ + editor, + type, + hideWhenUnavailable, + onToggled, + }) + + const handleClick = useCallback( + (event: React.MouseEvent) => { + onClick?.(event) + if (event.defaultPrevented) return + handleToggle() + }, + [handleToggle, onClick] + ) + + if (!isVisible) { + return null + } + + return ( + + ) + } +) + +ListButton.displayName = "ListButton" diff --git a/resources/js/components/tiptap-ui/list-button/use-list.ts b/resources/js/components/tiptap-ui/list-button/use-list.ts new file mode 100644 index 0000000..b07f440 --- /dev/null +++ b/resources/js/components/tiptap-ui/list-button/use-list.ts @@ -0,0 +1,353 @@ +"use client" + +import { useCallback, useEffect, useState } from "react" +import { type Editor } from "@tiptap/react" +import { NodeSelection, TextSelection } from "@tiptap/pm/state" + +// --- Hooks --- +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" + +// --- Icons --- +import { ListIcon } from "@/components/tiptap-icons/list-icon" +import { ListOrderedIcon } from "@/components/tiptap-icons/list-ordered-icon" +import { ListTodoIcon } from "@/components/tiptap-icons/list-todo-icon" + +// --- Lib --- +import { + findNodePosition, + getSelectedBlockNodes, + isNodeInSchema, + isNodeTypeSelected, + isValidPosition, + selectionWithinConvertibleTypes, +} from "@/lib/tiptap-utils" + +export type ListType = "bulletList" | "orderedList" | "taskList" + +/** + * Configuration for the list functionality + */ +export interface UseListConfig { + /** + * The Tiptap editor instance. + */ + editor?: Editor | null + /** + * The type of list to toggle. + */ + type: ListType + /** + * Whether the button should hide when list is not available. + * @default false + */ + hideWhenUnavailable?: boolean + /** + * Callback function called after a successful toggle. + */ + onToggled?: () => void +} + +export const listIcons = { + bulletList: ListIcon, + orderedList: ListOrderedIcon, + taskList: ListTodoIcon, +} + +export const listLabels: Record = { + bulletList: "Bullet List", + orderedList: "Ordered List", + taskList: "Task List", +} + +export const LIST_SHORTCUT_KEYS: Record = { + bulletList: "mod+shift+8", + orderedList: "mod+shift+7", + taskList: "mod+shift+9", +} + +/** + * Checks if a list can be toggled in the current editor state + */ +export function canToggleList( + editor: Editor | null, + type: ListType, + turnInto: boolean = true +): boolean { + if (!editor || !editor.isEditable) return false + if (!isNodeInSchema(type, editor) || isNodeTypeSelected(editor, ["image"])) + return false + + if (!turnInto) { + switch (type) { + case "bulletList": + return editor.can().toggleBulletList() + case "orderedList": + return editor.can().toggleOrderedList() + case "taskList": + return editor.can().toggleList("taskList", "taskItem") + default: + return false + } + } + + // Ensure selection is in nodes we're allowed to convert + if ( + !selectionWithinConvertibleTypes(editor, [ + "paragraph", + "heading", + "bulletList", + "orderedList", + "taskList", + "blockquote", + "codeBlock", + ]) + ) + return false + + // Either we can set list directly on the selection, + // or we can clear formatting/nodes to arrive at a list. + switch (type) { + case "bulletList": + return editor.can().toggleBulletList() || editor.can().clearNodes() + case "orderedList": + return editor.can().toggleOrderedList() || editor.can().clearNodes() + case "taskList": + return ( + editor.can().toggleList("taskList", "taskItem") || + editor.can().clearNodes() + ) + default: + return false + } +} + +/** + * Checks if list is currently active + */ +export function isListActive(editor: Editor | null, type: ListType): boolean { + if (!editor || !editor.isEditable) return false + + switch (type) { + case "bulletList": + return editor.isActive("bulletList") + case "orderedList": + return editor.isActive("orderedList") + case "taskList": + return editor.isActive("taskList") + default: + return false + } +} + +/** + * Toggles list in the editor + */ +export function toggleList(editor: Editor | null, type: ListType): boolean { + if (!editor || !editor.isEditable) return false + if (!canToggleList(editor, type)) return false + + try { + const view = editor.view + let state = view.state + let tr = state.tr + + const blocks = getSelectedBlockNodes(editor) + + // In case a selection contains multiple blocks, we only allow + // toggling to nide if there's exactly one block selected + // we also dont block the canToggle since it will fall back to the bottom logic + const isPossibleToTurnInto = + selectionWithinConvertibleTypes(editor, [ + "paragraph", + "heading", + "bulletList", + "orderedList", + "taskList", + "blockquote", + "codeBlock", + ]) && blocks.length === 1 + + // No selection, find the the cursor position + if ( + (state.selection.empty || state.selection instanceof TextSelection) && + isPossibleToTurnInto + ) { + const pos = findNodePosition({ + editor, + node: state.selection.$anchor.node(1), + })?.pos + if (!isValidPosition(pos)) return false + + tr = tr.setSelection(NodeSelection.create(state.doc, pos)) + view.dispatch(tr) + state = view.state + } + + const selection = state.selection + + let chain = editor.chain().focus() + + // Handle NodeSelection + if (selection instanceof NodeSelection) { + const firstChild = selection.node.firstChild?.firstChild + const lastChild = selection.node.lastChild?.lastChild + + const from = firstChild + ? selection.from + firstChild.nodeSize + : selection.from + 1 + + const to = lastChild + ? selection.to - lastChild.nodeSize + : selection.to - 1 + + const resolvedFrom = state.doc.resolve(from) + const resolvedTo = state.doc.resolve(to) + + chain = chain + .setTextSelection(TextSelection.between(resolvedFrom, resolvedTo)) + .clearNodes() + } + + if (editor.isActive(type)) { + // Unwrap list + chain + .liftListItem("listItem") + .lift("bulletList") + .lift("orderedList") + .lift("taskList") + .run() + } else { + // Wrap in specific list type + const toggleMap: Record typeof chain> = { + bulletList: () => chain.toggleBulletList(), + orderedList: () => chain.toggleOrderedList(), + taskList: () => chain.toggleList("taskList", "taskItem"), + } + + const toggle = toggleMap[type] + if (!toggle) return false + + toggle().run() + } + + editor.chain().focus().selectTextblockEnd().run() + + return true + } catch { + return false + } +} + +/** + * Determines if the list button should be shown + */ +export function shouldShowButton(props: { + editor: Editor | null + type: ListType + hideWhenUnavailable: boolean +}): boolean { + const { editor, type, hideWhenUnavailable } = props + + if (!editor) return false + + if (!hideWhenUnavailable) { + return true + } + + if (!editor.isEditable) return false + + if (!isNodeInSchema(type, editor)) return false + + if (!editor.isActive("code")) { + return canToggleList(editor, type) + } + + return true +} + +/** + * Custom hook that provides list functionality for Tiptap editor + * + * @example + * ```tsx + * // Simple usage + * function MySimpleListButton() { + * const { isVisible, handleToggle, isActive } = useList({ type: "bulletList" }) + * + * if (!isVisible) return null + * + * return + * } + * + * // Advanced usage with configuration + * function MyAdvancedListButton() { + * const { isVisible, handleToggle, label, isActive } = useList({ + * type: "orderedList", + * editor: myEditor, + * hideWhenUnavailable: true, + * onToggled: () => console.log('List toggled!') + * }) + * + * if (!isVisible) return null + * + * return ( + * + * Toggle List + * + * ) + * } + * ``` + */ +export function useList(config: UseListConfig) { + const { + editor: providedEditor, + type, + hideWhenUnavailable = false, + onToggled, + } = config + + const { editor } = useTiptapEditor(providedEditor) + const [isVisible, setIsVisible] = useState(true) + const canToggle = canToggleList(editor, type) + const isActive = isListActive(editor, type) + + useEffect(() => { + if (!editor) return + + const handleSelectionUpdate = () => { + setIsVisible(shouldShowButton({ editor, type, hideWhenUnavailable })) + } + + handleSelectionUpdate() + + editor.on("selectionUpdate", handleSelectionUpdate) + + return () => { + editor.off("selectionUpdate", handleSelectionUpdate) + } + }, [editor, type, hideWhenUnavailable]) + + const handleToggle = useCallback(() => { + if (!editor) return false + + const success = toggleList(editor, type) + if (success) { + onToggled?.() + } + return success + }, [editor, type, onToggled]) + + return { + isVisible, + isActive, + handleToggle, + canToggle, + label: listLabels[type], + shortcutKeys: LIST_SHORTCUT_KEYS[type], + Icon: listIcons[type], + } +} diff --git a/resources/js/components/tiptap-ui/list-dropdown-menu/index.tsx b/resources/js/components/tiptap-ui/list-dropdown-menu/index.tsx new file mode 100644 index 0000000..9a215b8 --- /dev/null +++ b/resources/js/components/tiptap-ui/list-dropdown-menu/index.tsx @@ -0,0 +1 @@ +export * from "./list-dropdown-menu" diff --git a/resources/js/components/tiptap-ui/list-dropdown-menu/list-dropdown-menu.tsx b/resources/js/components/tiptap-ui/list-dropdown-menu/list-dropdown-menu.tsx new file mode 100644 index 0000000..0c0dc5e --- /dev/null +++ b/resources/js/components/tiptap-ui/list-dropdown-menu/list-dropdown-menu.tsx @@ -0,0 +1,118 @@ +import { useCallback, useState } from "react" +import { type Editor } from "@tiptap/react" + +// --- Hooks --- +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" + +// --- Icons --- +import { ChevronDownIcon } from "@/components/tiptap-icons/chevron-down-icon" + +// --- Tiptap UI --- +import { ListButton, type ListType } from "@/components/tiptap-ui/list-button" + +import { useListDropdownMenu } from "@/components/tiptap-ui/list-dropdown-menu/use-list-dropdown-menu" + +// --- UI Primitives --- +import type { ButtonProps } from "@/components/tiptap-ui-primitive/button" +import { Button } from "@/components/tiptap-ui-primitive/button" +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuGroup, +} from "@/components/tiptap-ui-primitive/dropdown-menu" + +export interface ListDropdownMenuProps extends Omit { + /** + * The Tiptap editor instance. + */ + editor?: Editor + /** + * The list types to display in the dropdown. + */ + types?: ListType[] + /** + * Whether the dropdown should be hidden when no list types are available + * @default false + */ + hideWhenUnavailable?: boolean + /** + * Callback for when the dropdown opens or closes + */ + onOpenChange?: (isOpen: boolean) => void + /** + * Whether the dropdown should use a modal + */ + modal?: boolean +} + +export function ListDropdownMenu({ + editor: providedEditor, + types = ["bulletList", "orderedList", "taskList"], + hideWhenUnavailable = false, + onOpenChange, + modal = true, + ...props +}: ListDropdownMenuProps) { + const { editor } = useTiptapEditor(providedEditor) + const [isOpen, setIsOpen] = useState(false) + + const { filteredLists, canToggle, isActive, isVisible, Icon } = + useListDropdownMenu({ + editor, + types, + hideWhenUnavailable, + }) + + const handleOnOpenChange = useCallback( + (open: boolean) => { + setIsOpen(open) + onOpenChange?.(open) + }, + [onOpenChange] + ) + + if (!isVisible) { + return null + } + + return ( + + + + + + + + {filteredLists.map((option) => ( + + + + ))} + + + + ) +} + +export default ListDropdownMenu diff --git a/resources/js/components/tiptap-ui/list-dropdown-menu/use-list-dropdown-menu.ts b/resources/js/components/tiptap-ui/list-dropdown-menu/use-list-dropdown-menu.ts new file mode 100644 index 0000000..35299a0 --- /dev/null +++ b/resources/js/components/tiptap-ui/list-dropdown-menu/use-list-dropdown-menu.ts @@ -0,0 +1,220 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import type { Editor } from "@tiptap/react" + +// --- Hooks --- +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" + +// --- Icons --- +import { ListIcon } from "@/components/tiptap-icons/list-icon" +import { ListOrderedIcon } from "@/components/tiptap-icons/list-ordered-icon" +import { ListTodoIcon } from "@/components/tiptap-icons/list-todo-icon" + +// --- Lib --- +import { isNodeInSchema } from "@/lib/tiptap-utils" + +// --- Tiptap UI --- +import { + canToggleList, + isListActive, + listIcons, + type ListType, +} from "@/components/tiptap-ui/list-button" + +/** + * Configuration for the list dropdown menu functionality + */ +export interface UseListDropdownMenuConfig { + /** + * The Tiptap editor instance. + */ + editor?: Editor | null + /** + * The list types to display in the dropdown. + * @default ["bulletList", "orderedList", "taskList"] + */ + types?: ListType[] + /** + * Whether the dropdown should be hidden when no list types are available + * @default false + */ + hideWhenUnavailable?: boolean +} + +export interface ListOption { + label: string + type: ListType + icon: React.ElementType +} + +export const listOptions: ListOption[] = [ + { + label: "Bullet List", + type: "bulletList", + icon: ListIcon, + }, + { + label: "Ordered List", + type: "orderedList", + icon: ListOrderedIcon, + }, + { + label: "Task List", + type: "taskList", + icon: ListTodoIcon, + }, +] + +export function canToggleAnyList( + editor: Editor | null, + listTypes: ListType[] +): boolean { + if (!editor || !editor.isEditable) return false + return listTypes.some((type) => canToggleList(editor, type)) +} + +export function isAnyListActive( + editor: Editor | null, + listTypes: ListType[] +): boolean { + if (!editor || !editor.isEditable) return false + return listTypes.some((type) => isListActive(editor, type)) +} + +export function getFilteredListOptions( + availableTypes: ListType[] +): typeof listOptions { + return listOptions.filter( + (option) => !option.type || availableTypes.includes(option.type) + ) +} + +export function shouldShowListDropdown(params: { + editor: Editor | null + listTypes: ListType[] + hideWhenUnavailable: boolean + listInSchema: boolean + canToggleAny: boolean +}): boolean { + const { editor, hideWhenUnavailable, listInSchema, canToggleAny } = params + + if (!editor) return false + + if (!hideWhenUnavailable) { + return true + } + + if (!listInSchema) return false + + if (!editor.isActive("code")) { + return canToggleAny + } + + return true +} + +/** + * Gets the currently active list type from the available types + */ +export function getActiveListType( + editor: Editor | null, + availableTypes: ListType[] +): ListType | undefined { + if (!editor || !editor.isEditable) return undefined + return availableTypes.find((type) => isListActive(editor, type)) +} + +/** + * Custom hook that provides list dropdown menu functionality for Tiptap editor + * + * @example + * ```tsx + * // Simple usage + * function MyListDropdown() { + * const { + * isVisible, + * activeType, + * isAnyActive, + * canToggleAny, + * filteredLists, + * } = useListDropdownMenu() + * + * if (!isVisible) return null + * + * return ( + * + * // dropdown content + * + * ) + * } + * + * // Advanced usage with configuration + * function MyAdvancedListDropdown() { + * const { + * isVisible, + * activeType, + * } = useListDropdownMenu({ + * editor: myEditor, + * types: ["bulletList", "orderedList"], + * hideWhenUnavailable: true, + * }) + * + * // component implementation + * } + * ``` + */ +export function useListDropdownMenu(config?: UseListDropdownMenuConfig) { + const { + editor: providedEditor, + types = ["bulletList", "orderedList", "taskList"], + hideWhenUnavailable = false, + } = config || {} + + const { editor } = useTiptapEditor(providedEditor) + const [isVisible, setIsVisible] = useState(true) + + const listInSchema = types.some((type) => isNodeInSchema(type, editor)) + + const filteredLists = useMemo(() => getFilteredListOptions(types), [types]) + + const canToggleAny = canToggleAnyList(editor, types) + const isAnyActive = isAnyListActive(editor, types) + const activeType = getActiveListType(editor, types) + const activeList = filteredLists.find((option) => option.type === activeType) + + useEffect(() => { + if (!editor) return + + const handleSelectionUpdate = () => { + setIsVisible( + shouldShowListDropdown({ + editor, + listTypes: types, + hideWhenUnavailable, + listInSchema, + canToggleAny, + }) + ) + } + + handleSelectionUpdate() + + editor.on("selectionUpdate", handleSelectionUpdate) + + return () => { + editor.off("selectionUpdate", handleSelectionUpdate) + } + }, [canToggleAny, editor, hideWhenUnavailable, listInSchema, types]) + + return { + isVisible, + activeType, + isActive: isAnyActive, + canToggle: canToggleAny, + types, + filteredLists, + label: "List", + Icon: activeList ? listIcons[activeList.type] : ListIcon, + } +} diff --git a/resources/js/components/tiptap-ui/mark-button/index.tsx b/resources/js/components/tiptap-ui/mark-button/index.tsx new file mode 100644 index 0000000..32e85b9 --- /dev/null +++ b/resources/js/components/tiptap-ui/mark-button/index.tsx @@ -0,0 +1,2 @@ +export * from "./mark-button" +export * from "./use-mark" diff --git a/resources/js/components/tiptap-ui/mark-button/mark-button.tsx b/resources/js/components/tiptap-ui/mark-button/mark-button.tsx new file mode 100644 index 0000000..551b46d --- /dev/null +++ b/resources/js/components/tiptap-ui/mark-button/mark-button.tsx @@ -0,0 +1,122 @@ +"use client" + +import { forwardRef, useCallback } from "react" + +// --- Lib --- +import { parseShortcutKeys } from "@/lib/tiptap-utils" + +// --- Hooks --- +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" + +// --- Tiptap UI --- +import type { Mark, UseMarkConfig } from "@/components/tiptap-ui/mark-button" +import { MARK_SHORTCUT_KEYS, useMark } from "@/components/tiptap-ui/mark-button" + +// --- UI Primitives --- +import type { ButtonProps } from "@/components/tiptap-ui-primitive/button" +import { Button } from "@/components/tiptap-ui-primitive/button" +import { Badge } from "@/components/tiptap-ui-primitive/badge" + +export interface MarkButtonProps + extends Omit, UseMarkConfig { + /** + * Optional text to display alongside the icon. + */ + text?: string + /** + * Optional show shortcut keys in the button. + * @default false + */ + showShortcut?: boolean +} + +export function MarkShortcutBadge({ + type, + shortcutKeys = MARK_SHORTCUT_KEYS[type], +}: { + type: Mark + shortcutKeys?: string +}) { + return {parseShortcutKeys({ shortcutKeys })} +} + +/** + * Button component for toggling marks in a Tiptap editor. + * + * For custom button implementations, use the `useMark` hook instead. + */ +export const MarkButton = forwardRef( + ( + { + editor: providedEditor, + type, + text, + hideWhenUnavailable = false, + onToggled, + showShortcut = false, + onClick, + children, + ...buttonProps + }, + ref + ) => { + const { editor } = useTiptapEditor(providedEditor) + const { + isVisible, + handleMark, + label, + canToggle, + isActive, + Icon, + shortcutKeys, + } = useMark({ + editor, + type, + hideWhenUnavailable, + onToggled, + }) + + const handleClick = useCallback( + (event: React.MouseEvent) => { + onClick?.(event) + if (event.defaultPrevented) return + handleMark() + }, + [handleMark, onClick] + ) + + if (!isVisible) { + return null + } + + return ( + + ) + } +) + +MarkButton.displayName = "MarkButton" diff --git a/resources/js/components/tiptap-ui/mark-button/use-mark.ts b/resources/js/components/tiptap-ui/mark-button/use-mark.ts new file mode 100644 index 0000000..35bc7c4 --- /dev/null +++ b/resources/js/components/tiptap-ui/mark-button/use-mark.ts @@ -0,0 +1,219 @@ +import { useCallback, useEffect, useState } from "react" +import type { Editor } from "@tiptap/react" + +// --- Hooks --- +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" + +// --- Lib --- +import { isMarkInSchema, isNodeTypeSelected } from "@/lib/tiptap-utils" + +// --- Icons --- +import { BoldIcon } from "@/components/tiptap-icons/bold-icon" +import { Code2Icon } from "@/components/tiptap-icons/code2-icon" +import { ItalicIcon } from "@/components/tiptap-icons/italic-icon" +import { StrikeIcon } from "@/components/tiptap-icons/strike-icon" +import { SubscriptIcon } from "@/components/tiptap-icons/subscript-icon" +import { SuperscriptIcon } from "@/components/tiptap-icons/superscript-icon" +import { UnderlineIcon } from "@/components/tiptap-icons/underline-icon" + +export type Mark = + | "bold" + | "italic" + | "strike" + | "code" + | "underline" + | "superscript" + | "subscript" + +/** + * Configuration for the mark functionality + */ +export interface UseMarkConfig { + /** + * The Tiptap editor instance. + */ + editor?: Editor | null + /** + * The type of mark to toggle + */ + type: Mark + /** + * Whether the button should hide when mark is not available. + * @default false + */ + hideWhenUnavailable?: boolean + /** + * Callback function called after a successful mark toggle. + */ + onToggled?: () => void +} + +export const markIcons = { + bold: BoldIcon, + italic: ItalicIcon, + underline: UnderlineIcon, + strike: StrikeIcon, + code: Code2Icon, + superscript: SuperscriptIcon, + subscript: SubscriptIcon, +} + +export const MARK_SHORTCUT_KEYS: Record = { + bold: "mod+b", + italic: "mod+i", + underline: "mod+u", + strike: "mod+shift+s", + code: "mod+e", + superscript: "mod+.", + subscript: "mod+,", +} + +/** + * Checks if a mark can be toggled in the current editor state + */ +export function canToggleMark(editor: Editor | null, type: Mark): boolean { + if (!editor || !editor.isEditable) return false + if (!isMarkInSchema(type, editor) || isNodeTypeSelected(editor, ["image"])) + return false + + return editor.can().toggleMark(type) +} + +/** + * Checks if a mark is currently active + */ +export function isMarkActive(editor: Editor | null, type: Mark): boolean { + if (!editor || !editor.isEditable) return false + return editor.isActive(type) +} + +/** + * Toggles a mark in the editor + */ +export function toggleMark(editor: Editor | null, type: Mark): boolean { + if (!editor || !editor.isEditable) return false + if (!canToggleMark(editor, type)) return false + + return editor.chain().focus().toggleMark(type).run() +} + +/** + * Determines if the mark button should be shown + */ +export function shouldShowButton(props: { + editor: Editor | null + type: Mark + hideWhenUnavailable: boolean +}): boolean { + const { editor, type, hideWhenUnavailable } = props + + if (!editor) return false + + if (!hideWhenUnavailable) { + return true + } + + if (!editor.isEditable) return false + + if (!isMarkInSchema(type, editor)) return false + + if (!editor.isActive("code")) { + return canToggleMark(editor, type) + } + + return true +} + +/** + * Gets the formatted mark name + */ +export function getFormattedMarkName(type: Mark): string { + return type.charAt(0).toUpperCase() + type.slice(1) +} + +/** + * Custom hook that provides mark functionality for Tiptap editor + * + * @example + * ```tsx + * // Simple usage + * function MySimpleBoldButton() { + * const { isVisible, handleMark } = useMark({ type: "bold" }) + * + * if (!isVisible) return null + * + * return + * } + * + * // Advanced usage with configuration + * function MyAdvancedItalicButton() { + * const { isVisible, handleMark, label, isActive } = useMark({ + * editor: myEditor, + * type: "italic", + * hideWhenUnavailable: true, + * onToggled: () => console.log('Mark toggled!') + * }) + * + * if (!isVisible) return null + * + * return ( + * + * Italic + * + * ) + * } + * ``` + */ +export function useMark(config: UseMarkConfig) { + const { + editor: providedEditor, + type, + hideWhenUnavailable = false, + onToggled, + } = config + + const { editor } = useTiptapEditor(providedEditor) + const [isVisible, setIsVisible] = useState(true) + const canToggle = canToggleMark(editor, type) + const isActive = isMarkActive(editor, type) + + useEffect(() => { + if (!editor) return + + const handleSelectionUpdate = () => { + setIsVisible(shouldShowButton({ editor, type, hideWhenUnavailable })) + } + + handleSelectionUpdate() + + editor.on("selectionUpdate", handleSelectionUpdate) + + return () => { + editor.off("selectionUpdate", handleSelectionUpdate) + } + }, [editor, type, hideWhenUnavailable]) + + const handleMark = useCallback(() => { + if (!editor) return false + + const success = toggleMark(editor, type) + if (success) { + onToggled?.() + } + return success + }, [editor, type, onToggled]) + + return { + isVisible, + isActive, + handleMark, + canToggle, + label: getFormattedMarkName(type), + shortcutKeys: MARK_SHORTCUT_KEYS[type], + Icon: markIcons[type], + } +} diff --git a/resources/js/components/tiptap-ui/text-align-button/index.tsx b/resources/js/components/tiptap-ui/text-align-button/index.tsx new file mode 100644 index 0000000..d19f95c --- /dev/null +++ b/resources/js/components/tiptap-ui/text-align-button/index.tsx @@ -0,0 +1,2 @@ +export * from "./text-align-button" +export * from "./use-text-align" diff --git a/resources/js/components/tiptap-ui/text-align-button/text-align-button.tsx b/resources/js/components/tiptap-ui/text-align-button/text-align-button.tsx new file mode 100644 index 0000000..6e362c5 --- /dev/null +++ b/resources/js/components/tiptap-ui/text-align-button/text-align-button.tsx @@ -0,0 +1,144 @@ +"use client" + +import { forwardRef, useCallback } from "react" + +// --- Lib --- +import { parseShortcutKeys } from "@/lib/tiptap-utils" + +// --- Hooks --- +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" + +// --- Tiptap UI --- +import type { + TextAlign, + UseTextAlignConfig, +} from "@/components/tiptap-ui/text-align-button" +import { + TEXT_ALIGN_SHORTCUT_KEYS, + useTextAlign, +} from "@/components/tiptap-ui/text-align-button" + +// --- UI Primitives --- +import type { ButtonProps } from "@/components/tiptap-ui-primitive/button" +import { Button } from "@/components/tiptap-ui-primitive/button" +import { Badge } from "@/components/tiptap-ui-primitive/badge" + +type IconProps = React.SVGProps +type IconComponent = ({ className, ...props }: IconProps) => React.ReactElement + +export interface TextAlignButtonProps + extends Omit, UseTextAlignConfig { + /** + * Optional text to display alongside the icon. + */ + text?: string + /** + * Optional show shortcut keys in the button. + * @default false + */ + showShortcut?: boolean + /** + * Optional custom icon component to render instead of the default. + */ + icon?: React.MemoExoticComponent | React.FC +} + +export function TextAlignShortcutBadge({ + align, + shortcutKeys = TEXT_ALIGN_SHORTCUT_KEYS[align], +}: { + align: TextAlign + shortcutKeys?: string +}) { + return {parseShortcutKeys({ shortcutKeys })} +} + +/** + * Button component for setting text alignment in a Tiptap editor. + * + * For custom button implementations, use the `useTextAlign` hook instead. + */ +export const TextAlignButton = forwardRef< + HTMLButtonElement, + TextAlignButtonProps +>( + ( + { + editor: providedEditor, + align, + text, + hideWhenUnavailable = false, + onAligned, + showShortcut = false, + onClick, + icon: CustomIcon, + children, + ...buttonProps + }, + ref + ) => { + const { editor } = useTiptapEditor(providedEditor) + const { + isVisible, + handleTextAlign, + label, + canAlign, + isActive, + Icon, + shortcutKeys, + } = useTextAlign({ + editor, + align, + hideWhenUnavailable, + onAligned, + }) + + const handleClick = useCallback( + (event: React.MouseEvent) => { + onClick?.(event) + if (event.defaultPrevented) return + handleTextAlign() + }, + [handleTextAlign, onClick] + ) + + if (!isVisible) { + return null + } + + const RenderIcon = CustomIcon ?? Icon + + return ( + + ) + } +) + +TextAlignButton.displayName = "TextAlignButton" diff --git a/resources/js/components/tiptap-ui/text-align-button/use-text-align.ts b/resources/js/components/tiptap-ui/text-align-button/use-text-align.ts new file mode 100644 index 0000000..b6b229a --- /dev/null +++ b/resources/js/components/tiptap-ui/text-align-button/use-text-align.ts @@ -0,0 +1,229 @@ +import { useCallback, useEffect, useState } from "react" +import type { ChainedCommands } from "@tiptap/react" +import { type Editor } from "@tiptap/react" + +// --- Hooks --- +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" + +// --- Lib --- +import { + isExtensionAvailable, + isNodeTypeSelected, +} from "@/lib/tiptap-utils" + +// --- Icons --- +import { AlignCenterIcon } from "@/components/tiptap-icons/align-center-icon" +import { AlignJustifyIcon } from "@/components/tiptap-icons/align-justify-icon" +import { AlignLeftIcon } from "@/components/tiptap-icons/align-left-icon" +import { AlignRightIcon } from "@/components/tiptap-icons/align-right-icon" + +export type TextAlign = "left" | "center" | "right" | "justify" + +/** + * Configuration for the text align functionality + */ +export interface UseTextAlignConfig { + /** + * The Tiptap editor instance. + */ + editor?: Editor | null + /** + * The text alignment to apply. + */ + align: TextAlign + /** + * Whether the button should hide when alignment is not available. + * @default false + */ + hideWhenUnavailable?: boolean + /** + * Callback function called after a successful alignment change. + */ + onAligned?: () => void +} + +export const TEXT_ALIGN_SHORTCUT_KEYS: Record = { + left: "mod+shift+l", + center: "mod+shift+e", + right: "mod+shift+r", + justify: "mod+shift+j", +} + +export const textAlignIcons = { + left: AlignLeftIcon, + center: AlignCenterIcon, + right: AlignRightIcon, + justify: AlignJustifyIcon, +} + +export const textAlignLabels: Record = { + left: "Align left", + center: "Align center", + right: "Align right", + justify: "Align justify", +} + +/** + * Checks if text alignment can be performed in the current editor state + */ +export function canSetTextAlign( + editor: Editor | null, + align: TextAlign +): boolean { + if (!editor || !editor.isEditable) return false + if ( + !isExtensionAvailable(editor, "textAlign") || + isNodeTypeSelected(editor, ["image", "horizontalRule"]) + ) + return false + + return editor.can().setTextAlign(align) +} + +export function hasSetTextAlign( + commands: ChainedCommands +): commands is ChainedCommands & { + setTextAlign: (align: TextAlign) => ChainedCommands +} { + return "setTextAlign" in commands +} + +/** + * Checks if the text alignment is currently active + */ +export function isTextAlignActive( + editor: Editor | null, + align: TextAlign +): boolean { + if (!editor || !editor.isEditable) return false + return editor.isActive({ textAlign: align }) +} + +/** + * Sets text alignment in the editor + */ +export function setTextAlign(editor: Editor | null, align: TextAlign): boolean { + if (!editor || !editor.isEditable) return false + if (!canSetTextAlign(editor, align)) return false + + const chain = editor.chain().focus() + if (hasSetTextAlign(chain)) { + return chain.setTextAlign(align).run() + } + + return false +} + +/** + * Determines if the text align button should be shown + */ +export function shouldShowButton(props: { + editor: Editor | null + hideWhenUnavailable: boolean + align: TextAlign +}): boolean { + const { editor, hideWhenUnavailable, align } = props + + if (!editor) return false + + if (!hideWhenUnavailable) { + return true + } + + if (!editor.isEditable) return false + + if (!isExtensionAvailable(editor, "textAlign")) return false + + if (!editor.isActive("code")) { + return canSetTextAlign(editor, align) + } + + return true +} + +/** + * Custom hook that provides text align functionality for Tiptap editor + * + * @example + * ```tsx + * // Simple usage + * function MySimpleAlignButton() { + * const { isVisible, handleTextAlign } = useTextAlign({ align: "center" }) + * + * if (!isVisible) return null + * + * return + * } + * + * // Advanced usage with configuration + * function MyAdvancedAlignButton() { + * const { isVisible, handleTextAlign, label, isActive } = useTextAlign({ + * editor: myEditor, + * align: "right", + * hideWhenUnavailable: true, + * onAligned: () => console.log('Text aligned!') + * }) + * + * if (!isVisible) return null + * + * return ( + * + * Align Right + * + * ) + * } + * ``` + */ +export function useTextAlign(config: UseTextAlignConfig) { + const { + editor: providedEditor, + align, + hideWhenUnavailable = false, + onAligned, + } = config + + const { editor } = useTiptapEditor(providedEditor) + const [isVisible, setIsVisible] = useState(true) + const canAlign = canSetTextAlign(editor, align) + const isActive = isTextAlignActive(editor, align) + + useEffect(() => { + if (!editor) return + + const handleSelectionUpdate = () => { + setIsVisible(shouldShowButton({ editor, align, hideWhenUnavailable })) + } + + handleSelectionUpdate() + + editor.on("selectionUpdate", handleSelectionUpdate) + + return () => { + editor.off("selectionUpdate", handleSelectionUpdate) + } + }, [editor, hideWhenUnavailable, align]) + + const handleTextAlign = useCallback(() => { + if (!editor) return false + + const success = setTextAlign(editor, align) + if (success) { + onAligned?.() + } + return success + }, [editor, align, onAligned]) + + return { + isVisible, + isActive, + handleTextAlign, + canAlign, + label: textAlignLabels[align], + shortcutKeys: TEXT_ALIGN_SHORTCUT_KEYS[align], + Icon: textAlignIcons[align], + } +} diff --git a/resources/js/components/tiptap-ui/undo-redo-button/index.tsx b/resources/js/components/tiptap-ui/undo-redo-button/index.tsx new file mode 100644 index 0000000..fa0fdbe --- /dev/null +++ b/resources/js/components/tiptap-ui/undo-redo-button/index.tsx @@ -0,0 +1,2 @@ +export * from "./undo-redo-button" +export * from "./use-undo-redo" diff --git a/resources/js/components/tiptap-ui/undo-redo-button/undo-redo-button.tsx b/resources/js/components/tiptap-ui/undo-redo-button/undo-redo-button.tsx new file mode 100644 index 0000000..84c2506 --- /dev/null +++ b/resources/js/components/tiptap-ui/undo-redo-button/undo-redo-button.tsx @@ -0,0 +1,125 @@ +"use client" + +import { forwardRef, useCallback } from "react" + +// --- Lib --- +import { parseShortcutKeys } from "@/lib/tiptap-utils" + +// --- Hooks --- +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" + +// --- Tiptap UI --- +import type { + UndoRedoAction, + UseUndoRedoConfig, +} from "@/components/tiptap-ui/undo-redo-button" +import { + UNDO_REDO_SHORTCUT_KEYS, + useUndoRedo, +} from "@/components/tiptap-ui/undo-redo-button" + +// --- UI Primitives --- +import type { ButtonProps } from "@/components/tiptap-ui-primitive/button" +import { Button } from "@/components/tiptap-ui-primitive/button" +import { Badge } from "@/components/tiptap-ui-primitive/badge" + +export interface UndoRedoButtonProps + extends Omit, UseUndoRedoConfig { + /** + * Optional text to display alongside the icon. + */ + text?: string + /** + * Optional show shortcut keys in the button. + * @default false + */ + showShortcut?: boolean +} + +export function HistoryShortcutBadge({ + action, + shortcutKeys = UNDO_REDO_SHORTCUT_KEYS[action], +}: { + action: UndoRedoAction + shortcutKeys?: string +}) { + return {parseShortcutKeys({ shortcutKeys })} +} + +/** + * Button component for triggering undo/redo actions in a Tiptap editor. + * + * For custom button implementations, use the `useHistory` hook instead. + */ +export const UndoRedoButton = forwardRef< + HTMLButtonElement, + UndoRedoButtonProps +>( + ( + { + editor: providedEditor, + action, + text, + hideWhenUnavailable = false, + onExecuted, + showShortcut = false, + onClick, + children, + ...buttonProps + }, + ref + ) => { + const { editor } = useTiptapEditor(providedEditor) + const { isVisible, handleAction, label, canExecute, Icon, shortcutKeys } = + useUndoRedo({ + editor, + action, + hideWhenUnavailable, + onExecuted, + }) + + const handleClick = useCallback( + (event: React.MouseEvent) => { + onClick?.(event) + if (event.defaultPrevented) return + handleAction() + }, + [handleAction, onClick] + ) + + if (!isVisible) { + return null + } + + return ( + + ) + } +) + +UndoRedoButton.displayName = "UndoRedoButton" diff --git a/resources/js/components/tiptap-ui/undo-redo-button/use-undo-redo.ts b/resources/js/components/tiptap-ui/undo-redo-button/use-undo-redo.ts new file mode 100644 index 0000000..507b17b --- /dev/null +++ b/resources/js/components/tiptap-ui/undo-redo-button/use-undo-redo.ts @@ -0,0 +1,188 @@ +import { useCallback, useEffect, useState } from "react" +import { type Editor } from "@tiptap/react" + +// --- Hooks --- +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" + +// --- Lib --- +import { isNodeTypeSelected } from "@/lib/tiptap-utils" + +// --- Icons --- +import { Redo2Icon } from "@/components/tiptap-icons/redo2-icon" +import { Undo2Icon } from "@/components/tiptap-icons/undo2-icon" + +export type UndoRedoAction = "undo" | "redo" + +/** + * Configuration for the history functionality + */ +export interface UseUndoRedoConfig { + /** + * The Tiptap editor instance. + */ + editor?: Editor | null + /** + * The history action to perform (undo or redo). + */ + action: UndoRedoAction + /** + * Whether the button should hide when action is not available. + * @default false + */ + hideWhenUnavailable?: boolean + /** + * Callback function called after a successful action execution. + */ + onExecuted?: () => void +} + +export const UNDO_REDO_SHORTCUT_KEYS: Record = { + undo: "mod+z", + redo: "mod+shift+z", +} + +export const historyActionLabels: Record = { + undo: "Undo", + redo: "Redo", +} + +export const historyIcons = { + undo: Undo2Icon, + redo: Redo2Icon, +} + +/** + * Checks if a history action can be executed + */ +export function canExecuteUndoRedoAction( + editor: Editor | null, + action: UndoRedoAction +): boolean { + if (!editor || !editor.isEditable) return false + if (isNodeTypeSelected(editor, ["image"])) return false + + return action === "undo" ? editor.can().undo() : editor.can().redo() +} + +/** + * Executes a history action on the editor + */ +export function executeUndoRedoAction( + editor: Editor | null, + action: UndoRedoAction +): boolean { + if (!editor || !editor.isEditable) return false + if (!canExecuteUndoRedoAction(editor, action)) return false + + const chain = editor.chain().focus() + return action === "undo" ? chain.undo().run() : chain.redo().run() +} + +/** + * Determines if the history button should be shown + */ +export function shouldShowButton(props: { + editor: Editor | null + hideWhenUnavailable: boolean + action: UndoRedoAction +}): boolean { + const { editor, hideWhenUnavailable, action } = props + + if (!editor) return false + + if (!hideWhenUnavailable) { + return true + } + + if (!editor.isEditable) return false + + if (!editor.isActive("code")) { + return canExecuteUndoRedoAction(editor, action) + } + + return true +} + +/** + * Custom hook that provides history functionality for Tiptap editor + * + * @example + * ```tsx + * // Simple usage + * function MySimpleUndoButton() { + * const { isVisible, handleAction } = useHistory({ action: "undo" }) + * + * if (!isVisible) return null + * + * return + * } + * + * // Advanced usage with configuration + * function MyAdvancedRedoButton() { + * const { isVisible, handleAction, label } = useHistory({ + * editor: myEditor, + * action: "redo", + * hideWhenUnavailable: true, + * onExecuted: () => console.log('Action executed!') + * }) + * + * if (!isVisible) return null + * + * return ( + * + * Redo + * + * ) + * } + * ``` + */ +export function useUndoRedo(config: UseUndoRedoConfig) { + const { + editor: providedEditor, + action, + hideWhenUnavailable = false, + onExecuted, + } = config + + const { editor } = useTiptapEditor(providedEditor) + const [isVisible, setIsVisible] = useState(true) + const canExecute = canExecuteUndoRedoAction(editor, action) + + useEffect(() => { + if (!editor) return + + const handleUpdate = () => { + setIsVisible(shouldShowButton({ editor, hideWhenUnavailable, action })) + } + + handleUpdate() + + editor.on("transaction", handleUpdate) + + return () => { + editor.off("transaction", handleUpdate) + } + }, [editor, hideWhenUnavailable, action]) + + const handleAction = useCallback(() => { + if (!editor) return false + + const success = executeUndoRedoAction(editor, action) + if (success) { + onExecuted?.() + } + return success + }, [editor, action, onExecuted]) + + return { + isVisible, + handleAction, + canExecute, + label: historyActionLabels[action], + shortcutKeys: UNDO_REDO_SHORTCUT_KEYS[action], + Icon: historyIcons[action], + } +} diff --git a/resources/js/hooks/use-composed-ref.ts b/resources/js/hooks/use-composed-ref.ts new file mode 100644 index 0000000..30745b8 --- /dev/null +++ b/resources/js/hooks/use-composed-ref.ts @@ -0,0 +1,47 @@ +"use client" + +import { useCallback, useRef } from "react" + +// basically Exclude["ref"], string> +type UserRef = + | ((instance: T | null) => void) + | React.RefObject + | null + | undefined + +const updateRef = (ref: NonNullable>, value: T | null) => { + if (typeof ref === "function") { + ref(value) + } else if (ref && typeof ref === "object" && "current" in ref) { + // Safe assignment without MutableRefObject + ;(ref as { current: T | null }).current = value + } +} + +export const useComposedRef = ( + libRef: React.RefObject, + userRef: UserRef +) => { + const prevUserRef = useRef>(null) + + return useCallback( + (instance: T | null) => { + if (libRef && "current" in libRef) { + ;(libRef as { current: T | null }).current = instance + } + + if (prevUserRef.current) { + updateRef(prevUserRef.current, null) + } + + prevUserRef.current = userRef + + if (userRef) { + updateRef(userRef, instance) + } + }, + [libRef, userRef] + ) +} + +export default useComposedRef diff --git a/resources/js/hooks/use-cursor-visibility.ts b/resources/js/hooks/use-cursor-visibility.ts new file mode 100644 index 0000000..19668d8 --- /dev/null +++ b/resources/js/hooks/use-cursor-visibility.ts @@ -0,0 +1,69 @@ +import type { Editor } from "@tiptap/react" +import { useWindowSize } from "@/hooks/use-window-size" +import { useBodyRect } from "@/hooks/use-element-rect" +import { useEffect } from "react" + +export interface CursorVisibilityOptions { + /** + * The Tiptap editor instance + */ + editor?: Editor | null + /** + * Reference to the toolbar element that may obscure the cursor + */ + overlayHeight?: number +} + +/** + * Custom hook that ensures the cursor remains visible when typing in a Tiptap editor. + * Automatically scrolls the window when the cursor would be hidden by the toolbar. + * + * @param options.editor The Tiptap editor instance + * @param options.overlayHeight Toolbar height to account for + * @returns The bounding rect of the body + */ +export function useCursorVisibility({ + editor, + overlayHeight = 0, +}: CursorVisibilityOptions) { + const { height: windowHeight } = useWindowSize() + const rect = useBodyRect({ + enabled: true, + throttleMs: 100, + useResizeObserver: true, + }) + + useEffect(() => { + const ensureCursorVisibility = () => { + if (!editor) return + + const { state, view } = editor + if (!view.hasFocus()) return + + // Get current cursor position coordinates + const { from } = state.selection + const cursorCoords = view.coordsAtPos(from) + + if (windowHeight < rect.height && cursorCoords) { + const availableSpace = windowHeight - cursorCoords.top + + // If the cursor is hidden behind the overlay or offscreen, scroll it into view + if (availableSpace < overlayHeight) { + const targetCursorY = Math.max(windowHeight / 2, overlayHeight) + const currentScrollY = window.scrollY + const cursorAbsoluteY = cursorCoords.top + currentScrollY + const newScrollY = cursorAbsoluteY - targetCursorY + + window.scrollTo({ + top: Math.max(0, newScrollY), + behavior: "smooth", + }) + } + } + } + + ensureCursorVisibility() + }, [editor, overlayHeight, windowHeight, rect.height]) + + return rect +} diff --git a/resources/js/hooks/use-element-rect.ts b/resources/js/hooks/use-element-rect.ts new file mode 100644 index 0000000..55ba9bd --- /dev/null +++ b/resources/js/hooks/use-element-rect.ts @@ -0,0 +1,166 @@ +"use client" + +import { useCallback, useEffect, useState } from "react" +import { useThrottledCallback } from "@/hooks/use-throttled-callback" + +export type RectState = Omit + +export interface ElementRectOptions { + /** + * The element to track. Can be an Element, ref, or selector string. + * Defaults to document.body if not provided. + */ + element?: Element | React.RefObject | string | null + /** + * Whether to enable rect tracking + */ + enabled?: boolean + /** + * Throttle delay in milliseconds for rect updates + */ + throttleMs?: number + /** + * Whether to use ResizeObserver for more accurate tracking + */ + useResizeObserver?: boolean +} + +const initialRect: RectState = { + x: 0, + y: 0, + width: 0, + height: 0, + top: 0, + right: 0, + bottom: 0, + left: 0, +} + +const isSSR = typeof window === "undefined" +const hasResizeObserver = !isSSR && typeof ResizeObserver !== "undefined" + +/** + * Helper function to check if code is running on client side + */ +const isClientSide = (): boolean => !isSSR + +/** + * Custom hook that tracks an element's bounding rectangle and updates on resize, scroll, etc. + * + * @param options Configuration options for element rect tracking + * @returns The current bounding rectangle of the element + */ +export function useElementRect({ + element, + enabled = true, + throttleMs = 100, + useResizeObserver = true, +}: ElementRectOptions = {}): RectState { + const [rect, setRect] = useState(initialRect) + + const getTargetElement = useCallback((): Element | null => { + if (!enabled || !isClientSide()) return null + + if (!element) { + return document.body + } + + if (typeof element === "string") { + return document.querySelector(element) + } + + if ("current" in element) { + return element.current + } + + return element + }, [element, enabled]) + + const updateRect = useThrottledCallback( + () => { + if (!enabled || !isClientSide()) return + + const targetElement = getTargetElement() + if (!targetElement) { + setRect(initialRect) + return + } + + const newRect = targetElement.getBoundingClientRect() + setRect({ + x: newRect.x, + y: newRect.y, + width: newRect.width, + height: newRect.height, + top: newRect.top, + right: newRect.right, + bottom: newRect.bottom, + left: newRect.left, + }) + }, + throttleMs, + [enabled, getTargetElement], + { leading: true, trailing: true } + ) + + useEffect(() => { + if (!enabled || !isClientSide()) { + setRect(initialRect) + return + } + + const targetElement = getTargetElement() + if (!targetElement) return + + updateRect() + + const cleanup: (() => void)[] = [] + + if (useResizeObserver && hasResizeObserver) { + const resizeObserver = new ResizeObserver(() => { + window.requestAnimationFrame(updateRect) + }) + resizeObserver.observe(targetElement) + cleanup.push(() => resizeObserver.disconnect()) + } + + const handleUpdate = () => updateRect() + + window.addEventListener("scroll", handleUpdate, true) + window.addEventListener("resize", handleUpdate, true) + + cleanup.push(() => { + window.removeEventListener("scroll", handleUpdate) + window.removeEventListener("resize", handleUpdate) + }) + + return () => { + cleanup.forEach((fn) => fn()) + setRect(initialRect) + } + }, [enabled, getTargetElement, updateRect, useResizeObserver]) + + return rect +} + +/** + * Convenience hook for tracking document.body rect + */ +export function useBodyRect( + options: Omit = {} +): RectState { + return useElementRect({ + ...options, + element: isClientSide() ? document.body : null, + }) +} + +/** + * Convenience hook for tracking a ref element's rect + */ +export function useRefRect( + ref: React.RefObject, + options: Omit = {} +): RectState { + return useElementRect({ ...options, element: ref }) +} diff --git a/resources/js/hooks/use-is-breakpoint.ts b/resources/js/hooks/use-is-breakpoint.ts new file mode 100644 index 0000000..50f1874 --- /dev/null +++ b/resources/js/hooks/use-is-breakpoint.ts @@ -0,0 +1,35 @@ +import { useEffect, useState } from "react" + +type BreakpointMode = "min" | "max" + +/** + * Hook to detect whether the current viewport matches a given breakpoint rule. + * Example: + * useIsBreakpoint("max", 768) // true when width < 768 + * useIsBreakpoint("min", 1024) // true when width >= 1024 + */ +export function useIsBreakpoint( + mode: BreakpointMode = "max", + breakpoint = 768 +) { + const [matches, setMatches] = useState(undefined) + + useEffect(() => { + const query = + mode === "min" + ? `(min-width: ${breakpoint}px)` + : `(max-width: ${breakpoint - 1}px)` + + const mql = window.matchMedia(query) + const onChange = (e: MediaQueryListEvent) => setMatches(e.matches) + + // Set initial value + setMatches(mql.matches) + + // Add listener + mql.addEventListener("change", onChange) + return () => mql.removeEventListener("change", onChange) + }, [mode, breakpoint]) + + return !!matches +} diff --git a/resources/js/hooks/use-menu-navigation.ts b/resources/js/hooks/use-menu-navigation.ts new file mode 100644 index 0000000..58dfd4b --- /dev/null +++ b/resources/js/hooks/use-menu-navigation.ts @@ -0,0 +1,194 @@ +import type { Editor } from "@tiptap/react" +import { useEffect, useState } from "react" + +type Orientation = "horizontal" | "vertical" | "both" + +interface MenuNavigationOptions { + /** + * The Tiptap editor instance, if using with a Tiptap editor. + */ + editor?: Editor | null + /** + * Reference to the container element for handling keyboard events. + */ + containerRef?: React.RefObject + /** + * Search query that affects the selected item. + */ + query?: string + /** + * Array of items to navigate through. + */ + items: T[] + /** + * Callback fired when an item is selected. + */ + onSelect?: (item: T) => void + /** + * Callback fired when the menu should close. + */ + onClose?: () => void + /** + * The navigation orientation of the menu. + * @default "vertical" + */ + orientation?: Orientation + /** + * Whether to automatically select the first item when the menu opens. + * @default true + */ + autoSelectFirstItem?: boolean +} + +/** + * Hook that implements keyboard navigation for dropdown menus and command palettes. + * + * Handles arrow keys, tab, home/end, enter for selection, and escape to close. + * Works with both Tiptap editors and regular DOM elements. + * + * @param options - Configuration options for the menu navigation + * @returns Object containing the selected index and a setter function + */ +export function useMenuNavigation({ + editor, + containerRef, + query, + items, + onSelect, + onClose, + orientation = "vertical", + autoSelectFirstItem = true, +}: MenuNavigationOptions) { + const [selectedIndex, setSelectedIndex] = useState( + autoSelectFirstItem ? 0 : -1 + ) + + useEffect(() => { + const handleKeyboardNavigation = (event: KeyboardEvent) => { + if (!items.length) return false + + const moveNext = () => + setSelectedIndex((currentIndex) => { + if (currentIndex === -1) return 0 + return (currentIndex + 1) % items.length + }) + + const movePrev = () => + setSelectedIndex((currentIndex) => { + if (currentIndex === -1) return items.length - 1 + return (currentIndex - 1 + items.length) % items.length + }) + + switch (event.key) { + case "ArrowUp": { + if (orientation === "horizontal") return false + event.preventDefault() + movePrev() + return true + } + + case "ArrowDown": { + if (orientation === "horizontal") return false + event.preventDefault() + moveNext() + return true + } + + case "ArrowLeft": { + if (orientation === "vertical") return false + event.preventDefault() + movePrev() + return true + } + + case "ArrowRight": { + if (orientation === "vertical") return false + event.preventDefault() + moveNext() + return true + } + + case "Tab": { + event.preventDefault() + if (event.shiftKey) { + movePrev() + } else { + moveNext() + } + return true + } + + case "Home": { + event.preventDefault() + setSelectedIndex(0) + return true + } + + case "End": { + event.preventDefault() + setSelectedIndex(items.length - 1) + return true + } + + case "Enter": { + if (event.isComposing) return false + event.preventDefault() + if (selectedIndex !== -1 && items[selectedIndex]) { + onSelect?.(items[selectedIndex]) + } + return true + } + + case "Escape": { + event.preventDefault() + onClose?.() + return true + } + + default: + return false + } + } + + let targetElement: HTMLElement | null = null + + if (editor) { + targetElement = editor.view.dom + } else if (containerRef?.current) { + targetElement = containerRef.current + } + + if (targetElement) { + targetElement.addEventListener("keydown", handleKeyboardNavigation, true) + + return () => { + targetElement?.removeEventListener( + "keydown", + handleKeyboardNavigation, + true + ) + } + } + + return undefined + }, [ + editor, + containerRef, + items, + selectedIndex, + onSelect, + onClose, + orientation, + ]) + + useEffect(() => { + if (query) { + setSelectedIndex(autoSelectFirstItem ? 0 : -1) + } + }, [query, autoSelectFirstItem]) + + return { + selectedIndex: items.length ? selectedIndex : undefined, + setSelectedIndex, + } +} diff --git a/resources/js/hooks/use-scrolling.ts b/resources/js/hooks/use-scrolling.ts new file mode 100644 index 0000000..8a5fb35 --- /dev/null +++ b/resources/js/hooks/use-scrolling.ts @@ -0,0 +1,75 @@ +import type { RefObject } from "react" +import { useEffect, useState } from "react" + +type ScrollTarget = RefObject | Window | null | undefined +type EventTargetWithScroll = Window | HTMLElement | Document + +interface UseScrollingOptions { + debounce?: number + fallbackToDocument?: boolean +} + +export function useScrolling( + target?: ScrollTarget, + options: UseScrollingOptions = {} +): boolean { + const { debounce = 150, fallbackToDocument = true } = options + const [isScrolling, setIsScrolling] = useState(false) + + useEffect(() => { + // Resolve element or window + const element: EventTargetWithScroll = + target && typeof Window !== "undefined" && target instanceof Window + ? target + : ((target as RefObject)?.current ?? window) + + // Mobile: fallback to document when using window + const eventTarget: EventTargetWithScroll = + fallbackToDocument && + element === window && + typeof document !== "undefined" + ? document + : element + + const on = ( + el: EventTargetWithScroll, + event: string, + handler: EventListener + ) => el.addEventListener(event, handler, true) + + const off = ( + el: EventTargetWithScroll, + event: string, + handler: EventListener + ) => el.removeEventListener(event, handler) + + let timeout: ReturnType + const supportsScrollEnd = element === window && "onscrollend" in window + + const handleScroll: EventListener = () => { + if (!isScrolling) setIsScrolling(true) + + if (!supportsScrollEnd) { + clearTimeout(timeout) + timeout = setTimeout(() => setIsScrolling(false), debounce) + } + } + + const handleScrollEnd: EventListener = () => setIsScrolling(false) + + on(eventTarget, "scroll", handleScroll) + if (supportsScrollEnd) { + on(eventTarget, "scrollend", handleScrollEnd) + } + + return () => { + off(eventTarget, "scroll", handleScroll) + if (supportsScrollEnd) { + off(eventTarget, "scrollend", handleScrollEnd) + } + clearTimeout(timeout) + } + }, [target, debounce, fallbackToDocument, isScrolling]) + + return isScrolling +} diff --git a/resources/js/hooks/use-throttled-callback.ts b/resources/js/hooks/use-throttled-callback.ts new file mode 100644 index 0000000..54894cf --- /dev/null +++ b/resources/js/hooks/use-throttled-callback.ts @@ -0,0 +1,48 @@ +import throttle from "lodash.throttle" + +import { useUnmount } from "@/hooks/use-unmount" +import { useMemo } from "react" + +interface ThrottleSettings { + leading?: boolean | undefined + trailing?: boolean | undefined +} + +const defaultOptions: ThrottleSettings = { + leading: false, + trailing: true, +} + +/** + * A hook that returns a throttled callback function. + * + * @param fn The function to throttle + * @param wait The time in ms to wait before calling the function + * @param dependencies The dependencies to watch for changes + * @param options The throttle options + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function useThrottledCallback any>( + fn: T, + wait = 250, + dependencies: React.DependencyList = [], + options: ThrottleSettings = defaultOptions +): { + (this: ThisParameterType, ...args: Parameters): ReturnType + cancel: () => void + flush: () => void +} { + const handler = useMemo( + () => throttle(fn, wait, options), + // eslint-disable-next-line react-hooks/exhaustive-deps + dependencies + ) + + useUnmount(() => { + handler.cancel() + }) + + return handler +} + +export default useThrottledCallback diff --git a/resources/js/hooks/use-tiptap-editor.ts b/resources/js/hooks/use-tiptap-editor.ts new file mode 100644 index 0000000..ef9ca7b --- /dev/null +++ b/resources/js/hooks/use-tiptap-editor.ts @@ -0,0 +1,69 @@ +import type { Editor } from "@tiptap/react" +import { useCurrentEditor, useEditorState } from "@tiptap/react" +import { useEffect, useState } from "react" + +function getActivePageEditor(editor: Editor): Editor | null { + const storage = editor.storage as unknown as Record + const pages = storage.pages as { activeEditor?: Editor | null } | undefined + if (!pages || !("activeEditor" in pages)) return null + return pages.activeEditor ?? null +} + +export function useTiptapEditor(providedEditor?: Editor | null): { + editor: Editor | null + editorState?: Editor["state"] + canCommand?: Editor["can"] +} { + const { editor: coreEditor } = useCurrentEditor() + const mainEditor = providedEditor ?? coreEditor + + const [storageEditor, setStorageEditor] = useState(null) + + useEffect(() => { + if (!mainEditor) { + setStorageEditor(null) + return + } + + const updateHandler = () => + setStorageEditor(getActivePageEditor(mainEditor)) + + updateHandler() + + mainEditor.on("update", updateHandler) + mainEditor.on("selectionUpdate", updateHandler) + + return () => { + mainEditor.off("update", updateHandler) + mainEditor.off("selectionUpdate", updateHandler) + } + }, [mainEditor]) + + useEffect(() => { + if (!storageEditor) return + + const handleDestroy = () => setStorageEditor(null) + + storageEditor.on("destroy", handleDestroy) + return () => { + storageEditor.off("destroy", handleDestroy) + } + }, [storageEditor]) + + const editorState = useEditorState({ + editor: storageEditor ?? mainEditor, + selector(context) { + if (!context.editor) { + return { editor: null, editorState: undefined, canCommand: undefined } + } + + return { + editor: context.editor, + editorState: context.editor.state, + canCommand: context.editor.can, + } + }, + }) + + return editorState ?? { editor: null } +} diff --git a/resources/js/hooks/use-unmount.ts b/resources/js/hooks/use-unmount.ts new file mode 100644 index 0000000..91a22e7 --- /dev/null +++ b/resources/js/hooks/use-unmount.ts @@ -0,0 +1,21 @@ +import { useRef, useEffect } from "react" + +/** + * Hook that executes a callback when the component unmounts. + * + * @param callback Function to be called on component unmount + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const useUnmount = (callback: (...args: Array) => any) => { + const ref = useRef(callback) + ref.current = callback + + useEffect( + () => () => { + ref.current() + }, + [] + ) +} + +export default useUnmount diff --git a/resources/js/hooks/use-window-size.ts b/resources/js/hooks/use-window-size.ts new file mode 100644 index 0000000..dd211d9 --- /dev/null +++ b/resources/js/hooks/use-window-size.ts @@ -0,0 +1,93 @@ +"use client" + +import { useEffect, useState } from "react" +import { useThrottledCallback } from "@/hooks/use-throttled-callback" + +export interface WindowSizeState { + /** + * The width of the window's visual viewport in pixels. + */ + width: number + /** + * The height of the window's visual viewport in pixels. + */ + height: number + /** + * The distance from the top of the visual viewport to the top of the layout viewport. + * Particularly useful for handling mobile keyboard appearance. + */ + offsetTop: number + /** + * The distance from the left of the visual viewport to the left of the layout viewport. + */ + offsetLeft: number + /** + * The scale factor of the visual viewport. + * This is useful for scaling elements based on the current zoom level. + */ + scale: number +} + +/** + * Hook that tracks the window's visual viewport dimensions, position, and provides + * a CSS transform for positioning elements. + * + * Uses the Visual Viewport API to get accurate measurements, especially important + * for mobile devices where virtual keyboards can change the visible area. + * Only updates state when values actually change to optimize performance. + * + * @returns An object containing viewport properties and a CSS transform string + */ +export function useWindowSize(): WindowSizeState { + const [windowSize, setWindowSize] = useState({ + width: 0, + height: 0, + offsetTop: 0, + offsetLeft: 0, + scale: 0, + }) + + const handleViewportChange = useThrottledCallback(() => { + if (typeof window === "undefined") return + + const vp = window.visualViewport + if (!vp) return + + const { + width = 0, + height = 0, + offsetTop = 0, + offsetLeft = 0, + scale = 0, + } = vp + + setWindowSize((prevState) => { + if ( + width === prevState.width && + height === prevState.height && + offsetTop === prevState.offsetTop && + offsetLeft === prevState.offsetLeft && + scale === prevState.scale + ) { + return prevState + } + + return { width, height, offsetTop, offsetLeft, scale } + }) + }, 200) + + useEffect(() => { + const visualViewport = window.visualViewport + if (!visualViewport) return + + visualViewport.addEventListener("resize", handleViewportChange) + + handleViewportChange() + + return () => { + visualViewport.removeEventListener("resize", handleViewportChange) + } + }, [handleViewportChange]) + + return windowSize +} diff --git a/resources/js/lib/tiptap-utils.ts b/resources/js/lib/tiptap-utils.ts new file mode 100644 index 0000000..79961b1 --- /dev/null +++ b/resources/js/lib/tiptap-utils.ts @@ -0,0 +1,640 @@ +import type { Node as PMNode } from "@tiptap/pm/model" +import type { Transaction } from "@tiptap/pm/state" +import { + AllSelection, + NodeSelection, + Selection, + TextSelection, +} from "@tiptap/pm/state" +import { cellAround, CellSelection } from "@tiptap/pm/tables" +import { + findParentNodeClosestToPos, + type Editor, + type NodeWithPos, +} from "@tiptap/react" + +export const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB + +export const MAC_SYMBOLS: Record = { + mod: "⌘", + command: "⌘", + meta: "⌘", + ctrl: "⌃", + control: "⌃", + alt: "⌥", + option: "⌥", + shift: "⇧", + backspace: "Del", + delete: "⌦", + enter: "⏎", + escape: "⎋", + capslock: "⇪", +} as const + +export const SR_ONLY = { + position: "absolute", + width: "1px", + height: "1px", + padding: 0, + margin: "-1px", + overflow: "hidden", + clip: "rect(0, 0, 0, 0)", + whiteSpace: "nowrap", + borderWidth: 0, +} as const + +export function cn( + ...classes: (string | boolean | undefined | null)[] +): string { + return classes.filter(Boolean).join(" ") +} + +/** + * Determines if the current platform is macOS + * @returns boolean indicating if the current platform is Mac + */ +export function isMac(): boolean { + return ( + typeof navigator !== "undefined" && + navigator.platform.toLowerCase().includes("mac") + ) +} + +/** + * Formats a shortcut key based on the platform (Mac or non-Mac) + * @param key - The key to format (e.g., "ctrl", "alt", "shift") + * @param isMac - Boolean indicating if the platform is Mac + * @param capitalize - Whether to capitalize the key (default: true) + * @returns Formatted shortcut key symbol + */ +export const formatShortcutKey = ( + key: string, + isMac: boolean, + capitalize: boolean = true +) => { + if (isMac) { + const lowerKey = key.toLowerCase() + return MAC_SYMBOLS[lowerKey] || (capitalize ? key.toUpperCase() : key) + } + + return capitalize ? key.charAt(0).toUpperCase() + key.slice(1) : key +} + +/** + * Parses a shortcut key string into an array of formatted key symbols + * @param shortcutKeys - The string of shortcut keys (e.g., "ctrl-alt-shift") + * @param delimiter - The delimiter used to split the keys (default: "-") + * @param capitalize - Whether to capitalize the keys (default: true) + * @returns Array of formatted shortcut key symbols + */ +export const parseShortcutKeys = (props: { + shortcutKeys: string | undefined + delimiter?: string + capitalize?: boolean +}) => { + const { shortcutKeys, delimiter = "+", capitalize = true } = props + + if (!shortcutKeys) return [] + + return shortcutKeys + .split(delimiter) + .map((key) => key.trim()) + .map((key) => formatShortcutKey(key, isMac(), capitalize)) +} + +/** + * Checks if a mark exists in the editor schema + * @param markName - The name of the mark to check + * @param editor - The editor instance + * @returns boolean indicating if the mark exists in the schema + */ +export const isMarkInSchema = ( + markName: string, + editor: Editor | null +): boolean => { + if (!editor?.schema) return false + return editor.schema.spec.marks.get(markName) !== undefined +} + +/** + * Checks if a node exists in the editor schema + * @param nodeName - The name of the node to check + * @param editor - The editor instance + * @returns boolean indicating if the node exists in the schema + */ +export const isNodeInSchema = ( + nodeName: string, + editor: Editor | null +): boolean => { + if (!editor?.schema) return false + return editor.schema.spec.nodes.get(nodeName) !== undefined +} + +/** + * Moves the focus to the next node in the editor + * @param editor - The editor instance + * @returns boolean indicating if the focus was moved + */ +export function focusNextNode(editor: Editor) { + const { state, view } = editor + const { doc, selection } = state + + const nextSel = Selection.findFrom(selection.$to, 1, true) + if (nextSel) { + view.dispatch(state.tr.setSelection(nextSel).scrollIntoView()) + return true + } + + const paragraphType = state.schema.nodes.paragraph + if (!paragraphType) { + console.warn("No paragraph node type found in schema.") + return false + } + + const end = doc.content.size + const para = paragraphType.create() + let tr = state.tr.insert(end, para) + + // Place the selection inside the new paragraph + const $inside = tr.doc.resolve(end + 1) + tr = tr.setSelection(TextSelection.near($inside)).scrollIntoView() + view.dispatch(tr) + return true +} + +/** + * Checks if a value is a valid number (not null, undefined, or NaN) + * @param value - The value to check + * @returns boolean indicating if the value is a valid number + */ +export function isValidPosition(pos: number | null | undefined): pos is number { + return typeof pos === "number" && pos >= 0 +} + +/** + * Checks if one or more extensions are registered in the Tiptap editor. + * @param editor - The Tiptap editor instance + * @param extensionNames - A single extension name or an array of names to check + * @returns True if at least one of the extensions is available, false otherwise + */ +export function isExtensionAvailable( + editor: Editor | null, + extensionNames: string | string[] +): boolean { + if (!editor) return false + + const names = Array.isArray(extensionNames) + ? extensionNames + : [extensionNames] + + const found = names.some((name) => + editor.extensionManager.extensions.some((ext) => ext.name === name) + ) + + if (!found) { + console.warn( + `None of the extensions [${names.join(", ")}] were found in the editor schema. Ensure they are included in the editor configuration.` + ) + } + + return found +} + +/** + * Finds a node at the specified position with error handling + * @param editor The Tiptap editor instance + * @param position The position in the document to find the node + * @returns The node at the specified position, or null if not found + */ +export function findNodeAtPosition(editor: Editor, position: number) { + try { + const node = editor.state.doc.nodeAt(position) + if (!node) { + console.warn(`No node found at position ${position}`) + return null + } + return node + } catch (error) { + console.error(`Error getting node at position ${position}:`, error) + return null + } +} + +/** + * Finds the position and instance of a node in the document + * @param props Object containing editor, node (optional), and nodePos (optional) + * @param props.editor The Tiptap editor instance + * @param props.node The node to find (optional if nodePos is provided) + * @param props.nodePos The position of the node to find (optional if node is provided) + * @returns An object with the position and node, or null if not found + */ +export function findNodePosition(props: { + editor: Editor | null + node?: PMNode | null + nodePos?: number | null +}): { pos: number; node: PMNode } | null { + const { editor, node, nodePos } = props + + if (!editor || !editor.state?.doc) return null + + // Zero is valid position + const hasValidNode = node !== undefined && node !== null + const hasValidPos = isValidPosition(nodePos) + + if (!hasValidNode && !hasValidPos) { + return null + } + + // First search for the node in the document if we have a node + if (hasValidNode) { + let foundPos = -1 + let foundNode: PMNode | null = null + + editor.state.doc.descendants((currentNode, pos) => { + // TODO: Needed? + // if (currentNode.type && currentNode.type.name === node!.type.name) { + if (currentNode === node) { + foundPos = pos + foundNode = currentNode + return false + } + return true + }) + + if (foundPos !== -1 && foundNode !== null) { + return { pos: foundPos, node: foundNode } + } + } + + // If we have a valid position, use findNodeAtPosition + if (hasValidPos) { + const nodeAtPos = findNodeAtPosition(editor, nodePos!) + if (nodeAtPos) { + return { pos: nodePos!, node: nodeAtPos } + } + } + + return null +} + +/** + * Determines whether the current selection contains a node whose type matches + * any of the provided node type names. + * @param editor Tiptap editor instance + * @param nodeTypeNames List of node type names to match against + * @param checkAncestorNodes Whether to check ancestor node types up the depth chain + */ +export function isNodeTypeSelected( + editor: Editor | null, + nodeTypeNames: string[] = [], + checkAncestorNodes: boolean = false +): boolean { + if (!editor || !editor.state.selection) return false + + const { selection } = editor.state + if (selection.empty) return false + + // Direct node selection check + if (selection instanceof NodeSelection) { + const selectedNode = selection.node + return selectedNode ? nodeTypeNames.includes(selectedNode.type.name) : false + } + + // Depth-based ancestor node check + if (checkAncestorNodes) { + const { $from } = selection + for (let depth = $from.depth; depth > 0; depth--) { + const ancestorNode = $from.node(depth) + if (nodeTypeNames.includes(ancestorNode.type.name)) { + return true + } + } + } + + return false +} + +/** + * Check whether the current selection is fully within nodes + * whose type names are in the provided `types` list. + * + * - NodeSelection → checks the selected node. + * - Text/AllSelection → ensures all textblocks within [from, to) are allowed. + */ +export function selectionWithinConvertibleTypes( + editor: Editor, + types: string[] = [] +): boolean { + if (!editor || types.length === 0) return false + + const { state } = editor + const { selection } = state + const allowed = new Set(types) + + if (selection instanceof NodeSelection) { + const nodeType = selection.node?.type?.name + return !!nodeType && allowed.has(nodeType) + } + + if (selection instanceof TextSelection || selection instanceof AllSelection) { + let valid = true + state.doc.nodesBetween(selection.from, selection.to, (node) => { + if (node.isTextblock && !allowed.has(node.type.name)) { + valid = false + return false // stop early + } + return valid + }) + return valid + } + + return false +} + +/** + * Handles image upload with progress tracking and abort capability + * @param file The file to upload + * @param onProgress Optional callback for tracking upload progress + * @param abortSignal Optional AbortSignal for cancelling the upload + * @returns Promise resolving to the URL of the uploaded image + */ +export const handleImageUpload = async ( + file: File, + onProgress?: (event: { progress: number }) => void, + abortSignal?: AbortSignal +): Promise => { + // Validate file + if (!file) { + throw new Error("No file provided") + } + + if (file.size > MAX_FILE_SIZE) { + throw new Error( + `File size exceeds maximum allowed (${MAX_FILE_SIZE / (1024 * 1024)}MB)` + ) + } + + // For demo/testing: Simulate upload progress. In production, replace the following code + // with your own upload implementation. + for (let progress = 0; progress <= 100; progress += 10) { + if (abortSignal?.aborted) { + throw new Error("Upload cancelled") + } + await new Promise((resolve) => setTimeout(resolve, 500)) + onProgress?.({ progress }) + } + + return "/images/tiptap-ui-placeholder-image.jpg" +} + +type ProtocolOptions = { + /** + * The protocol scheme to be registered. + * @default ''' + * @example 'ftp' + * @example 'git' + */ + scheme: string + + /** + * If enabled, it allows optional slashes after the protocol. + * @default false + * @example true + */ + optionalSlashes?: boolean +} + +type ProtocolConfig = Array + +const ATTR_WHITESPACE = + // eslint-disable-next-line no-control-regex + /[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g + +export function isAllowedUri( + uri: string | undefined, + protocols?: ProtocolConfig +) { + const allowedProtocols: string[] = [ + "http", + "https", + "ftp", + "ftps", + "mailto", + "tel", + "callto", + "sms", + "cid", + "xmpp", + ] + + if (protocols) { + protocols.forEach((protocol) => { + const nextProtocol = + typeof protocol === "string" ? protocol : protocol.scheme + + if (nextProtocol) { + allowedProtocols.push(nextProtocol) + } + }) + } + + return ( + !uri || + uri.replace(ATTR_WHITESPACE, "").match( + new RegExp( + // eslint-disable-next-line no-useless-escape + `^(?:(?:${allowedProtocols.join("|")}):|[^a-z]|[a-z0-9+.\-]+(?:[^a-z+.\-:]|$))`, + "i" + ) + ) + ) +} + +export function sanitizeUrl( + inputUrl: string, + baseUrl: string, + protocols?: ProtocolConfig +): string { + try { + const url = new URL(inputUrl, baseUrl) + + if (isAllowedUri(url.href, protocols)) { + return url.href + } + } catch { + // If URL creation fails, it's considered invalid + } + return "#" +} + +/** + * Update a single attribute on multiple nodes. + * + * @param tr - The transaction to mutate + * @param targets - Array of { node, pos } + * @param attrName - Attribute key to update + * @param next - New value OR updater function receiving previous value + * Pass `undefined` to remove the attribute. + * @returns true if at least one node was updated, false otherwise + */ +export function updateNodesAttr( + tr: Transaction, + targets: readonly NodeWithPos[], + attrName: A, + next: V | ((prev: V | undefined) => V | undefined) +): boolean { + if (!targets.length) return false + + let changed = false + + for (const { pos } of targets) { + // Always re-read from the transaction's current doc + const currentNode = tr.doc.nodeAt(pos) + if (!currentNode) continue + + const prevValue = (currentNode.attrs as Record)[ + attrName + ] as V | undefined + const resolvedNext = + typeof next === "function" + ? (next as (p: V | undefined) => V | undefined)(prevValue) + : next + + if (prevValue === resolvedNext) continue + + const nextAttrs: Record = { ...currentNode.attrs } + if (resolvedNext === undefined) { + // Remove the key entirely instead of setting null + delete nextAttrs[attrName] + } else { + nextAttrs[attrName] = resolvedNext + } + + tr.setNodeMarkup(pos, undefined, nextAttrs) + changed = true + } + + return changed +} + +/** + * Selects the entire content of the current block node if the selection is empty. + * If the selection is not empty, it does nothing. + * @param editor The Tiptap editor instance + */ +export function selectCurrentBlockContent(editor: Editor) { + const { selection, doc } = editor.state + + if (!selection.empty) return + + const $pos = selection.$from + let blockNode = null + let blockPos = -1 + + for (let depth = $pos.depth; depth >= 0; depth--) { + const node = $pos.node(depth) + const pos = $pos.start(depth) + + if (node.isBlock && node.textContent.trim()) { + blockNode = node + blockPos = pos + break + } + } + + if (blockNode && blockPos >= 0) { + const from = blockPos + const to = blockPos + blockNode.nodeSize - 2 // -2 to exclude the closing tag + + if (from < to) { + const $from = doc.resolve(from) + const $to = doc.resolve(to) + const newSelection = TextSelection.between($from, $to, 1) + + if (newSelection && !selection.eq(newSelection)) { + editor.view.dispatch(editor.state.tr.setSelection(newSelection)) + } + } + } +} + +/** + * Retrieves all nodes of specified types from the current selection. + * @param selection The current editor selection + * @param allowedNodeTypes An array of node type names to look for (e.g., ["image", "table"]) + * @returns An array of objects containing the node and its position + */ +export function getSelectedNodesOfType( + selection: Selection, + allowedNodeTypes: string[] +): NodeWithPos[] { + const results: NodeWithPos[] = [] + const allowed = new Set(allowedNodeTypes) + + if (selection instanceof CellSelection) { + selection.forEachCell((node: PMNode, pos: number) => { + if (allowed.has(node.type.name)) { + results.push({ node, pos }) + } + }) + return results + } + + if (selection instanceof NodeSelection) { + const { node, from: pos } = selection + if (node && allowed.has(node.type.name)) { + results.push({ node, pos }) + } + return results + } + + const { $anchor } = selection + const cell = cellAround($anchor) + + if (cell) { + const cellNode = selection.$anchor.doc.nodeAt(cell.pos) + if (cellNode && allowed.has(cellNode.type.name)) { + results.push({ node: cellNode, pos: cell.pos }) + return results + } + } + + // Fallback: find parent nodes of allowed types + const parentNode = findParentNodeClosestToPos($anchor, (node) => + allowed.has(node.type.name) + ) + + if (parentNode) { + results.push({ node: parentNode.node, pos: parentNode.pos }) + } + + return results +} + +/** + * Clamps a value between min and max bounds + */ +export function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(value, max)) +} + +export function getSelectedBlockNodes(editor: Editor): PMNode[] { + const { doc } = editor.state + const { from, to } = editor.state.selection + + const blocks: PMNode[] = [] + const seen = new Set() + + doc.nodesBetween(from, to, (node, pos) => { + if (!node.isBlock) return + + if (!seen.has(pos)) { + seen.add(pos) + blocks.push(node) + } + + return false + }) + + return blocks +} diff --git a/resources/js/pages/simple.tsx b/resources/js/pages/simple.tsx new file mode 100644 index 0000000..5bb89be --- /dev/null +++ b/resources/js/pages/simple.tsx @@ -0,0 +1,5 @@ +import { SimpleEditor } from "@/components/tiptap-templates/simple/simple-editor" + +export default function Page() { + return +} diff --git a/resources/js/styles/_keyframe-animations.scss b/resources/js/styles/_keyframe-animations.scss new file mode 100644 index 0000000..dd98b7c --- /dev/null +++ b/resources/js/styles/_keyframe-animations.scss @@ -0,0 +1,91 @@ +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes fadeOut { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +@keyframes zoomIn { + from { + transform: scale(0.95); + } + to { + transform: scale(1); + } +} + +@keyframes zoomOut { + from { + transform: scale(1); + } + to { + transform: scale(0.95); + } +} + +@keyframes zoom { + 0% { + opacity: 0; + transform: scale(0.95); + } + 100% { + opacity: 1; + transform: scale(1); + } +} + +@keyframes slideFromTop { + from { + transform: translateY(-0.5rem); + } + to { + transform: translateY(0); + } +} + +@keyframes slideFromRight { + from { + transform: translateX(0.5rem); + } + to { + transform: translateX(0); + } +} + +@keyframes slideFromLeft { + from { + transform: translateX(-0.5rem); + } + to { + transform: translateX(0); + } +} + +@keyframes slideFromBottom { + from { + transform: translateY(0.5rem); + } + to { + transform: translateY(0); + } +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} diff --git a/resources/js/styles/_variables.scss b/resources/js/styles/_variables.scss new file mode 100644 index 0000000..113a16b --- /dev/null +++ b/resources/js/styles/_variables.scss @@ -0,0 +1,296 @@ +:root { + /****************** + Basics + ******************/ + + overflow-wrap: break-word; + text-size-adjust: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + + /****************** + Colors variables + ******************/ + + /* Gray alpha (light mode) */ + --tt-gray-light-a-50: rgba(56, 56, 56, 0.04); + --tt-gray-light-a-100: rgba(15, 22, 36, 0.05); + --tt-gray-light-a-200: rgba(37, 39, 45, 0.1); + --tt-gray-light-a-300: rgba(47, 50, 55, 0.2); + --tt-gray-light-a-400: rgba(40, 44, 51, 0.42); + --tt-gray-light-a-500: rgba(52, 55, 60, 0.64); + --tt-gray-light-a-600: rgba(36, 39, 46, 0.78); + --tt-gray-light-a-700: rgba(35, 37, 42, 0.87); + --tt-gray-light-a-800: rgba(30, 32, 36, 0.95); + --tt-gray-light-a-900: rgba(29, 30, 32, 0.98); + + /* Gray (light mode) */ + --tt-gray-light-50: rgba(250, 250, 250, 1); + --tt-gray-light-100: rgba(244, 244, 245, 1); + --tt-gray-light-200: rgba(234, 234, 235, 1); + --tt-gray-light-300: rgba(213, 214, 215, 1); + --tt-gray-light-400: rgba(166, 167, 171, 1); + --tt-gray-light-500: rgba(125, 127, 130, 1); + --tt-gray-light-600: rgba(83, 86, 90, 1); + --tt-gray-light-700: rgba(64, 65, 69, 1); + --tt-gray-light-800: rgba(44, 45, 48, 1); + --tt-gray-light-900: rgba(34, 35, 37, 1); + + /* Gray alpha (dark mode) */ + --tt-gray-dark-a-50: rgba(232, 232, 253, 0.05); + --tt-gray-dark-a-100: rgba(231, 231, 243, 0.07); + --tt-gray-dark-a-200: rgba(238, 238, 246, 0.11); + --tt-gray-dark-a-300: rgba(239, 239, 245, 0.22); + --tt-gray-dark-a-400: rgba(244, 244, 255, 0.37); + --tt-gray-dark-a-500: rgba(236, 238, 253, 0.5); + --tt-gray-dark-a-600: rgba(247, 247, 253, 0.64); + --tt-gray-dark-a-700: rgba(251, 251, 254, 0.75); + --tt-gray-dark-a-800: rgba(253, 253, 253, 0.88); + --tt-gray-dark-a-900: rgba(255, 255, 255, 0.96); + + /* Gray (dark mode) */ + --tt-gray-dark-50: rgba(25, 25, 26, 1); + --tt-gray-dark-100: rgba(32, 32, 34, 1); + --tt-gray-dark-200: rgba(45, 45, 47, 1); + --tt-gray-dark-300: rgba(70, 70, 73, 1); + --tt-gray-dark-400: rgba(99, 99, 105, 1); + --tt-gray-dark-500: rgba(124, 124, 131, 1); + --tt-gray-dark-600: rgba(163, 163, 168, 1); + --tt-gray-dark-700: rgba(192, 192, 195, 1); + --tt-gray-dark-800: rgba(224, 224, 225, 1); + --tt-gray-dark-900: rgba(245, 245, 245, 1); + + /* Brand colors */ + --tt-brand-color-50: rgba(239, 238, 255, 1); + --tt-brand-color-100: rgba(222, 219, 255, 1); + --tt-brand-color-200: rgba(195, 189, 255, 1); + --tt-brand-color-300: rgba(157, 138, 255, 1); + --tt-brand-color-400: rgba(122, 82, 255, 1); + --tt-brand-color-500: rgba(98, 41, 255, 1); + --tt-brand-color-600: rgba(84, 0, 229, 1); + --tt-brand-color-700: rgba(75, 0, 204, 1); + --tt-brand-color-800: rgba(56, 0, 153, 1); + --tt-brand-color-900: rgba(43, 25, 102, 1); + --tt-brand-color-950: hsla(257, 100%, 9%, 1); + + /* Green */ + --tt-color-green-inc-5: hsla(129, 100%, 97%, 1); + --tt-color-green-inc-4: hsla(129, 100%, 92%, 1); + --tt-color-green-inc-3: hsla(131, 100%, 86%, 1); + --tt-color-green-inc-2: hsla(133, 98%, 78%, 1); + --tt-color-green-inc-1: hsla(137, 99%, 70%, 1); + --tt-color-green-base: hsla(147, 99%, 50%, 1); + --tt-color-green-dec-1: hsla(147, 97%, 41%, 1); + --tt-color-green-dec-2: hsla(146, 98%, 32%, 1); + --tt-color-green-dec-3: hsla(146, 100%, 24%, 1); + --tt-color-green-dec-4: hsla(144, 100%, 16%, 1); + --tt-color-green-dec-5: hsla(140, 100%, 9%, 1); + + /* Yellow */ + --tt-color-yellow-inc-5: hsla(50, 100%, 97%, 1); + --tt-color-yellow-inc-4: hsla(50, 100%, 91%, 1); + --tt-color-yellow-inc-3: hsla(50, 100%, 84%, 1); + --tt-color-yellow-inc-2: hsla(50, 100%, 77%, 1); + --tt-color-yellow-inc-1: hsla(50, 100%, 68%, 1); + --tt-color-yellow-base: hsla(52, 100%, 50%, 1); + --tt-color-yellow-dec-1: hsla(52, 100%, 41%, 1); + --tt-color-yellow-dec-2: hsla(52, 100%, 32%, 1); + --tt-color-yellow-dec-3: hsla(52, 100%, 24%, 1); + --tt-color-yellow-dec-4: hsla(51, 100%, 16%, 1); + --tt-color-yellow-dec-5: hsla(50, 100%, 9%, 1); + + /* Red */ + --tt-color-red-inc-5: hsla(11, 100%, 96%, 1); + --tt-color-red-inc-4: hsla(11, 100%, 88%, 1); + --tt-color-red-inc-3: hsla(10, 100%, 80%, 1); + --tt-color-red-inc-2: hsla(9, 100%, 73%, 1); + --tt-color-red-inc-1: hsla(7, 100%, 64%, 1); + --tt-color-red-base: hsla(7, 100%, 54%, 1); + --tt-color-red-dec-1: hsla(7, 100%, 41%, 1); + --tt-color-red-dec-2: hsla(5, 100%, 32%, 1); + --tt-color-red-dec-3: hsla(4, 100%, 24%, 1); + --tt-color-red-dec-4: hsla(3, 100%, 16%, 1); + --tt-color-red-dec-5: hsla(1, 100%, 9%, 1); + + /* Basic colors */ + --white: rgba(255, 255, 255, 1); + --black: rgba(14, 14, 17, 1); + --transparent: rgba(255, 255, 255, 0); + + /****************** + Shadow variables + ******************/ + + /* Shadows Light */ + --tt-shadow-elevated-md: + 0px 16px 48px 0px rgba(17, 24, 39, 0.04), + 0px 12px 24px 0px rgba(17, 24, 39, 0.04), + 0px 6px 8px 0px rgba(17, 24, 39, 0.02), + 0px 2px 3px 0px rgba(17, 24, 39, 0.02); + + /************************************************** + Radius variables + **************************************************/ + + --tt-radius-xxs: 0.125rem; /* 2px */ + --tt-radius-xs: 0.25rem; /* 4px */ + --tt-radius-sm: 0.375rem; /* 6px */ + --tt-radius-md: 0.5rem; /* 8px */ + --tt-radius-lg: 0.75rem; /* 12px */ + --tt-radius-xl: 1rem; /* 16px */ + + /************************************************** + Transition variables + **************************************************/ + + --tt-transition-duration-short: 0.1s; + --tt-transition-duration-default: 0.2s; + --tt-transition-duration-long: 0.64s; + --tt-transition-easing-default: cubic-bezier(0.46, 0.03, 0.52, 0.96); + --tt-transition-easing-cubic: cubic-bezier(0.65, 0.05, 0.36, 1); + --tt-transition-easing-quart: cubic-bezier(0.77, 0, 0.18, 1); + --tt-transition-easing-circ: cubic-bezier(0.79, 0.14, 0.15, 0.86); + --tt-transition-easing-back: cubic-bezier(0.68, -0.55, 0.27, 1.55); + + /****************** + Contrast variables + ******************/ + + --tt-accent-contrast: 8%; + --tt-destructive-contrast: 8%; + --tt-foreground-contrast: 8%; + + &, + *, + ::before, + ::after { + box-sizing: border-box; + transition: none var(--tt-transition-duration-default) + var(--tt-transition-easing-default); + } +} + +:root { + /************************************************** + Global colors + **************************************************/ + + /* Global colors - Light mode */ + --tt-bg-color: var(--white); + --tt-border-color: var(--tt-gray-light-a-200); + --tt-border-color-tint: var(--tt-gray-light-a-100); + --tt-sidebar-bg-color: var(--tt-gray-light-100); + --tt-scrollbar-color: var(--tt-gray-light-a-200); + --tt-cursor-color: var(--tt-brand-color-500); + --tt-selection-color: rgba(157, 138, 255, 0.2); + --tt-card-bg-color: var(--white); + --tt-card-border-color: var(--tt-gray-light-a-100); +} + +/* Global colors - Dark mode */ +.dark { + --tt-bg-color: var(--black); + --tt-border-color: var(--tt-gray-dark-a-200); + --tt-border-color-tint: var(--tt-gray-dark-a-100); + --tt-sidebar-bg-color: var(--tt-gray-dark-100); + --tt-scrollbar-color: var(--tt-gray-dark-a-200); + --tt-cursor-color: var(--tt-brand-color-400); + --tt-selection-color: rgba(122, 82, 255, 0.2); + --tt-card-bg-color: var(--tt-gray-dark-50); + --tt-card-border-color: var(--tt-gray-dark-a-50); + + --tt-shadow-elevated-md: + 0px 16px 48px 0px rgba(0, 0, 0, 0.5), 0px 12px 24px 0px rgba(0, 0, 0, 0.24), + 0px 6px 8px 0px rgba(0, 0, 0, 0.22), 0px 2px 3px 0px rgba(0, 0, 0, 0.12); +} + +/* Text colors */ +:root { + --tt-color-text-gray: hsl(45, 2%, 46%); + --tt-color-text-brown: hsl(19, 31%, 47%); + --tt-color-text-orange: hsl(30, 89%, 45%); + --tt-color-text-yellow: hsl(38, 62%, 49%); + --tt-color-text-green: hsl(148, 32%, 39%); + --tt-color-text-blue: hsl(202, 54%, 43%); + --tt-color-text-purple: hsl(274, 32%, 54%); + --tt-color-text-pink: hsl(328, 49%, 53%); + --tt-color-text-red: hsl(2, 62%, 55%); + + --tt-color-text-gray-contrast: hsla(39, 26%, 26%, 0.15); + --tt-color-text-brown-contrast: hsla(18, 43%, 69%, 0.35); + --tt-color-text-orange-contrast: hsla(24, 73%, 55%, 0.27); + --tt-color-text-yellow-contrast: hsla(44, 82%, 59%, 0.39); + --tt-color-text-green-contrast: hsla(126, 29%, 60%, 0.27); + --tt-color-text-blue-contrast: hsla(202, 54%, 59%, 0.27); + --tt-color-text-purple-contrast: hsla(274, 37%, 64%, 0.27); + --tt-color-text-pink-contrast: hsla(331, 60%, 71%, 0.27); + --tt-color-text-red-contrast: hsla(8, 79%, 79%, 0.4); +} + +.dark { + --tt-color-text-gray: hsl(0, 0%, 61%); + --tt-color-text-brown: hsl(18, 35%, 58%); + --tt-color-text-orange: hsl(25, 53%, 53%); + --tt-color-text-yellow: hsl(36, 54%, 55%); + --tt-color-text-green: hsl(145, 32%, 47%); + --tt-color-text-blue: hsl(202, 64%, 52%); + --tt-color-text-purple: hsl(270, 55%, 62%); + --tt-color-text-pink: hsl(329, 57%, 58%); + --tt-color-text-red: hsl(1, 69%, 60%); + + --tt-color-text-gray-contrast: hsla(0, 0%, 100%, 0.09); + --tt-color-text-brown-contrast: hsla(17, 45%, 50%, 0.25); + --tt-color-text-orange-contrast: hsla(27, 82%, 53%, 0.2); + --tt-color-text-yellow-contrast: hsla(35, 49%, 47%, 0.2); + --tt-color-text-green-contrast: hsla(151, 55%, 39%, 0.2); + --tt-color-text-blue-contrast: hsla(202, 54%, 43%, 0.2); + --tt-color-text-purple-contrast: hsla(271, 56%, 60%, 0.18); + --tt-color-text-pink-contrast: hsla(331, 67%, 58%, 0.22); + --tt-color-text-red-contrast: hsla(0, 67%, 60%, 0.25); +} + +/* Highlight colors */ +:root { + --tt-color-highlight-yellow: #fef9c3; + --tt-color-highlight-green: #dcfce7; + --tt-color-highlight-blue: #e0f2fe; + --tt-color-highlight-purple: #f3e8ff; + --tt-color-highlight-red: #ffe4e6; + --tt-color-highlight-gray: rgb(248, 248, 247); + --tt-color-highlight-brown: rgb(244, 238, 238); + --tt-color-highlight-orange: rgb(251, 236, 221); + --tt-color-highlight-pink: rgb(252, 241, 246); + + --tt-color-highlight-yellow-contrast: #fbe604; + --tt-color-highlight-green-contrast: #c7fad8; + --tt-color-highlight-blue-contrast: #ceeafd; + --tt-color-highlight-purple-contrast: #e4ccff; + --tt-color-highlight-red-contrast: #ffccd0; + --tt-color-highlight-gray-contrast: rgba(84, 72, 49, 0.15); + --tt-color-highlight-brown-contrast: rgba(210, 162, 141, 0.35); + --tt-color-highlight-orange-contrast: rgba(224, 124, 57, 0.27); + --tt-color-highlight-pink-contrast: rgba(225, 136, 179, 0.27); +} + +.dark { + --tt-color-highlight-yellow: #6b6524; + --tt-color-highlight-green: #509568; + --tt-color-highlight-blue: #6e92aa; + --tt-color-highlight-purple: #583e74; + --tt-color-highlight-red: #743e42; + --tt-color-highlight-gray: rgb(47, 47, 47); + --tt-color-highlight-brown: rgb(74, 50, 40); + --tt-color-highlight-orange: rgb(92, 59, 35); + --tt-color-highlight-pink: rgb(78, 44, 60); + + --tt-color-highlight-yellow-contrast: #58531e; + --tt-color-highlight-green-contrast: #47855d; + --tt-color-highlight-blue-contrast: #5e86a1; + --tt-color-highlight-purple-contrast: #4c3564; + --tt-color-highlight-red-contrast: #643539; + --tt-color-highlight-gray-contrast: rgba(255, 255, 255, 0.094); + --tt-color-highlight-brown-contrast: rgba(184, 101, 69, 0.25); + --tt-color-highlight-orange-contrast: rgba(233, 126, 37, 0.2); + --tt-color-highlight-pink-contrast: rgba(220, 76, 145, 0.22); +} diff --git a/scss.d.ts b/scss.d.ts new file mode 100644 index 0000000..0292a33 --- /dev/null +++ b/scss.d.ts @@ -0,0 +1 @@ +declare module "*.scss";