├── 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 |

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 |
8 |
95 |
96 |
97 |
98 |
99 |
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 |
--------------------------------------------------------------------------------