├── shims ├── alpine.d.ts └── image-blob-reduce.d.ts ├── screenshot.png ├── .github ├── FUNDING.yml └── workflows │ ├── release.yml │ └── ci.yml ├── .gitignore ├── babel.config.js ├── .editorconfig ├── config └── laravel-tiptap.php ├── phpstan.neon.dist ├── tsconfig.json ├── src ├── ServiceProvider.php └── GenerateImageUploadConfigController.php ├── package.json ├── rollup.config.js ├── composer.json ├── README.md ├── packages └── laravel-tiptap │ ├── package.json │ └── src │ └── index.ts └── resources └── views └── components └── tiptap-editor.blade.php /shims/alpine.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'alpinejs' -------------------------------------------------------------------------------- /shims/image-blob-reduce.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'image-blob-reduce' 2 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/georgeboot/laravel-tiptap/HEAD/screenshot.png -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [georgeboot] 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist 3 | yarn.lock 4 | vendor/ 5 | .phpunit.result.cache 6 | yarn-error.log 7 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@babel/preset-env', 4 | '@babel/preset-react', 5 | ], 6 | plugins: [ 7 | '@babel/plugin-proposal-nullish-coalescing-operator', 8 | '@babel/plugin-proposal-optional-chaining', 9 | ], 10 | } 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 4 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | 14 | [*.{yml,yaml}] 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /config/laravel-tiptap.php: -------------------------------------------------------------------------------- 1 | [ 6 | 'disk' => env('FILESYSTEM_DRIVER', 's3'), 7 | 'maxSize' => 1000, 8 | 'middleware' => [ 9 | 'web', 10 | 'auth', // require login 11 | ], 12 | ], 13 | 14 | ]; 15 | -------------------------------------------------------------------------------- /phpstan.neon.dist: -------------------------------------------------------------------------------- 1 | includes: 2 | - ./vendor/nunomaduro/larastan/extension.neon 3 | - ./vendor/spatie/invade/phpstan-extension.neon 4 | 5 | parameters: 6 | 7 | paths: 8 | - src 9 | # - tests 10 | 11 | level: max 12 | 13 | ignoreErrors: 14 | - '#^Cannot call method debug\(\) on Illuminate\\Log\\LogManager\|null.$#' 15 | - '#^Call to an undefined method Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage::withArgs\(\).$#' 16 | # 17 | # excludePaths: 18 | # - ./*/*/FileToBeExcluded.php 19 | 20 | checkMissingIterableValueType: false 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2019", 4 | "module": "esnext", 5 | "strict": true, 6 | "importHelpers": true, 7 | "moduleResolution": "node", 8 | "esModuleInterop": true, 9 | "allowSyntheticDefaultImports": true, 10 | "experimentalDecorators": true, 11 | "sourceMap": true, 12 | "baseUrl": ".", 13 | "rootDir": ".", 14 | "allowJs": false, 15 | "checkJs": false, 16 | "paths": { 17 | "laravel-tiptap": ["packages/*/dist", "packages/*/src"] 18 | }, 19 | "lib": [ 20 | "esnext", 21 | "dom", 22 | "dom.iterable", 23 | "scripthost" 24 | ] 25 | }, 26 | "files": [ 27 | "./shims/alpine.d.ts", 28 | "./shims/image-blob-reduce.d.ts" 29 | ], 30 | "include": [ 31 | "**/*.ts", 32 | "**/*.tsx" 33 | ], 34 | "exclude": [ 35 | "**/node_modules", 36 | "**/dist", 37 | "**/vue-3" 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /src/ServiceProvider.php: -------------------------------------------------------------------------------- 1 | mergeConfigFrom( 14 | __DIR__ . '/../config/laravel-tiptap.php', 15 | 'laravel-tiptap' 16 | ); 17 | } 18 | 19 | public function boot(): void 20 | { 21 | $this->publishes([ 22 | __DIR__ . '/../config/laravel-tiptap.php' => config_path('laravel-tiptap.php'), 23 | ], 'laravel-tiptap-config'); 24 | 25 | $this->loadViewsFrom(__DIR__ . '/../resources/views', 'laravel-tiptap'); 26 | 27 | Blade::component('laravel-tiptap::components.tiptap-editor', 'tiptap-editor'); 28 | 29 | if ($this->app->routesAreCached()) { 30 | return; 31 | } 32 | 33 | Route::post( 34 | '/laravel-tiptap/generate-image-upload-config', 35 | GenerateImageUploadConfigController::class, 36 | )->middleware(config('laravel-tiptap.images.middleware')); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Publish new release 2 | 3 | on: 4 | release: 5 | types: [ published ] 6 | 7 | jobs: 8 | release: 9 | name: Create NPM release 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | with: 15 | ref: ${{ github.event.release.target_commitish }} 16 | - name: Use Node.js 16 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: 16 20 | registry-url: https://registry.npmjs.org/ 21 | - run: yarn install --frozen-lockfile 22 | - run: git config --global user.name "GitHub CD bot" 23 | - run: git config --global user.email "github-cd-bot@github.com" 24 | - run: yarn version --new-version ${{ github.event.release.tag_name }} --no-git-tag-version 25 | working-directory: packages/laravel-tiptap 26 | - run: yarn publish --access public --tag latest 27 | working-directory: packages/laravel-tiptap 28 | env: 29 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 30 | # - run: git add package.json 31 | # - run: git commit -m 'Bump version' 32 | # - run: git push 33 | # env: 34 | # github-token: ${{ secrets.GITHUB_TOKEN }} 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "workspaces": [ 4 | "packages/*" 5 | ], 6 | "browserslist": [ 7 | "defaults", 8 | "not IE 11" 9 | ], 10 | "scripts": { 11 | "build": "./node_modules/.bin/rollup -c", 12 | "lint": "eslint --ext .js,.ts ./packages", 13 | "prepublish": "yarn run build", 14 | "release": "yarn run test && standard-version && git push --follow-tags && yarn publish" 15 | }, 16 | "devDependencies": { 17 | "@babel/core": "^7.8.3", 18 | "@babel/plugin-proposal-decorators": "^7.8.3", 19 | "@babel/plugin-proposal-export-namespace-from": "^7.8.3", 20 | "@babel/plugin-proposal-function-sent": "^7.8.3", 21 | "@babel/plugin-proposal-numeric-separator": "^7.8.3", 22 | "@babel/plugin-proposal-throw-expressions": "^7.8.3", 23 | "@babel/plugin-transform-object-assign": "^7.8.3", 24 | "@babel/preset-env": "^7.9.6", 25 | "@rollup/plugin-babel": "^5.0.0", 26 | "@rollup/plugin-commonjs": "^20.0.0", 27 | "@types/js-cookie": "^2.2.7", 28 | "@typescript-eslint/eslint-plugin": "^3.7.0", 29 | "@typescript-eslint/parser": "^3.7.0", 30 | "jest-websocket-mock": "^2.2.0", 31 | "laravel-echo": "^1.10.0", 32 | "mock-socket": "^9.0.3", 33 | "rollup": "^2.10.2", 34 | "rollup-plugin-typescript2": "^0.27.1", 35 | "standard-version": "^8.0.1", 36 | "tslib": "^1.10.0", 37 | "typescript": "^3.6.3" 38 | }, 39 | "dependencies": {} 40 | } 41 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from '@rollup/plugin-babel' 2 | import typescript from 'rollup-plugin-typescript2' 3 | import commonjs from '@rollup/plugin-commonjs' 4 | 5 | export default { 6 | input: './packages/laravel-tiptap/src/index.ts', 7 | output: [ 8 | { 9 | name: 'laravel-tiptap', 10 | file: './packages/laravel-tiptap/dist/laravel-tiptap.umd.js', 11 | format: 'umd', 12 | sourcemap: true, 13 | }, 14 | { 15 | name: 'laravel-tiptap', 16 | file: './packages/laravel-tiptap/dist/laravel-tiptap.cjs.js', 17 | format: 'cjs', 18 | sourcemap: true, 19 | exports: 'auto', 20 | }, 21 | { 22 | name: 'laravel-tiptap', 23 | file: './packages/laravel-tiptap/dist/laravel-tiptap.esm.js', 24 | format: 'es', 25 | sourcemap: true, 26 | }, 27 | ], 28 | plugins: [ 29 | typescript({ 30 | tsconfigOverride: { 31 | compilerOptions: { 32 | declaration: true, 33 | paths: { 34 | 'laravel-tiptap': ['packages/*/src'], 35 | }, 36 | }, 37 | include: null, 38 | }, 39 | }), 40 | babel({ 41 | babelHelpers: 'bundled', 42 | exclude: 'node_modules/**', 43 | }), 44 | commonjs(), 45 | ], 46 | } 47 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "georgeboot/laravel-tiptap", 3 | "description": "Opinionated integration of Tiptap editor using the TALL stack", 4 | "keywords": [ 5 | "laravel", 6 | "tiptap", 7 | "alpinejs", 8 | "livewire", 9 | "tall stack" 10 | ], 11 | "homepage": "https://github.com/georgeboot/laravel-tiptap", 12 | "type": "library", 13 | "license": "MIT", 14 | "authors": [ 15 | { 16 | "name": "George Boot" 17 | } 18 | ], 19 | "require": { 20 | "php": "^8.0", 21 | "ext-json": "*", 22 | "aws/aws-sdk-php": "^3.80", 23 | "laravel/framework": "^6.0|^7.0|^8.0|^9.0|^10.0", 24 | "spatie/invade": "^1.1" 25 | }, 26 | "require-dev": { 27 | "mockery/mockery": "^1.2", 28 | "nunomaduro/larastan": "^2.4", 29 | "orchestra/testbench": "^7.0", 30 | "pestphp/pest": "^1.0", 31 | "phpunit/phpunit": "^8.0|^9.0" 32 | }, 33 | "autoload": { 34 | "psr-4": { 35 | "Georgeboot\\LaravelTiptap\\": "src/" 36 | } 37 | }, 38 | "autoload-dev": { 39 | "psr-4": { 40 | "Tests\\": "tests/" 41 | } 42 | }, 43 | "scripts": { 44 | "test": "vendor/bin/phpunit" 45 | }, 46 | "extra": { 47 | "laravel": { 48 | "providers": [ 49 | "Georgeboot\\LaravelTiptap\\ServiceProvider" 50 | ] 51 | } 52 | }, 53 | "config": { 54 | "sort-packages": true, 55 | "allow-plugins": { 56 | "pestphp/pest-plugin": true 57 | } 58 | }, 59 | "minimum-stability": "dev", 60 | "prefer-stable": true 61 | } 62 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tiptap Editor for TALL stack 2 | 3 |

Screenshot

4 | 5 | ``` 6 | composer require georgeboot/laravel-tiptap 7 | yarn add laravel-tiptap 8 | ``` 9 | 10 | In your `app.js`: 11 | 12 | ```js 13 | import Alpine from 'alpinejs' 14 | import LaravelTiptap from 'laravel-tiptap' // add this 15 | Alpine.data('tiptapEditor', LaravelTiptap) // add this 16 | Alpine.start() 17 | ``` 18 | 19 | In your blade file: 20 | ```blade 21 | 22 | 23 | 24 | 25 | ``` 26 | 27 | ## Image upload 28 | Ensure you have your s3 disk configured correctly in s3: 29 | ```php 30 | // config/filesystems.php 31 | 32 | [ 39 | 40 | // other disks 41 | 42 | 's3' => [ 43 | 'driver' => 's3', 44 | 'key' => env('AWS_ACCESS_KEY_ID'), 45 | 'secret' => env('AWS_SECRET_ACCESS_KEY'), 46 | 'token' => env('AWS_SESSION_TOKEN'), 47 | 'region' => env('AWS_DEFAULT_REGION'), 48 | 'bucket' => env('AWS_BUCKET'), 49 | 'url' => env('AWS_URL'), 50 | // 'url' => 'https://my-cloudfront-id.cloudfront.net', // optional: if you use cloudfront or some other cdn in front of s3 51 | 'endpoint' => env('AWS_ENDPOINT'), 52 | ], 53 | 54 | // ... 55 | 56 | ], 57 | 58 | ]; 59 | ``` 60 | 61 | Add purge directory to TailwindCSS config: 62 | ```js 63 | module.exports = { 64 | purge: [ 65 | // your existing purges 66 | './vendor/georgeboot/laravel-tiptap/resources/views/**/*.blade.php', 67 | ], 68 | } 69 | ``` 70 | -------------------------------------------------------------------------------- /packages/laravel-tiptap/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "laravel-tiptap", 3 | "description": "Opinionated integration of Tiptap editor using the TALL stack", 4 | "version": "latest", 5 | "homepage": "https://github.com/georgeboot/laravel-tiptap", 6 | "keywords": [ 7 | "laravel", 8 | "tiptap", 9 | "alpinejs", 10 | "livewire", 11 | "tall stack" 12 | ], 13 | "license": "MIT", 14 | "funding": { 15 | "type": "github", 16 | "url": "https://github.com/sponsors/georgeboot" 17 | }, 18 | "main": "dist/laravel-tiptap.cjs.js", 19 | "umd": "dist/laravel-tiptap.umd.js", 20 | "module": "dist/laravel-tiptap.esm.js", 21 | "types": "dist/packages/laravel-tiptap/src/index.d.ts", 22 | "files": [ 23 | "src", 24 | "dist" 25 | ], 26 | "dependencies": { 27 | "@tiptap/core": "^2.0.0-beta", 28 | "@tiptap/extension-bold": "^2.0.0-beta", 29 | "@tiptap/extension-bubble-menu": "^2.0.0-beta", 30 | "@tiptap/extension-bullet-list": "^2.0.0-beta", 31 | "@tiptap/extension-document": "^2.0.0-beta", 32 | "@tiptap/extension-heading": "^2.0.0-beta", 33 | "@tiptap/extension-image": "^2.0.0-beta", 34 | "@tiptap/extension-italic": "^2.0.0-beta", 35 | "@tiptap/extension-link": "^2.0.0-beta", 36 | "@tiptap/extension-list-item": "^2.0.0-beta", 37 | "@tiptap/extension-ordered-list": "^2.0.0-beta", 38 | "@tiptap/extension-paragraph": "^2.0.0-beta", 39 | "@tiptap/extension-strike": "^2.0.0-beta", 40 | "@tiptap/extension-text": "^2.0.0-beta", 41 | "image-blob-reduce": "^3.0.1", 42 | "js-cookie": "^3.0.0" 43 | }, 44 | "repository": { 45 | "type": "git", 46 | "url": "https://github.com/georgeboot/laravel-tiptap", 47 | "directory": "packages/laravel-tiptap" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/GenerateImageUploadConfigController.php: -------------------------------------------------------------------------------- 1 | toString(); 21 | $disk = Storage::disk(config('laravel-tiptap.images.disk')); 22 | 23 | // Flysystem V2+ (Laravel 9+) doesn't allow direct access to stuff, so we need to invade instead. 24 | 25 | /** @var \League\Flysystem\FilesystemOperator|\League\Flysystem\FilesystemInterface $driver */ 26 | $driver = $disk->getDriver(); 27 | 28 | /** @var \Illuminate\Filesystem\FilesystemAdapter $adapter */ 29 | $adapter = method_exists($driver, 'getAdapter') 30 | ? $driver->getAdapter() 31 | : invade($driver)->adapter; 32 | 33 | /** @var \Aws\S3\S3Client $client */ 34 | $client = method_exists($adapter, 'getClient') 35 | ? $adapter->getClient() 36 | : invade($adapter)->client; 37 | 38 | $bucketName = method_exists($adapter, 'getBucket') 39 | ? $adapter->getBucket() 40 | : invade($adapter)->bucket; 41 | 42 | $bucketPrefix = method_exists($adapter, 'getPathPrefix') 43 | ? $adapter->getPathPrefix() 44 | : invade($adapter)->prefixer->prefixPath(''); 45 | 46 | $keyPrefix = $bucketPrefix.$uuid; 47 | 48 | $formInputs = [ 49 | 'acl' => 'public-read', 50 | 'success_action_status' => '201', 51 | ]; 52 | 53 | // Construct an array of conditions for policy 54 | $options = [ 55 | ['acl' => 'public-read'], 56 | ['bucket' => $bucketName], 57 | ['success_action_status' => '201'], 58 | ['starts-with', '$key', "{$keyPrefix}/"], 59 | ['starts-with', '$Content-Type', 'image/'], 60 | ]; 61 | 62 | $expires = '+12 hours'; 63 | 64 | $postObject = new \Aws\S3\PostObjectV4( 65 | $client, 66 | $bucketName, 67 | $formInputs, 68 | $options, 69 | $expires 70 | ); 71 | 72 | return response()->json([ 73 | 'uploadUrl' => $postObject->getFormAttributes()['action'], 74 | 'uploadUrlFormData' => $postObject->getFormInputs(), 75 | 'uploadKeyPrefix' => $keyPrefix, 76 | 'downloadUrlPrefix' => $disk->url("{$uuid}/"), 77 | ]); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | # on: 4 | # - pull_request 5 | # - push 6 | 7 | jobs: 8 | pest: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | fail-fast: true 12 | matrix: 13 | php: ['8.0'] 14 | name: Pest - PHP ${{ matrix.php }} 15 | steps: 16 | - uses: actions/checkout@v2 17 | - uses: shivammathur/setup-php@v2 18 | with: 19 | php-version: ${{ matrix.php }} 20 | extensions: dom, mbstring, soap, bcmath, pdo_sqlite, intl 21 | coverage: pcov 22 | - uses: ramsey/composer-install@v1 23 | with: 24 | composer-options: "--prefer-dist --optimize-autoloader --ignore-platform-reqs" 25 | - name: Run PestPHP 26 | run: vendor/bin/pest --coverage --coverage-clover test-results/pest.xml 27 | env: 28 | AWS_ACCESS_KEY_ID: fake 29 | AWS_SECRET_ACCESS_KEY: fake 30 | AWS_SESSION_TOKEN: fake 31 | - name: Upload coverage to Codecov 32 | if: always() 33 | uses: codecov/codecov-action@v2 34 | with: 35 | token: ${{ secrets.CODECOV_TOKEN }} 36 | flags: pest-${{ matrix.php }} 37 | file: test-results/pest.xml 38 | 39 | larastan: 40 | runs-on: ubuntu-latest 41 | name: Larastan 42 | steps: 43 | - uses: actions/checkout@v2 44 | - uses: shivammathur/setup-php@v2 45 | with: 46 | php-version: '8.0' 47 | extensions: soap, bcmath, pdo_sqlite, intl 48 | - uses: ramsey/composer-install@v1 49 | with: 50 | composer-options: "--prefer-dist --optimize-autoloader --ignore-platform-reqs" 51 | - name: Run Larastan 52 | run: vendor/bin/phpstan analyse --no-progress 53 | 54 | eslint: 55 | runs-on: ubuntu-latest 56 | name: ESLint 57 | steps: 58 | - uses: actions/checkout@v2 59 | - name: Install dependencies 60 | run: yarn install 61 | - name: Run ESLint 62 | run: yarn run lint 63 | 64 | jest: 65 | runs-on: ubuntu-latest 66 | name: Jest 67 | steps: 68 | - uses: actions/checkout@v2 69 | - uses: actions/setup-node@v2 70 | with: 71 | node-version: '15' 72 | - name: Install dependencies 73 | run: yarn install 74 | - name: Run Jest 75 | run: yarn test --coverage --coverageDirectory=test-results/jest 76 | - name: Upload coverage to Codecov 77 | if: always() 78 | uses: codecov/codecov-action@v1 79 | with: 80 | token: ${{ secrets.CODECOV_TOKEN }} 81 | flags: jest 82 | file: test-results/jest/clover.xml 83 | 84 | # phpcs: 85 | # runs-on: ubuntu-latest 86 | # name: PHP Style Fixer 87 | # steps: 88 | # - uses: actions/checkout@v2 89 | # - uses: shivammathur/setup-php@v2 90 | # with: 91 | # php-version: '7.4' 92 | # extensions: soap, bcmath, pdo_sqlite 93 | # - name: Get Composer Cache Directory 94 | # id: composer-cache 95 | # run: echo "::set-output name=dir::$(composer config cache-files-dir)" 96 | # - uses: actions/cache@v2 97 | # with: 98 | # path: ${{ steps.composer-cache.outputs.dir }} 99 | # key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} 100 | # restore-keys: ${{ runner.os }}-composer- 101 | # - name: Install Composer dependencies 102 | # run: composer install --no-interaction --no-progress --prefer-dist --optimize-autoloader --ignore-platform-reqs 103 | # - name: Run PHP-CS-Fixer 104 | # run: vendor/bin/php-cs-fixer fix --dry-run --diff --allow-risky=yes 105 | -------------------------------------------------------------------------------- /resources/views/components/tiptap-editor.blade.php: -------------------------------------------------------------------------------- 1 |
whereDoesntStartWith('wire:model') }} 5 | class="block w-full bg-white border border-gray-300 rounded-md shadow-sm focus:ring-blue-200 focus:border-blue-300 focus:ring focus:ring-opacity-75 sm:text-sm" 6 | > 7 | 96 | 97 | 100 | 101 |
102 | 103 | 104 | visit 105 |
106 | 107 |
108 | 109 |
110 | 111 |
116 |
117 | -------------------------------------------------------------------------------- /packages/laravel-tiptap/src/index.ts: -------------------------------------------------------------------------------- 1 | import Alpine from 'alpinejs' 2 | import {Editor} from '@tiptap/core' 3 | import ExtensionBold from '@tiptap/extension-bold' 4 | import ExtensionBubbleMenu from '@tiptap/extension-bubble-menu' 5 | import ExtensionHeading from '@tiptap/extension-heading' 6 | import ExtensionImage from '@tiptap/extension-image' 7 | import ExtensionItalic from '@tiptap/extension-italic' 8 | import ExtensionLink from '@tiptap/extension-link' 9 | import ExtensionOrderedList from '@tiptap/extension-ordered-list' 10 | import ExtensionBulletList from '@tiptap/extension-bullet-list' 11 | import ExtensionListItem from '@tiptap/extension-list-item' 12 | import ExtensionParagraph from '@tiptap/extension-paragraph' 13 | import ExtensionStrike from '@tiptap/extension-strike' 14 | import ExtensionText from '@tiptap/extension-text' 15 | import ExtensionDocument from '@tiptap/extension-document' 16 | import 'tippy.js/dist/tippy.css' 17 | import ImageBlobReduce from 'image-blob-reduce' 18 | import { TextSelection } from 'prosemirror-state' 19 | import Cookies from 'js-cookie' 20 | 21 | interface GetsS3UrlResponse { 22 | uploadUrl: string 23 | uploadUrlFormData: { [key: string]: string } 24 | uploadKeyPrefix: string 25 | downloadUrlPrefix: string 26 | } 27 | 28 | const data = (content: any, userOptions: any) => ({ 29 | editor: null, 30 | content, 31 | isBold: false, 32 | isItalic: false, 33 | isStrike: false, 34 | isHeading: false, 35 | isLink: false, 36 | isImage: false, 37 | options: { 38 | enableImageUpload: false, 39 | enableLinks: true, 40 | maxSize: 1000, 41 | generateImageUploadConfigUrl: '/laravel-tiptap/generate-image-upload-config', 42 | ...userOptions, 43 | }, 44 | imageUploadConfig: null as null | GetsS3UrlResponse, 45 | init() { 46 | // @ts-ignore 47 | this.editor = new Editor({ 48 | // @ts-ignore 49 | element: this.$refs['editor'], 50 | extensions: [ 51 | ExtensionBold, 52 | ExtensionBubbleMenu.configure({ 53 | pluginKey: 'link-bubble-menu', 54 | // @ts-ignore 55 | element: this.$refs['link-bubble-menu'], 56 | shouldShow: ({ editor }) => editor.isActive('link'), 57 | }), 58 | ExtensionBubbleMenu.configure({ 59 | pluginKey: 'image-bubble-menu', 60 | // @ts-ignore 61 | element: this.$refs['image-bubble-menu'], 62 | shouldShow: ({ editor }) => editor.isActive('image'), 63 | }), 64 | ExtensionHeading, 65 | ExtensionImage, 66 | ExtensionItalic, 67 | ExtensionLink.configure({ 68 | HTMLAttributes: { target: '_blank', rel: 'noopener' }, 69 | openOnClick: false, 70 | }), 71 | ExtensionOrderedList, 72 | ExtensionBulletList, 73 | ExtensionListItem, 74 | ExtensionParagraph, 75 | ExtensionStrike, 76 | ExtensionText, 77 | ExtensionDocument, 78 | ], 79 | content: this.content, 80 | onUpdate: ({ editor }) => { 81 | this.content = editor.getHTML() 82 | }, 83 | onSelectionUpdate: () => { 84 | // @ts-ignore 85 | this.isBold = this.editor.isActive('bold') 86 | // @ts-ignore 87 | this.isItalic = this.editor.isActive('italic') 88 | // @ts-ignore 89 | this.isStrike = this.editor.isActive('strike') 90 | // @ts-ignore 91 | this.isHeading = this.editor.isActive('heading') 92 | // @ts-ignore 93 | this.isLink = this.editor.isActive('link') 94 | // @ts-ignore 95 | this.isImage = this.editor.isActive('image') 96 | }, 97 | }) 98 | 99 | // @ts-ignore 100 | this.$watch('content', (content) => { 101 | // If the new content matches TipTap's then we just skip. 102 | if (content === Alpine.raw(this.editor).getHTML()) return 103 | /* 104 | Otherwise, it means that a force external to TipTap 105 | is modifying the data on this Alpine component, 106 | which could be Livewire itself. 107 | In this case, we just need to update TipTap's 108 | content and we're good to do. 109 | For more information on the `setContent()` method, see: 110 | https://www.tiptap.dev/api/commands/set-content 111 | */ 112 | Alpine.raw(this.editor).commands.setContent(content, false) 113 | }) 114 | }, 115 | 116 | get currentLinkHref() { 117 | if (! this.editor) return '' 118 | 119 | const state = Alpine.raw(this.editor).state 120 | const { from, to } = state.selection 121 | 122 | let marks: any = [] 123 | state.doc.nodesBetween(from, to, (node: any) => { 124 | marks = [...marks, ...node.marks] 125 | }) 126 | 127 | const mark = marks.find((markItem: any) => markItem.type.name === 'link') 128 | 129 | return mark && mark.attrs.href ? mark.attrs.href : '' 130 | }, 131 | 132 | captureLinkHref() { 133 | const href = window.prompt('Please give the URL', this.currentLinkHref) 134 | 135 | if (! href) return 136 | 137 | Alpine.raw(this.editor).chain().extendMarkRange('link').setLink({ href }).focus().run() 138 | }, 139 | 140 | removeLink() { 141 | Alpine.raw(this.editor).chain().extendMarkRange('link').unsetLink().focus().run() 142 | }, 143 | 144 | showFilePicker() { 145 | // @ts-ignore 146 | this.$refs['picker'].click() 147 | }, 148 | 149 | async handleFileSelect(event: Event) { 150 | const target = event.target as HTMLInputElement 151 | 152 | if (!target.files?.length) return 153 | 154 | for await (const file of Array.from(target.files)) { 155 | await this.handleUpload(file) 156 | } 157 | 158 | // reset picker 159 | (event.target).value = '' 160 | }, 161 | 162 | async handleFileDrop(event: DragEvent) { 163 | event.stopPropagation() 164 | event.preventDefault() 165 | 166 | if (!event.dataTransfer) return 167 | 168 | const editor = Alpine.raw(this.editor) as Editor 169 | const coordinates = editor.view.posAtCoords({ left: event.clientX, top: event.clientY }) 170 | 171 | for await (const file of Array.from(event.dataTransfer.files)) { 172 | if (! file.type.startsWith('image/')) { 173 | return 174 | } 175 | 176 | await this.handleUpload(file, coordinates?.pos) 177 | } 178 | }, 179 | 180 | async getImageUploadConfig(): Promise { 181 | if (this.imageUploadConfig) return this.imageUploadConfig 182 | 183 | const getsS3UrlResponse = await typedFetch(this.options.generateImageUploadConfigUrl, { 184 | method: 'post', 185 | headers: [ 186 | ['X-XSRF-TOKEN', Cookies.get('XSRF-TOKEN') ?? ''], 187 | ], 188 | }) 189 | 190 | if (!getsS3UrlResponse.data) { 191 | throw 'Something went wrong' 192 | } 193 | 194 | this.imageUploadConfig = getsS3UrlResponse.data 195 | 196 | return this.imageUploadConfig 197 | }, 198 | 199 | async handleUpload(file: File, position?: number) { 200 | const imageUploadConfig = await this.getImageUploadConfig() 201 | 202 | const formData = new FormData() 203 | for (const [key, value] of Object.entries(imageUploadConfig.uploadUrlFormData)) { 204 | formData.set(key, value) 205 | } 206 | 207 | // resize our image 208 | const resizer = ImageBlobReduce() 209 | const resizedFile = await resizer.toBlob(file, { 210 | max: this.options.maxSize, 211 | }) 212 | 213 | formData.set('Content-Type', file.type) 214 | formData.set('key', `${imageUploadConfig.uploadKeyPrefix}/${file.name}`) 215 | formData.append('file', resizedFile) 216 | 217 | const uploadResponse = await typedFetch(imageUploadConfig.uploadUrl, { 218 | method: 'post', 219 | body: formData, 220 | }) 221 | 222 | if (uploadResponse.status !== 201) { 223 | throw 'something went wrong while uploading the image' 224 | } 225 | 226 | const imageUrl = `${imageUploadConfig.downloadUrlPrefix}${file.name}` 227 | 228 | const editor = Alpine.raw(this.editor) as Editor 229 | 230 | const node = editor.schema.nodes.image.create({ 231 | src: imageUrl, 232 | }) 233 | 234 | const insertTransaction = editor.view.state.tr.insert( 235 | position ?? editor.view.state.selection.anchor, 236 | node 237 | ) 238 | 239 | editor.view.dispatch(insertTransaction) 240 | 241 | const endPos = editor.state.selection.$to.after() - 1 242 | const resolvedPos = editor.state.doc.resolve(endPos) 243 | const moveCursorTransaction = editor.view.state.tr.setSelection(new TextSelection(resolvedPos)) 244 | 245 | editor.view.dispatch(moveCursorTransaction.scrollIntoView()) 246 | }, 247 | 248 | removeImage() { 249 | const state = Alpine.raw(this.editor).state 250 | const view = Alpine.raw(this.editor).view 251 | const transaction = state.tr 252 | const pos = state.selection.$anchor.pos 253 | 254 | const nodeSize = state.selection.node.nodeSize 255 | 256 | transaction.delete(pos, pos + nodeSize) 257 | 258 | view.dispatch(transaction) 259 | }, 260 | }) 261 | 262 | type FetchResponse = Response & { 263 | data?: T 264 | } 265 | 266 | export async function typedFetch( 267 | request: RequestInfo, 268 | init?: RequestInit | undefined, 269 | ): Promise> { 270 | const response = await fetch(request, init) 271 | 272 | const contentType = response.headers.get('content-type') 273 | if (contentType && contentType === 'application/json') { 274 | const data = await response.json() as T 275 | 276 | return { 277 | ...response, 278 | data, 279 | } 280 | } 281 | 282 | return response 283 | } 284 | 285 | export default data 286 | --------------------------------------------------------------------------------