├── .browserslistrc ├── images └── sample.png ├── src ├── plugins │ ├── vuetify.ts │ ├── video.ts │ └── tiptap-kit.ts ├── types │ └── options.ts ├── constants │ ├── toolbarItems.ts │ ├── testHtml.ts │ ├── xssRules.ts │ ├── suggestion.ts │ ├── icons.ts │ └── toolbarDefinitions.ts ├── entry.esm.ts ├── utils │ └── options │ │ └── index.ts ├── entry.ts ├── components │ ├── components.ts │ ├── VideoDialog.vue │ ├── LinkDialog.vue │ ├── EmojiPicker.vue │ ├── ImageDialog.vue │ ├── ColorPicker.vue │ └── VTiptap.vue └── stories │ └── VTiptap.stories.js ├── shims-img.d.ts ├── shims-vue.d.ts ├── dev ├── utils.ts ├── serve.ts ├── plugins │ └── vuetify.ts ├── index.html └── App.vue ├── .prettierrc ├── upgrade.txt ├── .editorconfig ├── vue.config.js ├── shims-tsx.d.ts ├── .gitignore ├── babel.config.js ├── .eslintrc.js ├── .storybook ├── util │ └── helpers.js ├── main.js └── preview.js ├── tsconfig.json ├── .github └── workflows │ ├── chromatic.yml │ └── create-release.yml ├── LICENSE.md ├── package.json └── README.md /.browserslistrc: -------------------------------------------------------------------------------- 1 | current node 2 | last 2 versions and > 2% 3 | ie > 10 4 | -------------------------------------------------------------------------------- /images/sample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peepi-com-br/vuetify-tiptap/HEAD/images/sample.png -------------------------------------------------------------------------------- /src/plugins/vuetify.ts: -------------------------------------------------------------------------------- 1 | import Vuetify from "vuetify"; 2 | 3 | export default new Vuetify(); 4 | -------------------------------------------------------------------------------- /shims-img.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.png" { 2 | const value: string; 3 | export default value; 4 | } 5 | -------------------------------------------------------------------------------- /shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.vue" { 2 | import Vue from "vue"; 3 | export default Vue; 4 | } 5 | -------------------------------------------------------------------------------- /src/types/options.ts: -------------------------------------------------------------------------------- 1 | export interface PluginOptions { 2 | uploadImage: (file: File) => Promise | null; 3 | mentionItems: (query: string) => Promise | null; 4 | } 5 | -------------------------------------------------------------------------------- /dev/utils.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/prefer-default-export 2 | export function titleCase(text: string) { 3 | const result = text.replace(/([A-Z])/g, " $1"); 4 | 5 | return result.charAt(0).toUpperCase() + result.slice(1); 6 | } 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "tabWidth": 2, 4 | "singleQuote": false, 5 | "quoteProps": "as-needed", 6 | "printWidth": 80, 7 | "trailingComma": "es5", 8 | "jsxBracketSameLine": false, 9 | "arrowParens": "avoid", 10 | "bracketSameLine": false 11 | } 12 | -------------------------------------------------------------------------------- /upgrade.txt: -------------------------------------------------------------------------------- 1 | # Commands to update version :) 2 | 3 | npm version minor 4 | git commit -a -m "Version bump to v`node -p -e \"require('./package.json').version\"`" 5 | git push origin main 6 | git tag v`node -p -e \"require('./package.json').version\"` 7 | git push origin main --tags 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # https://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | [*.md] 14 | insert_final_newline = false 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | publicPath: process.env.NODE_ENV === "production" ? "/v-tiptap/" : "/", 3 | outputDir: "dist_demo", 4 | pages: { 5 | index: { 6 | entry: "dev/serve.ts", 7 | template: "dev/index.html", 8 | title: "VTiptap", 9 | }, 10 | }, 11 | transpileDependencies: ["vuetify"], 12 | }; 13 | -------------------------------------------------------------------------------- /dev/serve.ts: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import App from "./App.vue"; 3 | import vuetify from "./plugins/vuetify"; 4 | import "roboto-fontface/css/roboto/roboto-fontface.css"; 5 | import "@mdi/font/css/materialdesignicons.css"; 6 | 7 | Vue.config.productionTip = false; 8 | 9 | new Vue({ 10 | vuetify, 11 | render: h => h(App), 12 | }).$mount("#app"); 13 | -------------------------------------------------------------------------------- /shims-tsx.d.ts: -------------------------------------------------------------------------------- 1 | import Vue, { VNode } from "vue"; 2 | 3 | declare global { 4 | namespace JSX { 5 | // tslint:disable no-empty-interface 6 | interface Element extends VNode {} 7 | // tslint:disable no-empty-interface 8 | interface ElementClass extends Vue {} 9 | interface IntrinsicElements { 10 | [elem: string]: any; 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/constants/toolbarItems.ts: -------------------------------------------------------------------------------- 1 | export default [ 2 | "bold", 3 | "italic", 4 | "underline", 5 | "strike", 6 | "color", 7 | "|", 8 | "headings", 9 | "|", 10 | "left", 11 | "center", 12 | "right", 13 | "justify", 14 | "|", 15 | "bulletList", 16 | "orderedList", 17 | "|", 18 | "link", 19 | "image", 20 | "video", 21 | "emoji", 22 | "|", 23 | "clear", 24 | ]; 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | storybook-static 5 | 6 | /tests/e2e/videos/ 7 | /tests/e2e/screenshots/ 8 | 9 | 10 | # local env files 11 | .env.local 12 | .env.*.local 13 | 14 | # Log files 15 | npm-debug.log* 16 | yarn-debug.log* 17 | yarn-error.log* 18 | pnpm-debug.log* 19 | 20 | # Editor directories and files 21 | .idea 22 | .vscode 23 | *.suo 24 | *.ntvs* 25 | *.njsproj 26 | *.sln 27 | *.sw? 28 | 29 | stats.html 30 | -------------------------------------------------------------------------------- /dev/plugins/vuetify.ts: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import Vuetify from "vuetify/lib"; 3 | import colors from "vuetify/lib/util/colors"; 4 | 5 | Vue.use(Vuetify); 6 | 7 | export default new Vuetify({ 8 | theme: { 9 | themes: { 10 | light: { background: { base: colors.teal.lighten5 } }, 11 | dark: { background: { base: colors.blueGrey.darken4 } }, 12 | }, 13 | }, 14 | icons: { 15 | iconfont: "mdi", 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /src/entry.esm.ts: -------------------------------------------------------------------------------- 1 | import { PluginOptions } from "@/types/options"; 2 | import { mergeOptions } from "@/utils/options"; 3 | import { PluginFunction } from "vue"; 4 | 5 | import VTiptap from "@/components/VTiptap.vue"; 6 | 7 | const install: PluginFunction = (Vue, options?) => { 8 | mergeOptions(options || {}); 9 | 10 | Vue.component("VTiptap", VTiptap); 11 | }; 12 | 13 | export { VTiptap }; 14 | 15 | export default install; 16 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | let presets = ["@vue/babel-preset-app"]; 2 | if (process.env.NODE_ENV === "production") { 3 | presets = [["@babel/preset-env"], "@babel/preset-typescript"]; 4 | if (process.env.DEMO_ENV === "production") { 5 | presets[0].push({ useBuiltIns: "usage", corejs: 3 }); 6 | } 7 | } 8 | 9 | const plugins = []; 10 | 11 | plugins.push(["@babel/plugin-proposal-decorators", { legacy: true }]); 12 | 13 | module.exports = { 14 | presets, 15 | plugins, 16 | }; 17 | -------------------------------------------------------------------------------- /src/utils/options/index.ts: -------------------------------------------------------------------------------- 1 | import { PluginOptions } from "@/types/options"; 2 | 3 | export const DEFAULT_OPTIONS: PluginOptions = { 4 | uploadImage: null, 5 | mentionItems: null, 6 | }; 7 | 8 | export const options = { ...DEFAULT_OPTIONS }; 9 | 10 | export function mergeOptions(newOptions: Partial) { 11 | Object.assign(options, newOptions); 12 | } 13 | 14 | export function getOption( 15 | key: T 16 | ): PluginOptions[T] { 17 | return options[key]; 18 | } 19 | -------------------------------------------------------------------------------- /src/entry.ts: -------------------------------------------------------------------------------- 1 | import plugin, * as elements from "@/entry.esm"; 2 | 3 | type NamedExports = Exclude; 4 | type ExtendedPlugin = typeof plugin & NamedExports; 5 | 6 | Object.entries(elements).forEach(([elementName, element]) => { 7 | if (elementName !== "default") { 8 | const key = elementName as Exclude; 9 | (plugin as ExtendedPlugin)[key] = element as Exclude< 10 | ExtendedPlugin, 11 | typeof plugin 12 | >; 13 | } 14 | }); 15 | 16 | export default plugin; 17 | -------------------------------------------------------------------------------- /src/components/components.ts: -------------------------------------------------------------------------------- 1 | export { default as ImageDialog } from "./ImageDialog.vue"; 2 | export { default as LinkDialog } from "./LinkDialog.vue"; 3 | export { default as VideoDialog } from "./VideoDialog.vue"; 4 | export { default as EmojiPicker } from "./EmojiPicker.vue"; 5 | export { default as ColorPicker } from "./ColorPicker.vue"; 6 | 7 | export { 8 | VAvatar, 9 | VInput, 10 | VCard, 11 | VBtn, 12 | VImg, 13 | VIcon, 14 | VFileInput, 15 | VList, 16 | VListItem, 17 | VListItemContent, 18 | VListItemAvatar, 19 | VListItemTitle, 20 | VMenu, 21 | VProgressLinear, 22 | VSelect, 23 | VSpacer, 24 | VToolbar, 25 | VTooltip, 26 | } from "vuetify/lib"; 27 | -------------------------------------------------------------------------------- /dev/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | VTiptap 14 | 15 | 16 |
17 | 18 | 19 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true, 5 | jest: true, 6 | }, 7 | extends: [ 8 | "plugin:vue/essential", 9 | "eslint:recommended", 10 | "@vue/typescript/recommended", 11 | ], 12 | parser: "vue-eslint-parser", 13 | parserOptions: { 14 | parser: "@typescript-eslint/parser", 15 | }, 16 | rules: { 17 | "no-console": process.env.NODE_ENV === "production" ? "error" : "off", 18 | "no-debugger": process.env.NODE_ENV === "production" ? "error" : "off", 19 | "import/order": ["off"], // See https://youtrack.jetbrains.com/issue/WEB-21182 20 | "object-curly-newline": ["off"], 21 | "@typescript-eslint/ban-ts-comment": "off", 22 | "prefer-const": "off", 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /src/constants/testHtml.ts: -------------------------------------------------------------------------------- 1 | export default '

Heading 1

Heading 2

Heading 3

Bold Italic Strike Underline Colored

Left

Center

Right

Justify

  • Unordered A

  • Unordered B

  1. Ordered A

  2. Ordered B

Link

😄😄😄

'; 2 | -------------------------------------------------------------------------------- /src/constants/xssRules.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | a: ["href", "title", "target"], 3 | span: ["style", "data-type", "class", "data-label", "data-id"], 4 | blockquote: ["style"], 5 | p: ["style"], 6 | hr: [], 7 | pre: [], 8 | code: [], 9 | strong: [], 10 | img: ["src", "alt", "title"], 11 | label: ["contenteditable"], 12 | input: ["type", "value"], 13 | div: ["class"], 14 | iframe: ["src", "allowfullscreen"], 15 | em: [], 16 | s: [], 17 | mark: [], 18 | h1: ["style"], 19 | h2: ["style"], 20 | h3: ["style"], 21 | ul: ["class", "data-type"], 22 | li: ["data-checked"], 23 | ol: [], 24 | u: [], 25 | tr: ["class", "style"], 26 | td: ["class", "style"], 27 | th: ["class", "style"], 28 | tbody: ["class", "style"], 29 | table: ["class", "style"], 30 | br: [], 31 | }; 32 | -------------------------------------------------------------------------------- /.storybook/util/helpers.js: -------------------------------------------------------------------------------- 1 | // Returns a function to generate stories 2 | export const storyFactory = options => { 3 | const { title, component, args, argTypes, description } = options; 4 | return { 5 | title, 6 | component, 7 | args: { 8 | ...args, 9 | }, 10 | argTypes: { 11 | locale: { 12 | defaultValue: "en", 13 | control: { 14 | type: "inline-radio", 15 | options: { English: "en", Español: "es" }, 16 | }, 17 | }, 18 | 19 | onClick: { 20 | action: "clicked", 21 | table: { 22 | type: { 23 | summary: null, 24 | }, 25 | }, 26 | }, 27 | }, 28 | parameters: { 29 | docs: { 30 | description: { 31 | component: description, 32 | }, 33 | }, 34 | }, 35 | }; 36 | }; 37 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "strict": false, 6 | "declaration": true, 7 | "declarationDir": "dist/types", 8 | "importHelpers": true, 9 | "moduleResolution": "node", 10 | "experimentalDecorators": true, 11 | "useDefineForClassFields": false, 12 | "esModuleInterop": true, 13 | "allowSyntheticDefaultImports": true, 14 | "sourceMap": true, 15 | "baseUrl": ".", 16 | "newLine": "lf", 17 | "types": ["node", "vue", "vuetify"], 18 | "paths": { 19 | "@/*": ["src/*"] 20 | }, 21 | "plugins": [ 22 | { 23 | "transform": "@zerollup/ts-transform-paths", 24 | "exclude": ["*"] 25 | } 26 | ], 27 | "lib": ["esnext", "dom", "dom.iterable", "scripthost"] 28 | }, 29 | "exclude": ["node_modules", "dist"] 30 | } 31 | -------------------------------------------------------------------------------- /.github/workflows/chromatic.yml: -------------------------------------------------------------------------------- 1 | # .github/workflows/chromatic.yml 2 | 3 | # Workflow name 4 | name: "Chromatic" 5 | 6 | # Event for the workflow 7 | on: 8 | push: 9 | paths: 10 | - ".storybook/**/*" 11 | - "src/**/*" 12 | - "package*" 13 | 14 | # List of jobs 15 | jobs: 16 | chromatic-deployment: 17 | # Operating System 18 | runs-on: ubuntu-latest 19 | # Job steps 20 | steps: 21 | - uses: actions/checkout@v3 22 | with: 23 | fetch-depth: 0 24 | - uses: actions/setup-node@v3 25 | with: 26 | node-version: "16" 27 | cache: "npm" 28 | - run: npm ci --no-audit --ignore-scripts 29 | - name: Publish to Chromatic 30 | uses: chromaui/action@v1 31 | with: 32 | buildScriptName: "storybook:build" 33 | projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} 34 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Peepi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/create-release.yml: -------------------------------------------------------------------------------- 1 | name: Create Release 2 | on: 3 | push: 4 | tags: 5 | - "v*" 6 | 7 | jobs: 8 | create: 9 | if: startsWith(github.ref, 'refs/tags/') 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Build Changelog 13 | id: github_release 14 | uses: mikepenz/release-changelog-builder-action@v1 15 | env: 16 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 17 | - name: Create Release 18 | uses: actions/create-release@v1 19 | with: 20 | tag_name: ${{ github.ref }} 21 | release_name: ${{ github.ref }} 22 | body: ${{steps.github_release.outputs.changelog}} 23 | env: 24 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 25 | 26 | publish: 27 | runs-on: ubuntu-latest 28 | needs: [create] 29 | steps: 30 | - name: Checkout code 31 | uses: actions/checkout@v3 32 | with: 33 | depth: 1 34 | - uses: actions/setup-node@v3 35 | with: 36 | node-version: "16" 37 | cache: "npm" 38 | registry-url: "https://registry.npmjs.org" 39 | - name: Install dependencies 40 | run: npm ci --no-audit --ignore-scripts 41 | - name: Publish to NPM 42 | run: npm publish 43 | env: 44 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 45 | -------------------------------------------------------------------------------- /src/components/VideoDialog.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 61 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | module.exports = { 4 | stories: ["../src/**/*.stories.@(js|jsx|ts|tsx|mdx)"], 5 | addons: [ 6 | "@storybook/addon-essentials", 7 | "@storybook/addon-links", 8 | // "@storybook/addon-storysource", 9 | "@storybook/addon-interactions", 10 | ], 11 | core: { 12 | // builder: "webpack5", 13 | // 14 | }, 15 | webpackFinal: async (config, { configType }) => { 16 | // so I can import { storyFactory } from '~storybook/util/helpers' 17 | config.resolve.alias["~storybook"] = path.resolve(__dirname); 18 | // the @ alias points to the `src/` directory, a common alias 19 | // used in the Vue community 20 | config.resolve.alias["@"] = path.resolve(__dirname, "..", "src"); 21 | 22 | // THIS is the tricky stuff! 23 | // config.module.rules.push({ 24 | // test: /\.sass$/, 25 | // use: [ 26 | // "style-loader", 27 | // "css-loader", 28 | // { 29 | // loader: "sass-loader", 30 | // options: { 31 | // sassOptions: { 32 | // indentedSyntax: true, 33 | // }, 34 | // prependData: "@import '@/sass/variables.sass'", 35 | // }, 36 | // }, 37 | // ], 38 | // include: path.resolve(__dirname, "../"), 39 | // }); 40 | 41 | // return the updated Storybook configuration 42 | return config; 43 | }, 44 | }; 45 | -------------------------------------------------------------------------------- /src/constants/suggestion.ts: -------------------------------------------------------------------------------- 1 | export function renderSuggestion(component) { 2 | return () => { 3 | const { mentionConfig } = component; 4 | 5 | return { 6 | onStart: props => { 7 | mentionConfig.show = true; 8 | mentionConfig.items = props.items; 9 | mentionConfig.command = props.command; 10 | mentionConfig.selected = 0; 11 | 12 | const { x, y } = props.clientRect(); 13 | mentionConfig.x = x; 14 | mentionConfig.y = y + 24; 15 | }, 16 | 17 | onUpdate(props) { 18 | mentionConfig.items = props.items; 19 | }, 20 | 21 | onKeyDown(props) { 22 | const { event } = props; 23 | 24 | if (event.key === "Escape") { 25 | mentionConfig.show = false; 26 | 27 | return true; 28 | } 29 | 30 | if (event.key === "ArrowUp") { 31 | mentionConfig.selected = 32 | (mentionConfig.selected + mentionConfig.items.length - 1) % 33 | mentionConfig.items.length; 34 | 35 | return true; 36 | } 37 | 38 | if (event.key === "ArrowDown") { 39 | mentionConfig.selected = (mentionConfig.selected + 1) % mentionConfig.items.length; 40 | 41 | return true; 42 | } 43 | 44 | if (event.key === "Enter") { 45 | component.selectMention(mentionConfig.selected); 46 | 47 | return true; 48 | } 49 | 50 | return false; 51 | }, 52 | 53 | onExit() { 54 | mentionConfig.show = false; 55 | }, 56 | }; 57 | }; 58 | } 59 | -------------------------------------------------------------------------------- /src/components/LinkDialog.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 67 | -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | // Imports for configuring Vuetify 2 | import Vue from "vue"; 3 | import Vuetify from "vuetify"; 4 | // import VueI18n from "vue-i18n"; // <== NOTE: I usually use i18n 5 | // import i18n from "@/i18n"; 6 | 7 | // configure Vue to use Vuetify 8 | Vue.use(Vuetify); 9 | // Vue.use(VueI18n); 10 | 11 | // this was the only thing here by default 12 | export const parameters = { 13 | actions: { argTypesRegex: "^on[A-Z].*" }, 14 | }; 15 | 16 | import "@mdi/font/css/materialdesignicons.css"; 17 | import "roboto-fontface/css/roboto/roboto-fontface.css"; 18 | 19 | const vuetify = new Vuetify(); 20 | 21 | // THIS is my decorator 22 | export const decorators = [ 23 | (story, context) => { 24 | // wrap the passed component within the passed context 25 | const wrapped = story(context); 26 | // extend Vue to use Vuetify around the wrapped component 27 | return Vue.extend({ 28 | vuetify, 29 | // i18n, 30 | components: { wrapped }, 31 | props: { 32 | locale: { 33 | type: String, 34 | default: "en", 35 | }, 36 | }, 37 | watch: { 38 | dark: { 39 | immediate: true, 40 | handler(val) { 41 | this.$vuetify.theme.dark = val; 42 | }, 43 | }, 44 | locale: { 45 | immediate: true, 46 | handler(val) { 47 | // this.$i18n.locale = val; 48 | }, 49 | }, 50 | }, 51 | template: ` 52 | 53 | 54 | 55 | 56 | 57 | `, 58 | }); 59 | }, 60 | ]; 61 | -------------------------------------------------------------------------------- /src/plugins/video.ts: -------------------------------------------------------------------------------- 1 | import { Node } from "@tiptap/core"; 2 | 3 | export interface IframeOptions { 4 | allowFullscreen: boolean; 5 | HTMLAttributes: { 6 | [key: string]: any; 7 | }; 8 | } 9 | 10 | declare module "@tiptap/core" { 11 | interface Commands { 12 | iframe: { 13 | /** 14 | * Add an iframe 15 | */ 16 | setIframe: (options: { src: string }) => ReturnType; 17 | }; 18 | } 19 | } 20 | 21 | export default Node.create({ 22 | name: "iframe", 23 | 24 | group: "block", 25 | 26 | atom: true, 27 | 28 | addOptions() { 29 | return { 30 | allowFullscreen: true, 31 | HTMLAttributes: { 32 | class: "iframe-wrapper", 33 | }, 34 | }; 35 | }, 36 | 37 | addAttributes() { 38 | return { 39 | src: { 40 | default: null, 41 | }, 42 | frameborder: { 43 | default: 0, 44 | }, 45 | allowfullscreen: { 46 | default: this.options.allowFullscreen, 47 | parseHTML: () => this.options.allowFullscreen, 48 | }, 49 | }; 50 | }, 51 | 52 | parseHTML() { 53 | return [ 54 | { 55 | tag: "iframe", 56 | }, 57 | ]; 58 | }, 59 | 60 | renderHTML({ HTMLAttributes }) { 61 | // Convert youtube links 62 | HTMLAttributes.src = HTMLAttributes.src 63 | .replace("https://youtu.be/", "https://www.youtube.com/watch?v=") 64 | .replace("watch?v=", "embed/"); 65 | 66 | // Convert vimeo links 67 | HTMLAttributes.src = HTMLAttributes.src.replace( 68 | "https://vimeo.com/", 69 | "https://player.vimeo.com/video/" 70 | ); 71 | 72 | // Convert google drive links 73 | if (HTMLAttributes.src.includes("drive.google.com")) { 74 | HTMLAttributes.src = HTMLAttributes.src.replace("/view", "/preview"); 75 | } 76 | 77 | return ["div", this.options.HTMLAttributes, ["iframe", HTMLAttributes]]; 78 | }, 79 | 80 | addCommands() { 81 | return { 82 | setIframe: 83 | (options: { src: string }) => 84 | ({ tr, dispatch }) => { 85 | const { selection } = tr; 86 | const node = this.type.create(options); 87 | 88 | if (dispatch) { 89 | tr.replaceRangeWith(selection.from, selection.to, node); 90 | } 91 | 92 | return true; 93 | }, 94 | }; 95 | }, 96 | }); 97 | -------------------------------------------------------------------------------- /src/components/EmojiPicker.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 54 | 55 | 106 | -------------------------------------------------------------------------------- /src/components/ImageDialog.vue: -------------------------------------------------------------------------------- 1 | 58 | 59 | 110 | 111 | 113 | -------------------------------------------------------------------------------- /src/constants/icons.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | get(iconName: string, packName = this.pack || "mdi") { 3 | return this.packs[packName][iconName]; 4 | }, 5 | 6 | packs: { 7 | md: { 8 | bold: "format_bold", 9 | italic: "format_italic", 10 | underline: "format_underlined", 11 | strike: "format_strikethrough", 12 | color: "format_color_text", 13 | highlight: "format_color_fill", 14 | h1: "mdi-format-header-1", 15 | h2: "mdi-format-header-2", 16 | h3: "mdi-format-header-3", 17 | p: "mdi-format-paragraph", 18 | left: "format_align_left", 19 | center: "format_align_center", 20 | right: "format_align_right", 21 | justify: "format_align_justify", 22 | bulletList: "format_list_bulleted", 23 | orderedList: "format_list_numbered", 24 | checkbox: "ballot", 25 | link: "link", 26 | image: "image", 27 | video: "videocam", 28 | emoji: "tag_faces", 29 | blockquote: "format_quote", 30 | rule: "horizontal_rule", 31 | code: "code", 32 | codeBlock: "terminal", 33 | clear: "format_clear", 34 | }, 35 | mdi: { 36 | bold: "mdi-format-bold", 37 | italic: "mdi-format-italic", 38 | underline: "mdi-format-underline", 39 | strike: "mdi-format-strikethrough", 40 | color: "mdi-palette", 41 | highlight: "mdi-grease-pencil", 42 | h1: "mdi-format-header-1", 43 | h2: "mdi-format-header-2", 44 | h3: "mdi-format-header-3", 45 | p: "mdi-format-paragraph", 46 | left: "mdi-format-align-left", 47 | center: "mdi-format-align-center", 48 | right: "mdi-format-align-right", 49 | justify: "mdi-format-align-justify", 50 | bulletList: "mdi-format-list-bulleted", 51 | orderedList: "mdi-format-list-numbered", 52 | checkbox: "mdi-format-list-checkbox", 53 | link: "mdi-link", 54 | image: "mdi-image", 55 | video: "mdi-video", 56 | emoji: "mdi-emoticon-outline", 57 | blockquote: "mdi-format-quote-open", 58 | rule: "mdi-minus", 59 | code: "mdi-code-tags", 60 | codeBlock: "mdi-code-braces-box", 61 | clear: "mdi-format-clear", 62 | }, 63 | fa: { 64 | bold: "fa-bold", 65 | italic: "fa-italic", 66 | underline: "fa-underline", 67 | strike: "fa-strikethrough", 68 | color: "fa-paint-brush", 69 | highlight: "fa-highlighter", 70 | h1: "mdi-format-header-1", 71 | h2: "mdi-format-header-2", 72 | h3: "mdi-format-header-3", 73 | p: "mdi-format-paragraph", 74 | left: "fa-align-left", 75 | center: "fa-align-center", 76 | right: "fa-align-right", 77 | justify: "fa-align-justify", 78 | bulletList: "fa-list-ul", 79 | orderedList: "fa-list-ol", 80 | checkbox: "fa-check", 81 | link: "fa-link", 82 | image: "fa-image", 83 | video: "fa-video", 84 | emoji: "fa-regular fa-face-grin", 85 | blockquote: "fa-quote-left", 86 | rule: "fa-minus", 87 | code: "fa-code", 88 | codeBlock: "fa-code", 89 | clear: "fa-text-slash", 90 | }, 91 | }, 92 | }; 93 | -------------------------------------------------------------------------------- /src/components/ColorPicker.vue: -------------------------------------------------------------------------------- 1 | 75 | 76 | 128 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@peepi/vuetify-tiptap", 3 | "version": "1.5.0", 4 | "license": "MIT", 5 | "description": "Awesome and extendable rich text editor component for Vuetify projects using tiptap", 6 | "homepage": "https://github.com/peepi-com-br/vuetify-tiptap", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/peepi-com-br/vuetify-tiptap.git" 10 | }, 11 | "bugs": { 12 | "url": "https://github.com/peepi-com-br/vuetify-tiptap/issues" 13 | }, 14 | "keywords": [ 15 | "vue", 16 | "vuetify", 17 | "tiptap", 18 | "richtext", 19 | "wysiwyg" 20 | ], 21 | "main": "dist/v-tiptap.ssr.js", 22 | "browser": "dist/v-tiptap.esm.js", 23 | "module": "dist/v-tiptap.esm.js", 24 | "unpkg": "dist/v-tiptap.min.js", 25 | "types": "dist/types/src/entry.esm.d.ts", 26 | "files": [ 27 | "dist/*", 28 | "src/**/*.vue" 29 | ], 30 | "sideEffects": [ 31 | "*.vue" 32 | ], 33 | "scripts": { 34 | "serve": "cross-env DEMO_ENV=development vue-cli-service serve", 35 | "build:demo": "cross-env DEMO_ENV=production vue-cli-service build", 36 | "prebuild": "rimraf ./dist", 37 | "build": "cross-env NODE_ENV=production rollup --config build/rollup.config.js", 38 | "build:ssr": "cross-env NODE_ENV=production rollup --config build/rollup.config.js --format cjs", 39 | "build:es": "cross-env NODE_ENV=production rollup --config build/rollup.config.js --format es", 40 | "build:unpkg": "cross-env NODE_ENV=production rollup --config build/rollup.config.js --format iife", 41 | "postbuild": "rimraf ./dist/types/dev ./dist/types/src/entry.d.ts", 42 | "prepublishOnly": "npm run build", 43 | "storybook:build": "vue-cli-service storybook:build -c .storybook", 44 | "storybook:serve": "cross-env NODE_OPTIONS=--openssl-legacy-provider vue-cli-service storybook:serve -p 6006 -c .storybook", 45 | "pretty": "prettier --write \"./**/*.{js,ts,jsx,json,vue}\"" 46 | }, 47 | "dependencies": { 48 | "@tiptap/vue-2": "^2.1.12", 49 | "debounce": "^1.0.1", 50 | "v-emoji-picker": "^2.3.3", 51 | "xss": "^1.0.11" 52 | }, 53 | "devDependencies": { 54 | "@babel/core": "^7.14.6", 55 | "@babel/plugin-proposal-class-properties": "^7.16.7", 56 | "@babel/plugin-proposal-decorators": "^7.17.9", 57 | "@babel/preset-env": "^7.14.7", 58 | "@babel/preset-typescript": "^7.14.5", 59 | "@mdi/font": "^6.6.96", 60 | "@rollup/plugin-alias": "^3.1.2", 61 | "@rollup/plugin-babel": "^5.3.0", 62 | "@rollup/plugin-commonjs": "^14.0.0", 63 | "@rollup/plugin-node-resolve": "^9.0.0", 64 | "@rollup/plugin-replace": "^2.4.2", 65 | "@storybook/addon-essentials": "^6.0.26", 66 | "@storybook/addon-interactions": "^6.4.21", 67 | "@storybook/addon-links": "^6.0.26", 68 | "@storybook/addon-storysource": "^7.0.24", 69 | "@storybook/jest": "^0.0.10", 70 | "@storybook/testing-library": "^0.0.9", 71 | "@storybook/vue": "^6.0.26", 72 | "@tiptap/extension-character-count": "^2.1.12", 73 | "@tiptap/extension-color": "^2.1.12", 74 | "@tiptap/extension-focus": "^2.1.12", 75 | "@tiptap/extension-highlight": "^2.1.12", 76 | "@tiptap/extension-image": "^2.1.12", 77 | "@tiptap/extension-link": "^2.1.12", 78 | "@tiptap/extension-mention": "^2.1.12", 79 | "@tiptap/extension-placeholder": "^2.1.12", 80 | "@tiptap/extension-task-item": "^2.1.12", 81 | "@tiptap/extension-task-list": "^2.1.12", 82 | "@tiptap/extension-text-align": "^2.1.12", 83 | "@tiptap/extension-text-style": "^2.1.12", 84 | "@tiptap/extension-underline": "^2.1.12", 85 | "@tiptap/pm": "^2.1.12", 86 | "@tiptap/starter-kit": "^2.1.12", 87 | "@tiptap/suggestion": "^2.1.12", 88 | "@typescript-eslint/eslint-plugin": "^5.12.1", 89 | "@typescript-eslint/parser": "^5.12.1", 90 | "@vue/cli-plugin-babel": "^4.5.13", 91 | "@vue/cli-plugin-eslint": "^5.0.1", 92 | "@vue/cli-plugin-typescript": "^4.5.13", 93 | "@vue/cli-service": "^4.5.13", 94 | "@vue/eslint-config-airbnb": "^6.0.0", 95 | "@vue/eslint-config-typescript": "^10.0.0", 96 | "@zerollup/ts-transform-paths": "^1.7.18", 97 | "cache-loader": "^4.1.0", 98 | "core-js": "^3.21.1", 99 | "cross-env": "^7.0.3", 100 | "css-loader": "^5.0.0", 101 | "delay": "^5.0.0", 102 | "eslint": "^8.10.0", 103 | "eslint-import-resolver-typescript": "^2.5.0", 104 | "eslint-plugin-import": "^2.25.4", 105 | "eslint-plugin-prettier": "^4.0.0", 106 | "eslint-plugin-vue": "^8.5.0", 107 | "eslint-plugin-vuejs-accessibility": "^1.1.1", 108 | "jsdom-global": "^3.0.2", 109 | "minimist": "^1.2.5", 110 | "regenerator-runtime": "^0.13.9", 111 | "rimraf": "^3.0.2", 112 | "roboto-fontface": "^0.10.0", 113 | "rollup": "^2.52.8", 114 | "rollup-plugin-terser": "^7.0.2", 115 | "rollup-plugin-typescript2": "^0.30.0", 116 | "rollup-plugin-visualizer": "^5.6.0", 117 | "rollup-plugin-vue": "^5.1.9", 118 | "rollup-plugin-vuetify": "^0.2.4", 119 | "sass": "~1.32", 120 | "sass-loader": "^10.0.0", 121 | "ttypescript": "^1.5.12", 122 | "typescript": "~4.7", 123 | "vue": "^2.6.14", 124 | "vue-cli-plugin-storybook": "~2.1.0", 125 | "vue-cli-plugin-vuetify": "~2.4.6", 126 | "vue-property-decorator": "^9.1.2", 127 | "vue-template-compiler": "^2.6.14", 128 | "vuetify": "^2.6.3", 129 | "vuetify-loader": "^1.7.3", 130 | "webpack": "^4.45.0" 131 | }, 132 | "peerDependencies": { 133 | "vue": "^2.6.14", 134 | "vuetify": "^2.6.3" 135 | }, 136 | "engines": { 137 | "node": ">=12" 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Stargazers][stars-shield]][stars-url] 2 | [![Contributors][contributors-shield]][contributors-url] 3 | [![Forks][forks-shield]][forks-url] 4 | [![Issues][issues-shield]][issues-url] 5 | [![MIT License][license-shield]][license-url] 6 | [![LinkedIn][linkedin-shield]][linkedin-url] 7 | 8 | ## vuetify-tiptap 9 | 10 | > 🚀 Awesome and extendable rich text editor component for Vuetify projects using tiptap 11 | 12 | ![Sample](images/sample.png) 13 | 14 |

15 | View Demo 16 | · 17 | Usage 18 | · 19 | Installation 20 | · 21 | API 22 | · 23 | License 24 | · 25 | Maintainers 26 |

27 | 28 | ## Usage 29 | 30 | ```vue 31 | 32 | ``` 33 | 34 | To ensure full consistency between whats is seen in the editor and what you see when you render the HTML value, the component also has a view mode. 35 | 36 | ```vue 37 | 38 | ``` 39 | 40 | The toolbar is fully customizable and you can add, remove or reorder buttons. You can also use slots to add custom buttons with your own functionality. 41 | 42 | ```vue 43 | 47 | 52 | 53 | ``` 54 | 55 | > ⚠️ This project uses Vue 2 and Vuetify 2. We plan to upgrade to Vue 3 as soon as Vuetify supports it. 56 | 57 | ## Instalation 58 | 59 | First, install the package using npm or yarn. 60 | 61 | ```bash 62 | npm i -S @peepi/vuetify-tiptap 63 | ``` 64 | 65 | Then add the package to the `transpileDependencies` config on `vue.config.js` 66 | 67 | ```js 68 | module.exports = { 69 | transpileDependencies: ["vuetify", "@peepi/vuetify-tiptap"], 70 | }; 71 | ``` 72 | 73 | Finally, register the plugin on your main.ts. 74 | 75 | ```js 76 | import VTiptap from "@peepi/vuetify-tiptap"; 77 | 78 | Vue.use(VTiptap); 79 | ``` 80 | 81 | ### Uploading Images 82 | 83 | In order to use the upload image feature, pass down a prop `uploadImage` to the component with a function that receives a File and returns a Promise to an URL. 84 | 85 | ```vue 86 | 89 | 90 | 96 | ``` 97 | 98 | You can also pass this function as a global option to the plugin. 99 | 100 | ```js 101 | Vue.use(VTiptap, { uploadImage: async file => (await myApi.upload(file)).url }); 102 | ``` 103 | 104 | ### Mentions 105 | 106 | You just need set `:mention="true"` and pass the `mentionItems` prop to the component. It accepts an array or a function that returns an array of items like: 107 | 108 | ```js 109 | mentionItems = [{text: 'User', value: 1, avatar: 'http://image.png'}] 110 | ``` 111 | 112 | ```vue 113 | 116 | 117 | 123 | ``` 124 | 125 | You can also pass this function as a global option to the plugin and add . 126 | 127 | ```js 128 | Vue.use(VTiptap, { mentionItems: async query => (await myApi.users(query)).data }); 129 | ``` 130 | 131 | ## Documentation 132 | 133 | Check the live demo for documentation. 134 | 135 | ## Changelog 136 | 137 | Please see [CHANGELOG](CHANGELOG.md) for more information what has changed recently. 138 | 139 | ## Contributing 140 | 141 | Please see [CONTRIBUTING](CONTRIBUTING.md) for details. 142 | 143 | ## Credits 144 | 145 | This package was developed at [Peepi](https://www.peepi.com.br), a SaaS platform to engage customers and employees. 146 | 147 | - [Atila Silva](https://github.com/a2insights) 148 | - [Ricardo Faust](https://github.com/alkin) 149 | - [All Contributors](../../contributors) 150 | 151 | ## License 152 | 153 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 154 | 155 | 156 | 157 | 158 | [contributors-shield]: https://img.shields.io/github/contributors/peepi-com-br/vuetify-tiptap.svg?style=for-the-badge 159 | [contributors-url]: https://github.com/peepi-com-br/vuetify-tiptap/graphs/contributors 160 | [forks-shield]: https://img.shields.io/github/forks/peepi-com-br/vuetify-tiptap.svg?style=for-the-badge 161 | [forks-url]: https://github.com/peepi-com-br/vuetify-tiptap/network/members 162 | [stars-shield]: https://img.shields.io/github/stars/peepi-com-br/vuetify-tiptap.svg?style=for-the-badge 163 | [stars-url]: https://github.com/peepi-com-br/vuetify-tiptap/stargazers 164 | [issues-shield]: https://img.shields.io/github/issues/peepi-com-br/vuetify-tiptap.svg?style=for-the-badge 165 | [issues-url]: https://github.com/peepi-com-br/vuetify-tiptap/issues 166 | [license-shield]: https://img.shields.io/github/license/peepi-com-br/vuetify-tiptap.svg?style=for-the-badge 167 | [license-url]: https://github.com/peepi-com-br/vuetify-tiptap/blob/master/LICENSE.txt 168 | [linkedin-shield]: https://img.shields.io/badge/-LinkedIn-black.svg?style=for-the-badge&logo=linkedin&colorB=555 169 | [linkedin-url]: https://linkedin.com/company/peepi 170 | [product-screenshot]: images/screenshot.png 171 | -------------------------------------------------------------------------------- /src/constants/toolbarDefinitions.ts: -------------------------------------------------------------------------------- 1 | import icons from "./icons"; 2 | 3 | export default function makeToolbarDefinitions(context) { 4 | const definitions = { 5 | // Special items 6 | "|": { type: "divider" }, 7 | divider: { type: "divider" }, 8 | ">": { type: "spacer" }, 9 | spacer: { type: "spacer" }, 10 | // Standard buttons 11 | bold: { 12 | title: "Bold", 13 | icon: "bold", 14 | action: () => context.editor.chain().focus().toggleBold().run(), 15 | isActive: () => context.editor.isActive("bold"), 16 | }, 17 | italic: { 18 | title: "Italic", 19 | icon: "italic", 20 | action: () => context.editor.chain().focus().toggleItalic().run(), 21 | isActive: () => context.editor.isActive("italic"), 22 | }, 23 | underline: { 24 | title: "Underline", 25 | icon: "underline", 26 | action: () => context.editor.chain().focus().toggleUnderline().run(), 27 | isActive: () => context.editor.isActive("underline"), 28 | }, 29 | strike: { 30 | title: "Strike", 31 | icon: "strike", 32 | action: () => context.editor.chain().focus().toggleStrike().run(), 33 | isActive: () => context.editor.isActive("strike"), 34 | }, 35 | color: { 36 | title: "Color", 37 | icon: "color", 38 | action: color => context.editor.chain().focus().setColor(color).run(), 39 | isActive: () => context.editor.isActive("textStyle"), 40 | }, 41 | highlight: { 42 | title: "Highlight", 43 | icon: "highlight", 44 | action: () => context.editor.chain().focus().toggleHighlight().run(), 45 | isActive: () => context.editor.isActive("highlight"), 46 | }, 47 | headings: { type: "headings" }, 48 | h1: { 49 | title: "Heading 1", 50 | icon: "h1", 51 | action: () => 52 | context.editor.chain().focus().toggleHeading({ level: 1 }).run(), 53 | isActive: () => context.editor.isActive("heading", { level: 1 }), 54 | }, 55 | h2: { 56 | title: "Heading 2", 57 | icon: "h2", 58 | action: () => 59 | context.editor.chain().focus().toggleHeading({ level: 2 }).run(), 60 | isActive: () => context.editor.isActive("heading", { level: 2 }), 61 | }, 62 | h3: { 63 | title: "Heading 3", 64 | icon: "h3", 65 | action: () => 66 | context.editor.chain().focus().toggleHeading({ level: 3 }).run(), 67 | isActive: () => context.editor.isActive("heading", { level: 3 }), 68 | }, 69 | p: { 70 | title: "Paragraph", 71 | icon: "p", 72 | action: () => context.editor.chain().focus().setParagraph().run(), 73 | isActive: () => context.editor.isActive("paragraph"), 74 | }, 75 | left: { 76 | title: "left", 77 | icon: "left", 78 | action: () => context.editor.chain().focus().setTextAlign("left").run(), 79 | isActive: () => context.editor.isActive({ textAlign: "left" }), 80 | }, 81 | center: { 82 | title: "center", 83 | icon: "center", 84 | action: () => context.editor.chain().focus().setTextAlign("center").run(), 85 | isActive: () => context.editor.isActive({ textAlign: "center" }), 86 | }, 87 | right: { 88 | title: "right", 89 | icon: "right", 90 | action: () => context.editor.chain().focus().setTextAlign("right").run(), 91 | isActive: () => context.editor.isActive({ textAlign: "right" }), 92 | }, 93 | justify: { 94 | title: "justify", 95 | icon: "justify", 96 | action: () => 97 | context.editor.chain().focus().setTextAlign("justify").run(), 98 | isActive: () => context.editor.isActive({ textAlign: "justify" }), 99 | }, 100 | bulletList: { 101 | title: "Bullet List", 102 | icon: "bulletList", 103 | action: () => context.editor.chain().focus().toggleBulletList().run(), 104 | isActive: () => context.editor.isActive("bulletList"), 105 | }, 106 | orderedList: { 107 | title: "Ordered List", 108 | icon: "orderedList", 109 | action: () => context.editor.chain().focus().toggleOrderedList().run(), 110 | isActive: () => context.editor.isActive("orderedList"), 111 | }, 112 | checkbox: { 113 | title: "Task List", 114 | icon: "checkbox", 115 | action: () => context.editor.chain().focus().toggleTaskList().run(), 116 | isActive: () => context.editor.isActive("taskList"), 117 | }, 118 | link: { 119 | title: "Link", 120 | icon: "link", 121 | action: () => (context.linkDialog = true), 122 | isActive: () => context.editor.isActive("link"), 123 | }, 124 | image: { 125 | title: "Image", 126 | icon: "image", 127 | action: () => (context.imageDialog = true), 128 | isActive: () => context.editor.isActive("image"), 129 | }, 130 | video: { 131 | title: "Video", 132 | icon: "video", 133 | action: () => (context.videoDialog = true), 134 | isActive: () => context.editor.isActive("iframe"), 135 | }, 136 | emoji: { 137 | title: "Emoji", 138 | icon: "emoji", 139 | action: context.setEmoji, 140 | }, 141 | blockquote: { 142 | title: "Blockquote", 143 | icon: "blockquote", 144 | action: () => context.editor.chain().focus().toggleBlockquote().run(), 145 | isActive: () => context.editor.isActive("blockquote"), 146 | }, 147 | rule: { 148 | title: "Horizontal Rule", 149 | icon: "rule", 150 | action: () => context.editor.chain().focus().setHorizontalRule().run(), 151 | }, 152 | code: { 153 | title: "Code", 154 | icon: "code", 155 | action: () => context.editor.chain().focus().toggleCode().run(), 156 | isActive: () => context.editor.isActive("code"), 157 | }, 158 | codeBlock: { 159 | title: "Code Block", 160 | icon: "codeBlock", 161 | action: () => context.editor.chain().focus().toggleCodeBlock().run(), 162 | isActive: () => context.editor.isActive("codeBlock"), 163 | }, 164 | clear: { 165 | title: "Clear Format", 166 | icon: "clear", 167 | /* eslint newline-per-chained-call: "off" */ 168 | action: () => 169 | context.editor.chain().focus().clearNodes().unsetAllMarks().run(), 170 | }, 171 | }; 172 | 173 | let toolbarItems = []; 174 | 175 | const toolbar = context.toolbar.concat(context.append); 176 | 177 | for (let i of toolbar) { 178 | if (definitions[i]) { 179 | definitions[i].icon = icons.get( 180 | definitions[i].icon, 181 | context.$vuetify.icons.iconfont 182 | ); 183 | definitions[i].type = definitions[i].type || i; 184 | toolbarItems.push(definitions[i]); 185 | } else if (i[0] === "#") { 186 | toolbarItems.push({ type: "slot", slot: i.substring(1) }); 187 | } 188 | } 189 | 190 | return toolbarItems; 191 | } 192 | -------------------------------------------------------------------------------- /dev/App.vue: -------------------------------------------------------------------------------- 1 | 186 | 187 | 239 | -------------------------------------------------------------------------------- /src/stories/VTiptap.stories.js: -------------------------------------------------------------------------------- 1 | import { storyFactory } from "~storybook/util/helpers"; 2 | import { userEvent, screen } from "@storybook/testing-library"; 3 | import { expect } from "@storybook/jest"; 4 | import delay from "delay"; 5 | 6 | import VTiptap from "../components/VTiptap.vue"; 7 | import testHtml from "../constants/testHtml"; 8 | import CharacterCount from "@tiptap/extension-character-count"; 9 | 10 | // set the default properties 11 | export default storyFactory({ 12 | title: "VTiptap", 13 | component: VTiptap, 14 | description: "This can be **markdown**!", 15 | argTypes: { 16 | toolbar: { control: "array" }, 17 | value: { control: "color" }, 18 | }, 19 | args: { 20 | toolbar: undefined, 21 | value: "", 22 | }, 23 | }); 24 | 25 | function makeSlots(args) { 26 | return Object.entries(args.slots || {}).map( 27 | ([key, value]) => 28 | `\n` 29 | ); 30 | } 31 | 32 | // create a base template to share 33 | const Template = (args, { argTypes }) => ({ 34 | components: { VTiptap }, 35 | props: Object.keys(argTypes).filter(key => key !== "slots"), 36 | 37 | template: ` 38 | 39 | ${makeSlots(args)} 40 | `, 41 | }); 42 | 43 | // now the stories, you need at least one 44 | export const BasicUsage = Template.bind({}); 45 | BasicUsage.args = { 46 | placeholder: "Type anything here", 47 | }; 48 | 49 | export const BasicUsageWithText = Template.bind({}); 50 | BasicUsageWithText.storyName = "Basic Usage (Value)"; 51 | BasicUsageWithText.args = { 52 | value: testHtml, 53 | label: "Basic Usage (Value)", 54 | }; 55 | 56 | export const BasicUsageWithView = Template.bind({}); 57 | BasicUsageWithView.storyName = "Basic Usage (View Mode)"; 58 | BasicUsageWithView.args = { value: testHtml, view: true }; 59 | 60 | // Toolbard 61 | export const CustomToolbar = Template.bind({}); 62 | CustomToolbar.args = { 63 | toolbar: [ 64 | "h1", 65 | "h2", 66 | "h3", 67 | "|", 68 | "bold", 69 | "color", 70 | "|", 71 | "left", 72 | "right", 73 | ">", 74 | "clear", 75 | ], 76 | }; 77 | 78 | export const CustomButtons = Template.bind({}); 79 | CustomButtons.args = { 80 | toolbar: ["#emoji", "#clear"], 81 | slots: { 82 | emoji: `mdi-emoticon-outline`, 83 | clear: `Clear`, 84 | }, 85 | }; 86 | 87 | export const DisabledToolbar = Template.bind({}); 88 | DisabledToolbar.args = { 89 | toolbar: ["bold", "color", "headings", "#emoji"], 90 | disableToolbar: true, 91 | slots: { 92 | emoji: `mdi-emoticon-outline`, 93 | }, 94 | }; 95 | 96 | export const Mentions = Template.bind({}); 97 | Mentions.args = { 98 | value: `

Type @ to start mentioning people: @Cyndi Lauper

`, 99 | mention: true, 100 | mentionItems: async function (query) { 101 | await delay(2000); 102 | 103 | const response = await fetch( 104 | `https://dummyjson.com/users/search?q=${query}`, 105 | {} 106 | ).then(res => res.json()); 107 | 108 | return response.users 109 | .map(u => ({ 110 | value: u.id, 111 | text: u.username, 112 | avatar: u.image, 113 | })) 114 | .slice(0, 5); 115 | }, 116 | // mentionItems: () => 117 | // new Promise(resolve => 118 | // setTimeout( 119 | // () => 120 | // resolve([ 121 | // { text: "Cyndi Lauper", value: 1 }, 122 | // { text: "Tom Cruise", value: 1 }, 123 | // { text: "Madonna", value: 1 }, 124 | // { text: "Jerry Hall", value: 1 }, 125 | // { text: "Joan Collins", value: 1 }, 126 | // { text: "Winona Ryder", value: 1 }, 127 | // { text: "Christina Applegate", value: 1 }, 128 | // { text: "Alyssa Milano", value: 1 }, 129 | // { text: "Molly Ringwald", value: 1 }, 130 | // { text: "Ally Sheedy", value: 1 }, 131 | // { text: "Debbie Harry", value: 1 }, 132 | // { text: "Olivia Newton-John", value: 1 }, 133 | // { text: "Elton John", value: 1 }, 134 | // { text: "Michael J. Fox", value: 1 }, 135 | // { text: "Axl Rose", value: 1 }, 136 | // { text: "Emilio Estevez", value: 1 }, 137 | // { text: "Ralph Macchio", value: 1 }, 138 | // { text: "Rob Lowe", value: 1 }, 139 | // { text: "Jennifer Grey", value: 1 }, 140 | // { text: "Mickey Rourke", value: 1 }, 141 | // { text: "John Cusack", value: 1 }, 142 | // { text: "Matthew Broderick", value: 1 }, 143 | // { text: "Justine Bateman", value: 1 }, 144 | // { text: "Lisa Bonet", value: 1 }, 145 | // ]), 146 | // 5000 147 | // ) 148 | // ), 149 | }; 150 | 151 | export const UploadImage = Template.bind({}); 152 | UploadImage.args = { 153 | uploadImage: async file => { 154 | await delay(2000); 155 | return "https://fakeimg.pl/360x240/25a7fd"; 156 | }, 157 | }; 158 | 159 | // Slots 160 | export const SlotsBottom = Template.bind({}); 161 | SlotsBottom.storyName = "Slots: Bottom"; 162 | SlotsBottom.args = { 163 | extensions: [CharacterCount], 164 | slots: { 165 | bottom: ` 166 | 167 | mdi-home 168 | 169 | {{ editor.storage.characterCount.characters() }} characters 170 | 171 | mdi-send 172 | `, 173 | }, 174 | }; 175 | 176 | export const SlotsPrepend = Template.bind({}); 177 | SlotsPrepend.storyName = "Slots: Prepend and Append"; 178 | SlotsPrepend.args = { 179 | hideToolbar: true, 180 | editorClass: "py-1", 181 | dense: true, 182 | placeholder: "Insert your comment here... (Use CTRL + B to make it bold)", 183 | 184 | slots: { 185 | prepend: `mdi-account-circle`, 186 | append: `mdi-send`, 187 | }, 188 | }; 189 | 190 | export const TestBasic = Template.bind({}); 191 | TestBasic.args = {}; 192 | TestBasic.play = async () => { 193 | await userEvent.click(screen.getByTestId("value").children[0].children[0]); 194 | 195 | await userEvent.keyboard("Testing", { delay: 100 }); 196 | await userEvent.keyboard("a", { 197 | keyboardState: userEvent.keyboard("[ControlLeft>]"), 198 | }); 199 | 200 | await userEvent.click(screen.getByTestId("bold")); 201 | await userEvent.click(screen.getByTestId("center")); 202 | 203 | await userEvent.click(screen.getByTestId("value").children[0].children[0]); 204 | await userEvent.keyboard("{home}"); 205 | 206 | await expect(screen.getByTestId("value").children[0].innerHTML).toBe( 207 | '

Testing

' 208 | ); 209 | }; 210 | -------------------------------------------------------------------------------- /src/plugins/tiptap-kit.ts: -------------------------------------------------------------------------------- 1 | // StarterKit 2 | import { Extension } from "@tiptap/core"; 3 | import Blockquote, { BlockquoteOptions } from "@tiptap/extension-blockquote"; 4 | import Bold, { BoldOptions } from "@tiptap/extension-bold"; 5 | import BulletList, { BulletListOptions } from "@tiptap/extension-bullet-list"; 6 | import Code, { CodeOptions } from "@tiptap/extension-code"; 7 | import CodeBlock, { CodeBlockOptions } from "@tiptap/extension-code-block"; 8 | import Document from "@tiptap/extension-document"; 9 | import Dropcursor, { DropcursorOptions } from "@tiptap/extension-dropcursor"; 10 | import Gapcursor from "@tiptap/extension-gapcursor"; 11 | import HardBreak, { HardBreakOptions } from "@tiptap/extension-hard-break"; 12 | import Heading, { HeadingOptions } from "@tiptap/extension-heading"; 13 | import History, { HistoryOptions } from "@tiptap/extension-history"; 14 | import HorizontalRule, { 15 | HorizontalRuleOptions, 16 | } from "@tiptap/extension-horizontal-rule"; 17 | import Italic, { ItalicOptions } from "@tiptap/extension-italic"; 18 | import ListItem, { ListItemOptions } from "@tiptap/extension-list-item"; 19 | import OrderedList, { 20 | OrderedListOptions, 21 | } from "@tiptap/extension-ordered-list"; 22 | import Paragraph, { ParagraphOptions } from "@tiptap/extension-paragraph"; 23 | import Strike, { StrikeOptions } from "@tiptap/extension-strike"; 24 | import Text from "@tiptap/extension-text"; 25 | 26 | // Extensions 27 | import Placeholder, { PlaceholderOptions } from "@tiptap/extension-placeholder"; 28 | import TextAlign, { TextAlignOptions } from "@tiptap/extension-text-align"; 29 | import Focus, { FocusOptions } from "@tiptap/extension-focus"; 30 | import Color, { ColorOptions } from "@tiptap/extension-color"; 31 | import Highlight, { HighlightOptions } from "@tiptap/extension-highlight"; 32 | import Image, { ImageOptions } from "@tiptap/extension-image"; 33 | import Link, { LinkOptions } from "@tiptap/extension-link"; 34 | import TaskList, { TaskListOptions } from "@tiptap/extension-task-list"; 35 | import TaskItem, { TaskItemOptions } from "@tiptap/extension-task-item"; 36 | import TextStyle, { TextStyleOptions } from "@tiptap/extension-text-style"; 37 | import Underline, { UnderlineOptions } from "@tiptap/extension-underline"; 38 | import Mention from "@tiptap/extension-mention"; 39 | import Video from "@/plugins/video"; 40 | 41 | export interface StarterKitOptions { 42 | blockquote: Partial | false; 43 | bold: Partial | false; 44 | bulletList: Partial | false; 45 | code: Partial | false; 46 | codeBlock: Partial | false; 47 | document: false; 48 | dropcursor: Partial | false; 49 | gapcursor: false; 50 | hardBreak: Partial | false; 51 | heading: Partial | false; 52 | history: Partial | false; 53 | horizontalRule: Partial | false; 54 | italic: Partial | false; 55 | listItem: Partial | false; 56 | orderedList: Partial | false; 57 | paragraph: Partial | false; 58 | strike: Partial | false; 59 | text: any; 60 | placeholder: Partial | false; 61 | textAlign: Partial | false; 62 | focus: Partial | false; 63 | color: Partial | false; 64 | highlight: Partial | false; 65 | image: Partial | false; 66 | link: Partial | false; 67 | taskList: Partial | false; 68 | taskItem: Partial | false; 69 | textStyle: Partial | false; 70 | underline: Partial | false; 71 | video: any; 72 | mention: any; 73 | } 74 | 75 | export default Extension.create({ 76 | name: "tiptap-kit", 77 | 78 | addExtensions() { 79 | const extensions = []; 80 | 81 | if (this.options.placeholder !== false) { 82 | extensions.push(Placeholder.configure(this.options.placeholder)); 83 | } 84 | 85 | if (this.options.textAlign !== false) { 86 | extensions.push(TextAlign.configure(this.options.textAlign)); 87 | } 88 | 89 | if (this.options.focus !== false) { 90 | extensions.push(Focus.configure(this.options.focus)); 91 | } 92 | 93 | if (this.options.color !== false) { 94 | extensions.push(Color.configure(this.options.color)); 95 | } 96 | 97 | if (this.options.highlight !== false) { 98 | extensions.push(Highlight.configure(this.options.highlight)); 99 | } 100 | 101 | if (this.options.image !== false) { 102 | extensions.push(Image.configure(this.options.image)); 103 | } 104 | 105 | if (this.options.link !== false) { 106 | extensions.push(Link.configure(this.options.link)); 107 | } 108 | 109 | if (this.options.taskList !== false) { 110 | extensions.push(TaskList.configure(this.options.taskList)); 111 | } 112 | 113 | if (this.options.taskItem !== false) { 114 | extensions.push(TaskItem.configure(this.options.taskItem)); 115 | } 116 | 117 | if (this.options.textStyle !== false) { 118 | extensions.push(TextStyle.configure(this.options.textStyle)); 119 | } 120 | 121 | if (this.options.underline !== false) { 122 | extensions.push(Underline.configure(this.options.underline)); 123 | } 124 | 125 | if (this.options.video !== false) { 126 | extensions.push(Video.configure(this.options.video)); 127 | } 128 | 129 | if (this.options.blockquote !== false) { 130 | extensions.push(Blockquote.configure(this.options.blockquote)); 131 | } 132 | 133 | if (this.options.bold !== false) { 134 | extensions.push(Bold.configure(this.options.bold)); 135 | } 136 | 137 | if (this.options.bulletList !== false) { 138 | extensions.push(BulletList.configure(this.options.bulletList)); 139 | } 140 | 141 | if (this.options.code !== false) { 142 | extensions.push(Code.configure(this.options.code)); 143 | } 144 | 145 | if (this.options.codeBlock !== false) { 146 | extensions.push(CodeBlock.configure(this.options.codeBlock)); 147 | } 148 | 149 | if (this.options.document !== false) { 150 | extensions.push(Document.configure(this.options.document)); 151 | } 152 | 153 | if (this.options.dropcursor !== false) { 154 | extensions.push(Dropcursor.configure(this.options.dropcursor)); 155 | } 156 | 157 | if (this.options.gapcursor !== false) { 158 | extensions.push(Gapcursor.configure(this.options.gapcursor)); 159 | } 160 | 161 | if (this.options.hardBreak !== false) { 162 | extensions.push(HardBreak.configure(this.options.hardBreak)); 163 | } 164 | 165 | if (this.options.heading !== false) { 166 | extensions.push(Heading.configure(this.options.heading)); 167 | } 168 | 169 | if (this.options.history !== false) { 170 | extensions.push(History.configure(this.options.history)); 171 | } 172 | 173 | if (this.options.horizontalRule !== false) { 174 | extensions.push(HorizontalRule.configure(this.options.horizontalRule)); 175 | } 176 | 177 | if (this.options.italic !== false) { 178 | extensions.push(Italic.configure(this.options.italic)); 179 | } 180 | 181 | if (this.options.listItem !== false) { 182 | extensions.push(ListItem.configure(this.options.listItem)); 183 | } 184 | 185 | if (this.options.orderedList !== false) { 186 | extensions.push(OrderedList.configure(this.options.orderedList)); 187 | } 188 | 189 | if (this.options.paragraph !== false) { 190 | extensions.push(Paragraph.configure(this.options.paragraph)); 191 | } 192 | 193 | if (this.options.strike !== false) { 194 | extensions.push(Strike.configure(this.options.strike)); 195 | } 196 | 197 | if (this.options.text !== false) { 198 | extensions.push(Text.configure(this.options.text)); 199 | } 200 | 201 | if (this.options.mention !== false) { 202 | extensions.push(Mention.configure(this.options.mention)); 203 | } 204 | 205 | return extensions; 206 | }, 207 | }); 208 | -------------------------------------------------------------------------------- /src/components/VTiptap.vue: -------------------------------------------------------------------------------- 1 | 237 | 238 | 669 | 670 | 897 | --------------------------------------------------------------------------------