├── .github ├── FUNDING.yml └── workflows │ ├── test.yaml │ └── build.yaml ├── src ├── tiptap │ ├── extensions │ │ ├── resizableMedia │ │ │ ├── styles.scss │ │ │ ├── index.ts │ │ │ ├── mediaPasteDropPlugin │ │ │ │ ├── index.ts │ │ │ │ └── mediaPasteDropPlugin.ts │ │ │ ├── resizableMediaMenuUtil.ts │ │ │ ├── resizableMedia.ts │ │ │ └── ResizableMediaNodeView.tsx │ │ ├── dBlock │ │ │ ├── index.ts │ │ │ ├── DBlockNodeView.tsx │ │ │ └── dBlock.ts │ │ ├── index.ts │ │ ├── trailingNode │ │ │ ├── index.ts │ │ │ └── trailingNode.ts │ │ ├── bubble-menu │ │ │ ├── index.ts │ │ │ ├── BubbleMenu.tsx │ │ │ └── bubble-menu-plugin.ts │ │ ├── link │ │ │ ├── index.ts │ │ │ ├── helpers │ │ │ │ ├── clickHandler.ts │ │ │ │ ├── pasteHandler.ts │ │ │ │ └── autolink.ts │ │ │ └── link.ts │ │ ├── slash-menu │ │ │ ├── index.ts │ │ │ ├── styles │ │ │ │ └── CommandList.scss │ │ │ ├── command.ts │ │ │ ├── CommandList.tsx │ │ │ └── suggestions.ts │ │ ├── supercharged-table │ │ │ ├── extension-table-row │ │ │ │ ├── index.ts │ │ │ │ ├── TableRowNodeView.ts │ │ │ │ └── table-row.ts │ │ │ ├── extension-table-cell │ │ │ │ ├── index.ts │ │ │ │ ├── styles.scss │ │ │ │ ├── table-cell.ts │ │ │ │ └── TableCellNodeView.tsx │ │ │ ├── extension-table-header │ │ │ │ ├── index.ts │ │ │ │ └── table-header.ts │ │ │ ├── extension-table │ │ │ │ ├── index.ts │ │ │ │ ├── utilities │ │ │ │ │ ├── isCellSelection.ts │ │ │ │ │ ├── createCell.ts │ │ │ │ │ ├── getTableNodeTypes.ts │ │ │ │ │ ├── deleteTableWhenAllCellsSelected.ts │ │ │ │ │ └── createTable.ts │ │ │ │ ├── TableView.ts │ │ │ │ └── table.ts │ │ │ ├── index.ts │ │ │ └── supercharged-table-kit.ts │ │ ├── doc.ts │ │ ├── paragraph.ts │ │ └── starter-kit.ts │ ├── index.ts │ ├── mocks │ │ ├── index.ts │ │ └── defaultContent.ts │ ├── menus │ │ ├── link-bubble-menu │ │ │ ├── index.ts │ │ │ └── linkBubbleMenu.tsx │ │ ├── bubble-menu │ │ │ ├── index.ts │ │ │ ├── styles.scss │ │ │ ├── bubbleMenu.tsx │ │ │ ├── NodeTypeDropdown.tsx │ │ │ └── buttons.ts │ │ └── index.ts │ ├── utils │ │ ├── index.ts │ │ ├── eventModifiers.ts │ │ └── debounce.ts │ ├── proseClassString.ts │ ├── styles │ │ └── tiptap.scss │ └── Tiptap.tsx ├── components │ ├── index.ts │ ├── Modal.txt │ └── Poppover.tsx ├── vite-env.d.ts ├── main.tsx ├── index.css ├── App.css ├── App.tsx ├── favicon.svg └── logo.svg ├── .vscode └── extensions.json ├── public └── editor │ ├── header.png │ ├── quote.png │ ├── text.png │ ├── header2.png │ ├── header3.png │ ├── bulleted-list.png │ └── numbered-list.png ├── postcss.config.js ├── tsconfig.node.json ├── tailwind.config.js ├── index.html ├── .gitignore ├── .eslintrc.js ├── tsconfig.json ├── sponsorkit.config.js ├── vite.config.ts ├── LICENSE ├── package.json ├── README.md └── sponsorkit └── sponsors.svg /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: sereneinserenade 2 | -------------------------------------------------------------------------------- /src/tiptap/extensions/resizableMedia/styles.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/tiptap/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Tiptap"; 2 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Poppover"; 2 | -------------------------------------------------------------------------------- /src/tiptap/mocks/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./defaultContent"; 2 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/tiptap/extensions/dBlock/index.ts: -------------------------------------------------------------------------------- 1 | export * from './dBlock' 2 | -------------------------------------------------------------------------------- /src/tiptap/extensions/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./starter-kit"; 2 | -------------------------------------------------------------------------------- /src/tiptap/extensions/resizableMedia/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./resizableMedia"; 2 | -------------------------------------------------------------------------------- /src/tiptap/extensions/trailingNode/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./trailingNode"; 2 | -------------------------------------------------------------------------------- /src/tiptap/menus/link-bubble-menu/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./linkBubbleMenu"; 2 | -------------------------------------------------------------------------------- /src/tiptap/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./debounce"; 2 | export * from "./eventModifiers"; 3 | -------------------------------------------------------------------------------- /src/tiptap/menus/bubble-menu/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./bubbleMenu"; 2 | export * from "./buttons"; 3 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "bradlc.vscode-tailwindcss" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /public/editor/header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sereneinserenade/notitap/HEAD/public/editor/header.png -------------------------------------------------------------------------------- /public/editor/quote.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sereneinserenade/notitap/HEAD/public/editor/quote.png -------------------------------------------------------------------------------- /public/editor/text.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sereneinserenade/notitap/HEAD/public/editor/text.png -------------------------------------------------------------------------------- /public/editor/header2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sereneinserenade/notitap/HEAD/public/editor/header2.png -------------------------------------------------------------------------------- /public/editor/header3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sereneinserenade/notitap/HEAD/public/editor/header3.png -------------------------------------------------------------------------------- /src/tiptap/extensions/resizableMedia/mediaPasteDropPlugin/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./mediaPasteDropPlugin"; 2 | -------------------------------------------------------------------------------- /src/tiptap/extensions/bubble-menu/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./bubble-menu-plugin"; 2 | export * from "./BubbleMenu"; 3 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/editor/bulleted-list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sereneinserenade/notitap/HEAD/public/editor/bulleted-list.png -------------------------------------------------------------------------------- /public/editor/numbered-list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sereneinserenade/notitap/HEAD/public/editor/numbered-list.png -------------------------------------------------------------------------------- /src/tiptap/extensions/link/index.ts: -------------------------------------------------------------------------------- 1 | import { Link } from "./link"; 2 | 3 | export * from "./link"; 4 | 5 | export default Link; 6 | -------------------------------------------------------------------------------- /src/tiptap/extensions/slash-menu/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./CommandList"; 2 | export * from "./command"; 3 | export * from "./suggestions"; 4 | -------------------------------------------------------------------------------- /src/tiptap/menus/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./bubble-menu"; 2 | export * from "./link-bubble-menu"; 3 | export * from "../extensions/slash-menu"; 4 | -------------------------------------------------------------------------------- /src/tiptap/extensions/supercharged-table/extension-table-row/index.ts: -------------------------------------------------------------------------------- 1 | import { TableRow } from "./table-row"; 2 | 3 | export * from "./table-row"; 4 | 5 | export default TableRow; 6 | -------------------------------------------------------------------------------- /src/tiptap/extensions/supercharged-table/extension-table-cell/index.ts: -------------------------------------------------------------------------------- 1 | import { TableCell } from "./table-cell"; 2 | 3 | export * from "./table-cell"; 4 | 5 | export default TableCell; 6 | -------------------------------------------------------------------------------- /src/tiptap/extensions/supercharged-table/extension-table-header/index.ts: -------------------------------------------------------------------------------- 1 | import { TableHeader } from "./table-header"; 2 | 3 | export * from "./table-header"; 4 | 5 | export default TableHeader; 6 | -------------------------------------------------------------------------------- /src/tiptap/utils/eventModifiers.ts: -------------------------------------------------------------------------------- 1 | export const stopPrevent = (e: T): T => { 2 | (e as Event).stopPropagation(); 3 | (e as Event).preventDefault(); 4 | 5 | return e; 6 | }; 7 | -------------------------------------------------------------------------------- /src/tiptap/extensions/supercharged-table/extension-table/index.ts: -------------------------------------------------------------------------------- 1 | import { Table } from "./table"; 2 | 3 | export * from "./table"; 4 | export * from "./utilities/createTable"; 5 | 6 | export default Table; 7 | -------------------------------------------------------------------------------- /src/tiptap/proseClassString.ts: -------------------------------------------------------------------------------- 1 | export const notitapEditorClass = 2 | "prose prose-p:my-2 prose-h1:my-2 prose-h2:my-2 prose-h3:my-2 prose-ul:my-2 prose-ol:my-2 max-w-none"; 3 | // export const notitapEditorClass = ""; 4 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "esnext", 5 | "moduleResolution": "node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /src/tiptap/extensions/supercharged-table/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./extension-table"; 2 | export * from "./extension-table-cell"; 3 | export * from "./extension-table-header"; 4 | export * from "./extension-table-row"; 5 | 6 | export * from "./supercharged-table-kit"; 7 | -------------------------------------------------------------------------------- /src/tiptap/extensions/supercharged-table/extension-table/utilities/isCellSelection.ts: -------------------------------------------------------------------------------- 1 | import { CellSelection } from "@_ueberdosis/prosemirror-tables"; 2 | 3 | export function isCellSelection(value: unknown): value is CellSelection { 4 | return value instanceof CellSelection; 5 | } 6 | -------------------------------------------------------------------------------- /src/tiptap/extensions/doc.ts: -------------------------------------------------------------------------------- 1 | import { Node } from "@tiptap/core"; 2 | 3 | export const Document = Node.create({ 4 | name: "doc", 5 | 6 | topNode: true, 7 | 8 | // content: "draggableBlock{1,}", // accepts one or more draggable block as content 9 | content: "dBlock+", 10 | }); 11 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import App from "./App"; 4 | import "uno.css"; 5 | 6 | import "./index.css"; 7 | 8 | ReactDOM.createRoot(document.getElementById("root")!).render( 9 | 10 | 11 | 12 | ); 13 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** @type {import('tailwindcss').Config} */ 3 | module.exports = { 4 | content: ["./src/**/*.{js,jsx,ts,tsx}"], 5 | theme: { 6 | extend: {}, 7 | }, 8 | plugins: [require("@tailwindcss/typography")], 9 | safelist: [ 10 | { 11 | pattern: /justify-(start|end)/ 12 | } 13 | ] 14 | }; 15 | -------------------------------------------------------------------------------- /src/tiptap/utils/debounce.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/ban-types 2 | export function debounce(func: T, wait: number) { 3 | let h: NodeJS.Timeout; 4 | 5 | const callable = (...args: any) => { 6 | clearTimeout(h); 7 | h = setTimeout(() => func(...args), wait); 8 | }; 9 | 10 | return (callable); 11 | } 12 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | /* Copyright (C) Jeet Ajaybhai Mandaliya - All Rights Reserved 2 | Unauthorized copying of this file or any file in notitap(this project - https://github.com/sereneinserenade/notitap), via any medium is strictly prohibited 3 | Proprietary and confidential 4 | Written by Jeet Ajaybhai Mandaliya , 17th July 2022 */ 5 | 6 | @tailwind base; 7 | @tailwind components; 8 | @tailwind utilities; 9 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Pro - Notitap 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/tiptap/extensions/slash-menu/styles/CommandList.scss: -------------------------------------------------------------------------------- 1 | .items { 2 | @apply shadow-md bg-white p-2 box-border max-h-[20rem] overflow-y-auto rounded; 3 | 4 | .item:not(.divider) { 5 | @apply w-full flex rounded items-center gap-2 p-2 box-border justify-between cursor-pointer; 6 | 7 | svg { 8 | @apply inline-flex justify-center items-center; 9 | } 10 | 11 | &.is-selected { 12 | @apply bg-slate-200; 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/tiptap/extensions/supercharged-table/extension-table/utilities/createCell.ts: -------------------------------------------------------------------------------- 1 | import { Fragment, Node as ProsemirrorNode, NodeType } from "prosemirror-model"; 2 | 3 | export function createCell( 4 | cellType: NodeType, 5 | cellContent?: Fragment | ProsemirrorNode | Array 6 | ): ProsemirrorNode | null | undefined { 7 | if (cellContent) { 8 | return cellType.createChecked(null, cellContent); 9 | } 10 | 11 | return cellType.createAndFill(); 12 | } 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | .npmrc 27 | .env 28 | sponsorkit/.cache.json 29 | sponsorkit/sponsors.json 30 | sponsorkit/sponsors.png 31 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (C) Jeet Ajaybhai Mandaliya - All Rights Reserved 3 | * Unauthorized copying of this file or any file in notitap(this project - https://github.com/sereneinserenade/notitap), via any medium is strictly prohibited 4 | * Proprietary and confidential 5 | * Written by Jeet Ajaybhai Mandaliya , 17th July 2022 6 | */ 7 | 8 | body { 9 | min-height: 100vh; 10 | 11 | 12 | } 13 | 14 | .App { 15 | min-height: 100vh; 16 | } 17 | -------------------------------------------------------------------------------- /src/tiptap/menus/bubble-menu/styles.scss: -------------------------------------------------------------------------------- 1 | .bubble-menu { 2 | @apply flex shadow bg-white rounded-sm overflow-hidden border border-slate-200 box-border; 3 | 4 | .bubble-menu-button { 5 | @apply inline-flex items-center p-2 border border-transparent text-xs font-medium text-gray-700 bg-gray-100 hover:bg-gray-200; 6 | 7 | &.active { 8 | @apply bg-gray-300; 9 | } 10 | } 11 | } 12 | 13 | .node-type-dropdown-button { 14 | @apply px-2 py-1.5 rounded hover:bg-slate-100; 15 | } 16 | -------------------------------------------------------------------------------- /src/tiptap/extensions/supercharged-table/supercharged-table-kit.ts: -------------------------------------------------------------------------------- 1 | import { AnyExtension } from "@tiptap/core"; 2 | 3 | import { Table } from "./extension-table"; 4 | import { TableCell } from "./extension-table-cell"; 5 | import { TableHeader } from "./extension-table-header"; 6 | import { TableRow } from "./extension-table-row"; 7 | 8 | export const SuperchargedTableExtensions: AnyExtension[] = [ 9 | Table.configure({ 10 | resizable: false, 11 | }), 12 | TableCell, 13 | TableHeader, 14 | TableRow, 15 | ]; 16 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: "airbnb-typescript-prettier", 3 | rules: { 4 | "react/react-in-jsx-scope": 0, 5 | "import/prefer-default-export": 0, 6 | "react/function-component-definition": 0, 7 | "import/no-extraneous-dependencies": 0, 8 | "@typescript-eslint/no-use-before-define": ["warn"], 9 | "jsx-a11y/click-events-have-key-events": ["warn"], 10 | "import/no-unresolved": 0, 11 | "import/extensions": 0, 12 | }, 13 | ignorePatterns: ["**/*.json", "**/vendor/*.js"], 14 | }; 15 | -------------------------------------------------------------------------------- /src/tiptap/extensions/supercharged-table/extension-table-cell/styles.scss: -------------------------------------------------------------------------------- 1 | .dropdown { 2 | @apply bg-white w-52 z-50 -translate-x-2 rounded; 3 | 4 | .dropdown-content { 5 | @apply bg-white flex flex-col; 6 | 7 | .button { 8 | @apply flex gap-2 text-black py-1 px-2 w-full rounded items-center; 9 | 10 | &:hover { 11 | @apply bg-gray-100; 12 | } 13 | } 14 | } 15 | 16 | } 17 | 18 | .trigger-button { 19 | @apply absolute right-0 bottom-0 flex justify-center items-center p-px py-0 text-base m-1 bg-black text-white rounded cursor-pointer; 20 | } 21 | -------------------------------------------------------------------------------- /src/tiptap/extensions/slash-menu/command.ts: -------------------------------------------------------------------------------- 1 | import { Extension } from "@tiptap/core"; 2 | import Suggestion from "@tiptap/suggestion"; 3 | 4 | export const Commands = Extension.create({ 5 | name: "slash-commands", 6 | 7 | addOptions() { 8 | return { 9 | suggestions: { 10 | char: "/", 11 | command: ({ editor, range, props }: any) => 12 | props.command({ editor, range }), 13 | }, 14 | }; 15 | }, 16 | 17 | addProseMirrorPlugins() { 18 | return [ 19 | Suggestion({ 20 | editor: this.editor, 21 | ...this.options.suggestions, 22 | }), 23 | ]; 24 | }, 25 | }); 26 | -------------------------------------------------------------------------------- /src/tiptap/extensions/supercharged-table/extension-table/utilities/getTableNodeTypes.ts: -------------------------------------------------------------------------------- 1 | import { NodeType, Schema } from "prosemirror-model"; 2 | 3 | export function getTableNodeTypes(schema: Schema): { [key: string]: NodeType } { 4 | if (schema.cached.tableNodeTypes) { 5 | return schema.cached.tableNodeTypes; 6 | } 7 | 8 | const roles: { [key: string]: NodeType } = {}; 9 | 10 | Object.keys(schema.nodes).forEach((type) => { 11 | const nodeType = schema.nodes[type]; 12 | 13 | if (nodeType.spec.tableRole) { 14 | roles[nodeType.spec.tableRole] = nodeType; 15 | } 16 | }); 17 | 18 | schema.cached.tableNodeTypes = roles; 19 | 20 | return roles; 21 | } 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | "paths": { 19 | "@/*": ["./src/*"] 20 | } 21 | }, 22 | "include": ["src"], 23 | "references": [{ "path": "./tsconfig.node.json" }] 24 | } 25 | -------------------------------------------------------------------------------- /src/tiptap/extensions/link/helpers/clickHandler.ts: -------------------------------------------------------------------------------- 1 | import { getAttributes } from "@tiptap/core"; 2 | import { MarkType } from "prosemirror-model"; 3 | import { Plugin, PluginKey } from "prosemirror-state"; 4 | 5 | type ClickHandlerOptions = { 6 | type: MarkType; 7 | }; 8 | 9 | export function clickHandler(options: ClickHandlerOptions): Plugin { 10 | return new Plugin({ 11 | key: new PluginKey("handleClickLink"), 12 | props: { 13 | handleClick: (view, pos, event) => { 14 | const attrs = getAttributes(view.state, options.type.name); 15 | const link = (event.target as HTMLElement)?.closest("a"); 16 | 17 | if (link && attrs.href) { 18 | window.open(attrs.href, attrs.target); 19 | 20 | return true; 21 | } 22 | 23 | return false; 24 | }, 25 | }, 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /sponsorkit.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig, presets } from "sponsorkit"; 2 | 3 | export default defineConfig({ 4 | // Providers configs 5 | github: { 6 | login: "sereneinserenade", 7 | type: "user", 8 | }, 9 | 10 | // Rendering configs 11 | width: 800, 12 | formats: ["svg"], 13 | tiers: [ 14 | { 15 | title: "Backers", 16 | preset: presets.base, 17 | }, 18 | { 19 | title: "Sponsors", 20 | monthlyDollars: 45, 21 | preset: presets.small, 22 | }, 23 | { 24 | title: "Silver Sponsors", 25 | monthlyDollars: 135, 26 | preset: presets.medium, 27 | }, 28 | { 29 | title: "Sold((silver + gold)/2) Sponsors", 30 | monthlyDollars: 200, 31 | preset: presets.large, 32 | }, 33 | { 34 | title: "Gold", 35 | monthlyDollars: 405, 36 | preset: presets.xl, 37 | }, 38 | ], 39 | }); 40 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build-and-deploy: 10 | concurrency: ci-${{ github.ref }} # Recommended if you intend to make multiple deployments in quick succession. 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 🛎️ 14 | uses: actions/checkout@v3 15 | 16 | - name: Setup Node 16 17 | uses: actions/setup-node@v3 18 | with: 19 | node-version: '16' 20 | 21 | - name: Gimme node_modules Cache 22 | uses: actions/cache@v3 23 | id: gimme_cache 24 | with: 25 | path: '**/node_modules' 26 | key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} 27 | 28 | - name: Install dependencies 29 | if: steps.gimme_cache.outputs.cache-hit == false 30 | run: yarn 31 | 32 | - name: Build 🔧 33 | run: yarn build 34 | 35 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, UserConfigExport } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | import path from "path"; 4 | 5 | // eslint-disable-next-line import/no-unresolved 6 | import Unocss from "unocss/vite"; 7 | 8 | import { presetIcons } from "unocss"; 9 | 10 | // https://vitejs.dev/config/ 11 | export default defineConfig(({ mode }) => { 12 | const config: UserConfigExport = { 13 | plugins: [ 14 | react(), 15 | Unocss({ 16 | presets: [ 17 | presetIcons({ 18 | extraProperties: { 19 | display: "inline-block", 20 | "vertical-align": "middle", 21 | }, 22 | }), 23 | ], 24 | }), 25 | ], 26 | base: mode === "production" ? "/notitap/" : "/", 27 | server: { 28 | port: 3002, 29 | }, 30 | resolve: { 31 | alias: { 32 | "@": path.resolve(__dirname, "./src"), 33 | }, 34 | }, 35 | }; 36 | 37 | return config; 38 | }); 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Jeet Mandaliya 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 | -------------------------------------------------------------------------------- /src/tiptap/extensions/paragraph.ts: -------------------------------------------------------------------------------- 1 | import { mergeAttributes, Node } from "@tiptap/core"; 2 | 3 | export interface ParagraphOptions { 4 | HTMLAttributes: Record; 5 | } 6 | 7 | declare module "@tiptap/core" { 8 | interface Commands { 9 | paragraph: { 10 | /** 11 | * Toggle a paragraph 12 | */ 13 | setParagraph: () => ReturnType; 14 | }; 15 | } 16 | } 17 | 18 | export const Paragraph = Node.create({ 19 | name: "paragraph", 20 | 21 | priority: 1000, 22 | 23 | addOptions() { 24 | return { 25 | HTMLAttributes: {}, 26 | }; 27 | }, 28 | 29 | group: "block", 30 | 31 | content: "inline*", 32 | 33 | parseHTML() { 34 | return [{ tag: "p" }]; 35 | }, 36 | 37 | renderHTML({ HTMLAttributes }) { 38 | return [ 39 | "p", 40 | mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 41 | 0, 42 | ]; 43 | }, 44 | 45 | addCommands() { 46 | return { 47 | setParagraph: 48 | () => 49 | ({ commands }) => { 50 | return commands.setNode(this.name); 51 | }, 52 | }; 53 | }, 54 | }); 55 | -------------------------------------------------------------------------------- /src/tiptap/extensions/supercharged-table/extension-table/utilities/deleteTableWhenAllCellsSelected.ts: -------------------------------------------------------------------------------- 1 | import { 2 | findParentNodeClosestToPos, 3 | KeyboardShortcutCommand, 4 | } from "@tiptap/core"; 5 | 6 | import { isCellSelection } from "./isCellSelection"; 7 | 8 | export const deleteTableWhenAllCellsSelected: KeyboardShortcutCommand = ({ 9 | editor, 10 | }) => { 11 | const { selection } = editor.state; 12 | 13 | if (!isCellSelection(selection)) { 14 | return false; 15 | } 16 | 17 | let cellCount = 0; 18 | const table = findParentNodeClosestToPos( 19 | selection.ranges[0].$from, 20 | (node) => { 21 | return node.type.name === "table"; 22 | } 23 | ); 24 | 25 | table?.node.descendants((node) => { 26 | if (node.type.name === "table") { 27 | return false; 28 | } 29 | 30 | if (["tableCell", "tableHeader"].includes(node.type.name)) { 31 | cellCount += 1; 32 | } 33 | }); 34 | 35 | const allCellsSelected = cellCount === selection.ranges.length; 36 | 37 | if (!allCellsSelected) { 38 | return false; 39 | } 40 | 41 | editor.commands.deleteTable(); 42 | 43 | return true; 44 | }; 45 | -------------------------------------------------------------------------------- /src/tiptap/menus/bubble-menu/bubbleMenu.tsx: -------------------------------------------------------------------------------- 1 | import { BubbleMenu, Editor } from "@tiptap/react"; 2 | 3 | import { generalButtons } from "./buttons"; 4 | import { NodeTypeDropdown } from "./NodeTypeDropdown"; 5 | 6 | import "./styles.scss"; 7 | 8 | interface CustomBubbleMenuProps { 9 | editor: Editor; 10 | } 11 | 12 | export const CustomBubbleMenu: React.FC = ({ 13 | editor, 14 | }) => { 15 | return ( 16 | 25 | 26 | {generalButtons.map((btn) => { 27 | return ( 28 | 36 | ); 37 | })} 38 | 39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Shipp it baby 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build-and-deploy: 10 | concurrency: ci-${{ github.ref }} # Recommended if you intend to make multiple deployments in quick succession. 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 🛎️ 14 | uses: actions/checkout@v3 15 | 16 | - name: Setup Node 16 17 | uses: actions/setup-node@v3 18 | with: 19 | node-version: '16' 20 | 21 | - name: Gimme node_modules Cache 22 | uses: actions/cache@v3 23 | id: gimme_cache 24 | with: 25 | path: '**/node_modules' 26 | key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} 27 | 28 | - name: Install dependencies 29 | if: steps.gimme_cache.outputs.cache-hit == false 30 | run: yarn 31 | 32 | - name: Build 🔧 33 | run: yarn build 34 | 35 | - name: Deploy 🚀 36 | uses: JamesIves/github-pages-deploy-action@v4.3.0 37 | with: 38 | branch: gh-pages # The branch the action should deploy to. 39 | folder: dist # The folder the action should deploy. 40 | -------------------------------------------------------------------------------- /src/tiptap/extensions/link/helpers/pasteHandler.ts: -------------------------------------------------------------------------------- 1 | import { Editor } from "@tiptap/core"; 2 | import { find } from "linkifyjs"; 3 | import { MarkType } from "prosemirror-model"; 4 | import { Plugin, PluginKey } from "prosemirror-state"; 5 | 6 | type PasteHandlerOptions = { 7 | editor: Editor; 8 | type: MarkType; 9 | }; 10 | 11 | export function pasteHandler(options: PasteHandlerOptions): Plugin { 12 | return new Plugin({ 13 | key: new PluginKey("handlePasteLink"), 14 | props: { 15 | handlePaste: (view, event, slice) => { 16 | const { state } = view; 17 | const { selection } = state; 18 | const { empty } = selection; 19 | 20 | if (empty) { 21 | return false; 22 | } 23 | 24 | let textContent = ""; 25 | 26 | slice.content.forEach((node) => { 27 | textContent += node.textContent; 28 | }); 29 | 30 | const link = find(textContent).find( 31 | (item) => item.isLink && item.value === textContent 32 | ); 33 | 34 | if (!textContent || !link) { 35 | return false; 36 | } 37 | 38 | options.editor.commands.setMark(options.type, { 39 | href: link.href, 40 | }); 41 | 42 | return true; 43 | }, 44 | }, 45 | }); 46 | } 47 | -------------------------------------------------------------------------------- /src/tiptap/extensions/supercharged-table/extension-table/utilities/createTable.ts: -------------------------------------------------------------------------------- 1 | import { Fragment, Node as ProsemirrorNode, Schema } from "prosemirror-model"; 2 | 3 | import { createCell } from "./createCell"; 4 | import { getTableNodeTypes } from "./getTableNodeTypes"; 5 | 6 | export function createTable( 7 | schema: Schema, 8 | rowsCount: number, 9 | colsCount: number, 10 | withHeaderRow: boolean, 11 | cellContent?: Fragment | ProsemirrorNode | Array 12 | ): ProsemirrorNode { 13 | const types = getTableNodeTypes(schema); 14 | const headerCells = []; 15 | const cells = []; 16 | 17 | for (let index = 0; index < colsCount; index += 1) { 18 | const cell = createCell(types.cell, cellContent); 19 | 20 | if (cell) { 21 | cells.push(cell); 22 | } 23 | 24 | if (withHeaderRow) { 25 | const headerCell = createCell(types.header_cell, cellContent); 26 | 27 | if (headerCell) { 28 | headerCells.push(headerCell); 29 | } 30 | } 31 | } 32 | 33 | const rows = []; 34 | 35 | for (let index = 0; index < rowsCount; index += 1) { 36 | rows.push( 37 | types.row.createChecked( 38 | null, 39 | withHeaderRow && index === 0 ? headerCells : cells 40 | ) 41 | ); 42 | } 43 | 44 | return types.table.createChecked(null, rows); 45 | } 46 | -------------------------------------------------------------------------------- /src/tiptap/extensions/supercharged-table/extension-table-cell/table-cell.ts: -------------------------------------------------------------------------------- 1 | import { mergeAttributes, Node } from "@tiptap/core"; 2 | import { ReactNodeViewRenderer } from "@tiptap/react"; 3 | 4 | import { TableCellNodeView } from "./TableCellNodeView"; 5 | 6 | export interface TableCellOptions { 7 | HTMLAttributes: Record; 8 | } 9 | 10 | export const TableCell = Node.create({ 11 | name: "tableCell", 12 | 13 | addOptions() { 14 | return { 15 | HTMLAttributes: {}, 16 | }; 17 | }, 18 | 19 | content: "block+", 20 | 21 | addAttributes() { 22 | return { 23 | colspan: { 24 | default: 1, 25 | }, 26 | rowspan: { 27 | default: 1, 28 | }, 29 | colwidth: { 30 | default: null, 31 | parseHTML: (element) => { 32 | const colwidth = element.getAttribute("colwidth"); 33 | const value = colwidth ? [parseInt(colwidth, 10)] : null; 34 | 35 | return value; 36 | }, 37 | }, 38 | }; 39 | }, 40 | 41 | tableRole: "cell", 42 | 43 | isolating: true, 44 | 45 | parseHTML() { 46 | return [{ tag: "td" }]; 47 | }, 48 | 49 | renderHTML({ HTMLAttributes }) { 50 | return [ 51 | "td", 52 | mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 53 | 0, 54 | ]; 55 | }, 56 | 57 | addNodeView() { 58 | return ReactNodeViewRenderer(TableCellNodeView, { 59 | as: "td", 60 | className: "relative", 61 | }); 62 | }, 63 | }); 64 | -------------------------------------------------------------------------------- /src/tiptap/extensions/resizableMedia/resizableMediaMenuUtil.ts: -------------------------------------------------------------------------------- 1 | /* @unocss-include */ 2 | // import { IconAlignCenter, IconAlignLeft, IconAlignRight, IconFloatLeft, IconFloatRight, IconDelete } from '~/assets' 3 | 4 | interface ResizableMediaAction { 5 | tooltip: string; 6 | icon?: string; 7 | action?: (updateAttributes: (o: Record) => any) => void; 8 | isActive?: (attrs: Record) => boolean; 9 | delete?: (d: () => void) => void; 10 | } 11 | 12 | export const resizableMediaActions: ResizableMediaAction[] = [ 13 | { 14 | tooltip: "Align left", 15 | action: (updateAttributes) => 16 | updateAttributes({ 17 | dataAlign: "start", 18 | dataFloat: null, 19 | }), 20 | icon: "i-mdi-format-align-left", 21 | isActive: (attrs) => attrs.dataAlign === "start", 22 | }, 23 | { 24 | tooltip: "Align center", 25 | action: (updateAttributes) => 26 | updateAttributes({ 27 | dataAlign: "center", 28 | dataFloat: null, 29 | }), 30 | icon: "i-mdi-format-align-center", 31 | isActive: (attrs) => attrs.dataAlign === "center", 32 | }, 33 | { 34 | tooltip: "Align right", 35 | action: (updateAttributes) => 36 | updateAttributes({ 37 | dataAlign: "end", 38 | dataFloat: null, 39 | }), 40 | icon: "i-mdi-format-align-right", 41 | isActive: (attrs) => attrs.dataAlign === "end", 42 | }, 43 | { 44 | tooltip: "Delete", 45 | icon: "i-mdi-delete", 46 | delete: (deleteNode) => deleteNode(), 47 | }, 48 | ]; 49 | -------------------------------------------------------------------------------- /src/tiptap/extensions/supercharged-table/extension-table-header/table-header.ts: -------------------------------------------------------------------------------- 1 | import { mergeAttributes, Node } from "@tiptap/core"; 2 | import { ReactNodeViewRenderer } from "@tiptap/react"; 3 | 4 | import { TableCellNodeView } from "../extension-table-cell/TableCellNodeView"; 5 | 6 | export interface TableHeaderOptions { 7 | HTMLAttributes: Record; 8 | } 9 | export const TableHeader = Node.create({ 10 | name: "tableHeader", 11 | 12 | addOptions() { 13 | return { 14 | HTMLAttributes: {}, 15 | }; 16 | }, 17 | 18 | content: "block+", 19 | 20 | addAttributes() { 21 | return { 22 | colspan: { 23 | default: 1, 24 | }, 25 | rowspan: { 26 | default: 1, 27 | }, 28 | colwidth: { 29 | default: null, 30 | parseHTML: (element) => { 31 | const colwidth = element.getAttribute("colwidth"); 32 | const value = colwidth ? [parseInt(colwidth, 10)] : null; 33 | 34 | return value; 35 | }, 36 | }, 37 | }; 38 | }, 39 | 40 | tableRole: "header_cell", 41 | 42 | isolating: true, 43 | 44 | parseHTML() { 45 | return [{ tag: "th" }]; 46 | }, 47 | 48 | renderHTML({ HTMLAttributes }) { 49 | return [ 50 | "th", 51 | mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 52 | 0, 53 | ]; 54 | }, 55 | 56 | addNodeView() { 57 | return ReactNodeViewRenderer(TableCellNodeView, { 58 | as: "th", 59 | className: "relative", 60 | }); 61 | }, 62 | }); 63 | -------------------------------------------------------------------------------- /src/tiptap/extensions/bubble-menu/BubbleMenu.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable consistent-return */ 2 | import React, { useEffect, useState } from "react"; 3 | 4 | import { BubbleMenuPlugin, BubbleMenuPluginProps } from "./bubble-menu-plugin"; 5 | 6 | type Optional = Pick, K> & Omit; 7 | 8 | export type BubbleMenuProps = Omit< 9 | Optional, 10 | "element" 11 | > & { 12 | className?: string; 13 | children: React.ReactNode; 14 | }; 15 | 16 | export const BubbleMenu: React.FC = ({ 17 | editor, 18 | pluginKey = "bubbleMenu", 19 | tippyOptions = {}, 20 | shouldShow = null, 21 | className, 22 | children, 23 | }) => { 24 | const [element, setElement] = useState(null); 25 | 26 | useEffect(() => { 27 | if (!element) { 28 | return; 29 | } 30 | 31 | if (editor.isDestroyed) { 32 | return; 33 | } 34 | 35 | const plugin = BubbleMenuPlugin({ 36 | pluginKey, 37 | editor, 38 | element, 39 | tippyOptions, 40 | shouldShow, 41 | }); 42 | 43 | editor.registerPlugin(plugin); 44 | 45 | return () => { 46 | editor.unregisterPlugin(pluginKey); 47 | }; 48 | }, [editor, element, pluginKey, shouldShow, tippyOptions]); 49 | 50 | return ( 51 |
56 | {children} 57 |
58 | ); 59 | }; 60 | 61 | BubbleMenu.defaultProps = { 62 | className: "", 63 | }; 64 | -------------------------------------------------------------------------------- /src/tiptap/extensions/supercharged-table/extension-table-row/TableRowNodeView.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | import { Node as ProseMirrorNode } from "prosemirror-model"; 3 | import { NodeView } from "prosemirror-view"; 4 | 5 | export class TableRowNodeView implements NodeView { 6 | node: ProseMirrorNode; 7 | 8 | cellMinWidth: number; 9 | 10 | dom: Element; 11 | 12 | table: HTMLTableElement; 13 | 14 | colgroup: Element; 15 | 16 | contentDOM: HTMLElement; 17 | 18 | constructor(node: ProseMirrorNode, cellMinWidth: number) { 19 | this.node = node; 20 | this.cellMinWidth = cellMinWidth; 21 | this.dom = document.createElement("div"); 22 | this.dom.className = "tableWrapper"; 23 | this.table = this.dom.appendChild(document.createElement("table")); 24 | this.colgroup = this.table.appendChild(document.createElement("colgroup")); 25 | this.contentDOM = this.table.appendChild(document.createElement("tbody")); 26 | } 27 | 28 | update(node: ProseMirrorNode) { 29 | if (node.type !== this.node.type) { 30 | return false; 31 | } 32 | 33 | this.node = node; 34 | 35 | return true; 36 | } 37 | 38 | ignoreMutation( 39 | mutation: MutationRecord | { type: "selection"; target: Element } 40 | ) { 41 | return ( 42 | mutation.type === "attributes" && 43 | (mutation.target === this.table || 44 | this.colgroup.contains(mutation.target)) 45 | ); 46 | } 47 | 48 | // deleteNode(): void { 49 | // const from = this.getPos(); 50 | // const to = from + this.node.nodeSize; 51 | // this.editor.commands.deleteRange({ from, to }); 52 | // } 53 | } 54 | -------------------------------------------------------------------------------- /src/tiptap/extensions/dBlock/DBlockNodeView.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable jsx-a11y/no-static-element-interactions */ 2 | 3 | import React, { useMemo } from "react"; 4 | import { NodeViewWrapper, NodeViewProps, NodeViewContent } from "@tiptap/react"; 5 | 6 | export const DBlockNodeView: React.FC = ({ 7 | node, 8 | getPos, 9 | editor, 10 | }) => { 11 | const isTable = useMemo(() => { 12 | const { content } = node.content as any; 13 | 14 | return content[0].type.name === "table"; 15 | }, [node.content]); 16 | 17 | const createNodeAfter = () => { 18 | const pos = getPos() + node.nodeSize; 19 | 20 | editor.commands.insertContentAt(pos, { 21 | type: "dBlock", 22 | content: [ 23 | { 24 | type: "paragraph", 25 | }, 26 | ], 27 | }); 28 | }; 29 | 30 | return ( 31 | 32 |
37 | 44 |
50 | 51 |
52 |
53 | 54 | 57 |
58 | ); 59 | }; 60 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Tiptap } from "./tiptap"; 2 | 3 | import "./App.css"; 4 | 5 | function App() { 6 | return ( 7 |
8 |
9 |
10 | 14 | notitap 15 | 16 | 20 | Support My Work 21 | 22 |
23 |

24 | Notion like editor built on top of{" "} 25 | 26 | Tiptap. 27 | 28 |

29 | 30 |

31 | A Big thank you to all my{" "} 32 | 36 | sponsors. 37 | {" "} 38 | You people are awesome. 39 |

40 |
41 |
42 | 43 |
44 |
45 | ); 46 | } 47 | 48 | export default App; 49 | -------------------------------------------------------------------------------- /src/favicon.svg: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "notitap", 3 | "private": true, 4 | "version": "0.0.0", 5 | "scripts": { 6 | "dev": "vite", 7 | "build": "tsc && vite build", 8 | "preview": "vite preview", 9 | "lint": "eslint --ext .ts,.tsx --ignore-path .gitignore src", 10 | "lintfix": "eslint --ext .ts,.tsx --ignore-path .gitignore --fix src", 11 | "generateSponsors": "sponsorkit" 12 | }, 13 | "dependencies": { 14 | "@floating-ui/react-dom-interactions": "^0.6.6", 15 | "@tippyjs/react": "^4.2.6", 16 | "@tiptap/extension-link": "^2.0.0-beta.43", 17 | "@tiptap/extension-placeholder": "^2.0.0-beta.53", 18 | "@tiptap/extension-table": "^2.0.0-beta.54", 19 | "@tiptap/extension-table-cell": "^2.0.0-beta.23", 20 | "@tiptap/extension-table-header": "^2.0.0-beta.25", 21 | "@tiptap/extension-table-row": "^2.0.0-beta.22", 22 | "@tiptap/extension-underline": "^2.0.0-beta.25", 23 | "@tiptap/react": "^2.0.0-beta.114", 24 | "@tiptap/starter-kit": "^2.0.0-beta.191", 25 | "@tiptap/suggestion": "^2.0.0-beta.97", 26 | "classnames": "^2.3.1", 27 | "daisyui": "^2.19.0", 28 | "fuzzysort": "^2.0.1", 29 | "lodash": "^4.17.21", 30 | "react": "^18.0.0", 31 | "react-dom": "^18.0.0", 32 | "tippy.js": "^6.3.7" 33 | }, 34 | "devDependencies": { 35 | "@iconify-json/ic": "^1.1.7", 36 | "@iconify-json/mdi": "^1.1.26", 37 | "@iconify-json/ri": "^1.1.3", 38 | "@tailwindcss/typography": "^0.5.3", 39 | "@types/lodash": "^4.14.182", 40 | "@types/node": "^18.0.3", 41 | "@types/prosemirror-dev-tools": "^3.0.2", 42 | "@types/react": "^18.0.0", 43 | "@types/react-dom": "^18.0.0", 44 | "@unocss/preset-icons": "^0.44.0", 45 | "@vitejs/plugin-react": "^1.3.0", 46 | "autoprefixer": "^10.4.7", 47 | "eslint": "^8.19.0", 48 | "eslint-config-airbnb-typescript-prettier": "^5.0.0", 49 | "postcss": "^8.4.14", 50 | "prettier": "^2.7.1", 51 | "prosemirror-dev-tools": "^3.1.0", 52 | "sass": "^1.53.0", 53 | "sponsorkit": "^0.4.3", 54 | "tailwindcss": "^3.1.5", 55 | "typescript": "^4.7.4", 56 | "unocss": "^0.44.0", 57 | "vite": "^2.9.9" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # notitap 2 | 3 | Pro version of Notion like editor built on top of [Tiptap](https://tiptap.dev/). 4 | 5 | ![GitHub Sponsors](https://img.shields.io/github/sponsors/sereneinserenade?color=%23bf3989&label=Sponsor%20Me&style=for-the-badge) 6 | ![GitHub Repo stars](https://img.shields.io/github/stars/sereneinserenade/notitap?label=Star%20the%20Repo&style=for-the-badge) 7 | [![Twitter Support](https://img.shields.io/badge/sereneInSerenad-a--?logo=twitter&style=for-the-badge&color=000)](https://twitter.com/sereneInSerenad) 8 | 9 | A ⭐️ to the repo if you 👍 / ❤️ what I'm doing would be much appreciated. If you're using this extension and making money from it, it'd be very kind of you to **[:heart: Sponsor me](https://github.com/sponsors/sereneinserenade)**. If you're looking for a **dev to work you on your project's Rich Text Editor** with or as **a frontend developer, [DM me on Discord/Twitter/LinkedIn](https://github.com/sereneinserenade)👨‍💻🤩**. 10 | 11 | I've made a bunch of extensions for Tiptap 2, some of them are **Resiable Images And Videos**, **Search and Replace**, **LanguageTool integration** with tiptap. You can check it our here https://github.com/sereneinserenade#a-glance-of-my-projects. 12 | 13 | > **Note**: This is __React version__. Vue3 version will be coming 🔜 14 | 15 | ## Demo: 16 | 17 | Visit https://sereneinserenade.github.io/notitap/ for a live demo. 18 | 19 |
20 |

Click to see the video

21 | 22 | https://user-images.githubusercontent.com/45892659/184548594-125208cb-b55b-4e3d-90a0-0b259eb1e102.mp4 23 |
24 | 25 | ## Sponsors: 26 | 27 | **This project is made possible thanks to these amazing orgs/people.** 28 | 29 | [![My Sponsors](./sponsorkit/sponsors.svg)](https://github.com/sponsors/sereneinserenade#sponsors) 30 | 31 | ## Contributing 32 | 33 | **[:heart: Sponsor me](https://github.com/sponsors/sereneinserenade)** to make it possible for me to work on . You can also show your ❤️ by ⭐️ing this repository. Your support means a lot. 34 | 35 | Clone the repo, do something, make a PR. You know what's the drill. Looking forward to your PRs, you amazing devs. 36 | 37 | ## Stargazers 38 | [![Stargazers repo roster for @sereneinserenade/notitap](https://reporoster.com/stars/dark/sereneinserenade/notitap)](https://github.com/sereneinserenade/notitap/stargazers) 39 | -------------------------------------------------------------------------------- /src/tiptap/extensions/trailingNode/trailingNode.ts: -------------------------------------------------------------------------------- 1 | import { Extension } from "@tiptap/core"; 2 | import { Plugin, PluginKey } from "prosemirror-state"; 3 | 4 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 5 | // @ts-ignore 6 | function nodeEqualsType({ types, node }) { 7 | return ( 8 | (Array.isArray(types) && types.includes(node.type)) || node.type === types 9 | ); 10 | } 11 | 12 | /** 13 | * Extension based on: 14 | * - https://github.com/ueberdosis/tiptap/blob/v1/packages/tiptap-extensions/src/extensions/TrailingNode.js 15 | * - https://github.com/remirror/remirror/blob/e0f1bec4a1e8073ce8f5500d62193e52321155b9/packages/prosemirror-trailing-node/src/trailing-node-plugin.ts 16 | */ 17 | 18 | export interface TrailingNodeOptions { 19 | node: string; 20 | notAfter: string[]; 21 | } 22 | 23 | export const TrailingNode = Extension.create({ 24 | name: "trailingNode", 25 | 26 | addOptions() { 27 | return { 28 | node: "paragraph", 29 | notAfter: ["paragraph"], 30 | }; 31 | }, 32 | 33 | addProseMirrorPlugins() { 34 | const plugin = new PluginKey(this.name); 35 | const disabledNodes = Object.entries(this.editor.schema.nodes) 36 | .map(([, value]) => value) 37 | .filter((node) => this.options.notAfter.includes(node.name)); 38 | 39 | return [ 40 | new Plugin({ 41 | key: plugin, 42 | appendTransaction: (_, __, state) => { 43 | const { doc, tr, schema } = state; 44 | 45 | const shouldInsertNodeAtEnd = plugin.getState(state); 46 | 47 | const endPosition = doc.content.size; 48 | 49 | const type = schema.nodes[this.options.node]; 50 | 51 | if (!shouldInsertNodeAtEnd) return; 52 | 53 | // eslint-disable-next-line consistent-return 54 | return tr.insert(endPosition, type.create()); 55 | }, 56 | state: { 57 | init: (_, state) => { 58 | const lastNode = state.tr.doc.lastChild; 59 | 60 | return !nodeEqualsType({ node: lastNode, types: disabledNodes }); 61 | }, 62 | apply: (tr, value) => { 63 | if (!tr.docChanged) return value; 64 | 65 | const lastNode = (tr.doc.lastChild?.content as any)?.content?.[0]; 66 | 67 | return !nodeEqualsType({ node: lastNode, types: disabledNodes }); 68 | }, 69 | }, 70 | }), 71 | ]; 72 | }, 73 | }); 74 | -------------------------------------------------------------------------------- /src/components/Modal.txt: -------------------------------------------------------------------------------- 1 | // import { onMounted, ref } from 'vue' 2 | // import { v4 as uuidv4 } from 'uuid' 3 | // import { onClickOutside } from '@vueuse/core' 4 | // import React, { useState } from 'react' 5 | 6 | // interface ModalProps { 7 | 8 | // } 9 | 10 | // const Modal: React.FC = () => { 11 | // const modalBoxRef = useRef(null) 12 | 13 | // const [isModalOpen, setIsModalOpen] = useState(false) 14 | 15 | // const open = () => setIsModalOpen(true) 16 | 17 | // const close = 18 | // } 19 | 20 | // const modalBoxRef = ref(null) 21 | 22 | // const isModalOpen = ref(false) 23 | 24 | // const open = () => isModalOpen.value = true 25 | 26 | // const close = () => isModalOpen.value = false 27 | 28 | // const toggle = () => isModalOpen.value = !isModalOpen.value 29 | 30 | // const uuid = uuidv4() 31 | 32 | // // ! TODO: handle close on Escape click 33 | // // const onKeyPress = ({ code }: KeyboardEvent) => code === 'Escape' && close() 34 | 35 | // onMounted(() => { 36 | // setTimeout(() => { 37 | // if (!modalBoxRef.value) return 38 | 39 | // onClickOutside(modalBoxRef, close) 40 | 41 | // // window.addEventListener('keypress', onKeyPress) 42 | // }, 100) 43 | 44 | // }) 45 | 46 | // // onUnmounted(() => { 47 | // // window.removeEventListener('keypress', onKeyPress) 48 | // // }) 49 | // 50 | 51 | // 90 | 91 | // 93 | -------------------------------------------------------------------------------- /src/components/Poppover.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/jsx-props-no-spreading */ 2 | import { 3 | autoUpdate, 4 | flip, 5 | FloatingFocusManager, 6 | offset, 7 | Placement, 8 | shift, 9 | useClick, 10 | useDismiss, 11 | useFloating, 12 | useHover, 13 | useId, 14 | useInteractions, 15 | useRole, 16 | } from "@floating-ui/react-dom-interactions"; 17 | import React, { cloneElement, useState } from "react"; 18 | 19 | interface Props { 20 | render: (data: { 21 | close: () => void; 22 | labelId: string; 23 | descriptionId: string; 24 | }) => React.ReactNode; 25 | placement?: Placement; 26 | children: JSX.Element; 27 | } 28 | 29 | export const Popover: React.FC = ({ children, render, placement }) => { 30 | const [open, setOpen] = useState(false); 31 | 32 | const { x, y, reference, floating, strategy, context } = useFloating({ 33 | open, 34 | onOpenChange: setOpen, 35 | middleware: [offset(5), flip(), shift()], 36 | placement, 37 | whileElementsMounted: autoUpdate, 38 | }); 39 | 40 | const id = useId(); 41 | const labelId = `${id}-label`; 42 | const descriptionId = `${id}-description`; 43 | 44 | const { getReferenceProps, getFloatingProps } = useInteractions([ 45 | useHover(context, { enabled: true }), 46 | useClick(context), 47 | useRole(context), 48 | useDismiss(context), 49 | ]); 50 | 51 | return ( 52 | <> 53 | {cloneElement( 54 | children, 55 | getReferenceProps({ ref: reference, ...children.props }) 56 | )} 57 | {open && ( 58 | 64 |
77 | {render({ 78 | labelId, 79 | descriptionId, 80 | close: () => { 81 | setOpen(false); 82 | }, 83 | })} 84 |
85 |
86 | )} 87 | 88 | ); 89 | }; 90 | 91 | Popover.defaultProps = { 92 | placement: "top", 93 | }; 94 | -------------------------------------------------------------------------------- /src/tiptap/menus/link-bubble-menu/linkBubbleMenu.tsx: -------------------------------------------------------------------------------- 1 | import { Editor } from "@tiptap/core"; 2 | import { BubbleMenu } from "@tiptap/react"; 3 | 4 | import { useEffect, useState } from "react"; 5 | import { test } from "linkifyjs"; 6 | import classNames from "classnames"; 7 | 8 | // import { BubbleMenu } from "../../extensions/bubble-menu"; 9 | 10 | interface CustomBubbleMenuProps { 11 | editor: Editor; 12 | } 13 | 14 | export const LinkBubbleMenu: React.FC = ({ editor }) => { 15 | const [isLinkActive, setIsLinkActive] = useState(false); 16 | 17 | const [linkHref, setLinkHref] = useState(""); 18 | 19 | const [isLinkHrefValid, setIsLinkHrefValid] = useState(true); 20 | 21 | useEffect(() => { 22 | setIsLinkHrefValid(test(linkHref)); 23 | }, [linkHref]); 24 | 25 | const processLink = () => { 26 | const active = editor.isActive("link"); 27 | 28 | setIsLinkActive(active); 29 | 30 | if (!active) { 31 | setLinkHref(""); 32 | return; 33 | } 34 | 35 | const href = editor.getAttributes("link")?.href; 36 | 37 | setLinkHref(href || ""); 38 | }; 39 | 40 | useEffect(() => { 41 | editor.on("selectionUpdate", processLink); 42 | 43 | return () => { 44 | editor.off("selectionUpdate", processLink); 45 | }; 46 | }); 47 | 48 | const setOrUpdateCurrentLink = () => { 49 | if (!isLinkHrefValid) return; 50 | 51 | if (!isLinkActive) return; 52 | 53 | // update existing link 54 | editor 55 | .chain() 56 | .focus() 57 | .extendMarkRange("link") 58 | .setLink({ href: linkHref }) 59 | .run(); 60 | }; 61 | 62 | return ( 63 | e.isActive("link")} 75 | > 76 | 85 | setLinkHref((e.target as HTMLInputElement).value.trim()) 86 | } 87 | onKeyDown={(e) => e.code === "Enter" && setOrUpdateCurrentLink()} 88 | /> 89 | 90 | ); 91 | }; 92 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/tiptap/styles/tiptap.scss: -------------------------------------------------------------------------------- 1 | .ProseMirror { 2 | p.is-empty::before { 3 | @apply text-slate-400 float-left h-0 pointer-events-none absolute ml-[2px]; 4 | 5 | content: attr(data-placeholder); 6 | } 7 | 8 | .media-node-view { 9 | @apply flex relative w-full my-2; 10 | 11 | &.f-left { 12 | @apply float-left 13 | } 14 | 15 | &.f-right { 16 | @apply float-right 17 | } 18 | 19 | &.align-left { 20 | @apply justify-start 21 | } 22 | 23 | &.align-center { 24 | @apply justify-center 25 | } 26 | 27 | &.align-right { 28 | @apply justify-end 29 | } 30 | 31 | .horizontal-resize-handle { 32 | @apply h-24 w-2.5 top-[50%] right-1 cursor-col-resize absolute z-50 opacity-50 translate-y-[-50%] rounded 33 | } 34 | 35 | .btn { 36 | @apply inline-flex items-center px-2.5 py-1.5 border border-transparent text-xs font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 37 | } 38 | 39 | .btn.active { 40 | @apply bg-gray-300 41 | } 42 | 43 | .media-control-buttons { 44 | @apply absolute top-2 right-2 bg-white transition-all duration-200 ease-linear shadow-xl rounded-sm overflow-hidden border border-slate-200 box-border 45 | } 46 | } 47 | 48 | 49 | table { 50 | border-collapse: collapse; 51 | table-layout: fixed; 52 | width: 100%; 53 | margin: 0; 54 | overflow-x: hidden; 55 | 56 | td, 57 | th { 58 | min-width: 1em; 59 | border: 2px solid #ced4da; 60 | padding: 3px 5px; 61 | vertical-align: top; 62 | box-sizing: border-box; 63 | position: relative; 64 | 65 | >* { 66 | margin-bottom: 0; 67 | } 68 | } 69 | 70 | tr { 71 | // display: flex; 72 | 73 | div { 74 | // display: inline-block; 75 | width: 100%; 76 | } 77 | } 78 | 79 | th { 80 | font-weight: bold; 81 | text-align: left; 82 | background-color: #f1f3f5; 83 | } 84 | 85 | .selectedCell:after { 86 | z-index: 2; 87 | position: absolute; 88 | content: ""; 89 | left: 0; 90 | right: 0; 91 | top: 0; 92 | bottom: 0; 93 | background: rgba(200, 200, 255, 0.4); 94 | pointer-events: none; 95 | } 96 | 97 | .column-resize-handle { 98 | position: absolute; 99 | right: -2px; 100 | top: 0; 101 | bottom: -2px; 102 | width: 4px; 103 | background-color: #adf; 104 | pointer-events: none; 105 | } 106 | 107 | p { 108 | margin: 0; 109 | } 110 | } 111 | 112 | .d-block-button { 113 | @apply bg-gray-200 hover:bg-gray-300 cursor-grab text-lg py-1 opacity-0 transition duration-200 ease-in-out text-black h-fit rounded flex justify-center items-center 114 | } 115 | } 116 | 117 | .tableWrapper { 118 | padding: 1rem 0; 119 | overflow-x: auto; 120 | } 121 | 122 | .resize-cursor { 123 | cursor: ew-resize; 124 | cursor: col-resize; 125 | } 126 | -------------------------------------------------------------------------------- /src/tiptap/extensions/slash-menu/CommandList.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useImperativeHandle, useState } from "react"; 2 | import { stopPrevent } from "../../utils"; 3 | 4 | import "./styles/CommandList.scss"; 5 | 6 | interface CommandListProps { 7 | items: any[]; 8 | command: (...args: any[]) => any; 9 | } 10 | 11 | export const CommandList = React.forwardRef( 12 | ({ items, command }: CommandListProps, ref) => { 13 | const [selectedIndex, setSelectedIndex] = useState(0); 14 | 15 | useEffect(() => { 16 | setSelectedIndex(0); 17 | }, [items]); 18 | 19 | useImperativeHandle(ref, () => ({ 20 | onKeyDown: ({ event }: { event: KeyboardEvent }) => { 21 | if (event.key === "ArrowUp") { 22 | stopPrevent(event); 23 | upHandler(); 24 | return true; 25 | } 26 | 27 | if (event.key === "ArrowDown") { 28 | stopPrevent(event); 29 | downHandler(); 30 | return true; 31 | } 32 | 33 | if (event.key === "Enter") { 34 | stopPrevent(event); 35 | enterHandler(); 36 | return true; 37 | } 38 | 39 | return false; 40 | }, 41 | })); 42 | 43 | const upHandler = () => { 44 | setSelectedIndex((selectedIndex + items.length - 1) % items.length); 45 | }; 46 | 47 | const downHandler = () => { 48 | setSelectedIndex((selectedIndex + 1) % items.length); 49 | }; 50 | 51 | const enterHandler = () => { 52 | selectItem(selectedIndex); 53 | }; 54 | 55 | const selectItem = (index: number) => { 56 | const item = items[index]; 57 | 58 | if (item) setTimeout(() => command(item)); 59 | }; 60 | return ( 61 |
62 | {items.length ? ( 63 | <> 64 | {items.map((item, index) => { 65 | return ( 66 | 89 | ); 90 | })} 91 | 92 | ) : ( 93 |
No result
94 | )} 95 |
96 | ); 97 | } 98 | ); 99 | -------------------------------------------------------------------------------- /src/tiptap/extensions/supercharged-table/extension-table/TableView.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | import { Node as ProseMirrorNode } from "prosemirror-model"; 3 | import { NodeView } from "prosemirror-view"; 4 | 5 | export function updateColumns( 6 | node: ProseMirrorNode, 7 | colgroup: Element, 8 | table: HTMLTableElement, 9 | cellMinWidth: number, 10 | overrideCol?: number, 11 | overrideValue?: any 12 | ) { 13 | let totalWidth = 0; 14 | let fixedWidth = true; 15 | let nextDOM = colgroup.firstChild; 16 | const row = node.firstChild; 17 | 18 | if (row) { 19 | for (let i = 0, col = 0; i < row.childCount; i += 1) { 20 | const { colspan, colwidth } = row.child(i).attrs; 21 | 22 | for (let j = 0; j < colspan; j += 1, col += 1) { 23 | const hasWidth = 24 | overrideCol === col ? overrideValue : colwidth && colwidth[j]; 25 | const cssWidth = hasWidth ? `${hasWidth}px` : ""; 26 | 27 | totalWidth += hasWidth || cellMinWidth; 28 | 29 | if (!hasWidth) { 30 | fixedWidth = false; 31 | } 32 | 33 | if (!nextDOM) { 34 | colgroup.appendChild(document.createElement("col")).style.width = 35 | cssWidth; 36 | } else { 37 | if ((nextDOM as HTMLTableColElement).style.width !== cssWidth) { 38 | (nextDOM as HTMLTableColElement).style.width = cssWidth; 39 | } 40 | 41 | nextDOM = nextDOM.nextSibling; 42 | } 43 | } 44 | } 45 | } 46 | 47 | while (nextDOM) { 48 | const after = nextDOM.nextSibling; 49 | 50 | nextDOM?.parentNode?.removeChild(nextDOM); 51 | nextDOM = after; 52 | } 53 | 54 | if (fixedWidth) { 55 | table.style.width = `${totalWidth}px`; 56 | table.style.minWidth = ""; 57 | } else { 58 | table.style.width = ""; 59 | table.style.minWidth = `${totalWidth}px`; 60 | } 61 | } 62 | 63 | export class TableView implements NodeView { 64 | node: ProseMirrorNode; 65 | 66 | cellMinWidth: number; 67 | 68 | dom: Element; 69 | 70 | table: HTMLTableElement; 71 | 72 | colgroup: Element; 73 | 74 | contentDOM: HTMLElement; 75 | 76 | constructor(node: ProseMirrorNode, cellMinWidth: number) { 77 | this.node = node; 78 | this.cellMinWidth = cellMinWidth; 79 | this.dom = document.createElement("div"); 80 | this.dom.className = "tableWrapper"; 81 | this.table = this.dom.appendChild(document.createElement("table")); 82 | this.colgroup = this.table.appendChild(document.createElement("colgroup")); 83 | updateColumns(node, this.colgroup, this.table, cellMinWidth); 84 | this.contentDOM = this.table.appendChild(document.createElement("tbody")); 85 | } 86 | 87 | update(node: ProseMirrorNode) { 88 | if (node.type !== this.node.type) { 89 | return false; 90 | } 91 | 92 | this.node = node; 93 | updateColumns(node, this.colgroup, this.table, this.cellMinWidth); 94 | 95 | return true; 96 | } 97 | 98 | ignoreMutation( 99 | mutation: MutationRecord | { type: "selection"; target: Element } 100 | ) { 101 | return ( 102 | mutation.type === "attributes" && 103 | (mutation.target === this.table || 104 | this.colgroup.contains(mutation.target)) 105 | ); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/tiptap/extensions/dBlock/dBlock.ts: -------------------------------------------------------------------------------- 1 | import { Node, mergeAttributes } from "@tiptap/core"; 2 | import { ReactNodeViewRenderer } from "@tiptap/react"; 3 | 4 | import { DBlockNodeView } from "./DBlockNodeView"; 5 | 6 | export interface DBlockOptions { 7 | HTMLAttributes: Record; 8 | } 9 | 10 | declare module "@tiptap/core" { 11 | interface Commands { 12 | dBlock: { 13 | /** 14 | * Toggle a dBlock 15 | */ 16 | setDBlock: (position?: number) => ReturnType; 17 | }; 18 | } 19 | } 20 | 21 | export const DBlock = Node.create({ 22 | name: "dBlock", 23 | 24 | priority: 1000, 25 | 26 | group: "dBlock", 27 | 28 | content: "block", 29 | 30 | draggable: true, 31 | 32 | selectable: false, 33 | 34 | inline: false, 35 | 36 | addOptions() { 37 | return { 38 | HTMLAttributes: {}, 39 | }; 40 | }, 41 | 42 | parseHTML() { 43 | return [{ tag: 'div[data-type="d-block"]' }]; 44 | }, 45 | 46 | renderHTML({ HTMLAttributes }) { 47 | return [ 48 | "div", 49 | mergeAttributes(HTMLAttributes, { "data-type": "d-block" }), 50 | 0, 51 | ]; 52 | }, 53 | 54 | addCommands() { 55 | return { 56 | setDBlock: 57 | (position) => 58 | ({ state, chain }) => { 59 | const { 60 | selection: { from }, 61 | } = state; 62 | 63 | const pos = 64 | position !== undefined || position !== null ? from : position; 65 | 66 | return chain() 67 | .insertContentAt(pos, { 68 | type: this.name, 69 | content: [ 70 | { 71 | type: "paragraph", 72 | }, 73 | ], 74 | }) 75 | .focus(pos + 2) 76 | .run(); 77 | }, 78 | }; 79 | }, 80 | 81 | addNodeView() { 82 | return ReactNodeViewRenderer(DBlockNodeView); 83 | }, 84 | 85 | addKeyboardShortcuts() { 86 | return { 87 | "Mod-Alt-0": () => this.editor.commands.setDBlock(), 88 | Enter: ({ editor }) => { 89 | const { 90 | selection: { $head, from, to }, 91 | doc, 92 | } = editor.state; 93 | 94 | const parent = $head.node($head.depth - 1); 95 | 96 | if (parent.type.name !== "dBlock") return false; 97 | 98 | let currentActiveNodeTo = -1; 99 | 100 | doc.descendants((node, pos) => { 101 | if (currentActiveNodeTo !== -1) return false; 102 | // eslint-disable-next-line consistent-return 103 | if (node.type.name === this.name) return; 104 | 105 | const [nodeFrom, nodeTo] = [pos, pos + node.nodeSize]; 106 | 107 | if (nodeFrom <= from && to <= nodeTo) currentActiveNodeTo = nodeTo; 108 | 109 | return false; 110 | }); 111 | 112 | const content = doc.slice(from, currentActiveNodeTo)?.toJSON().content; 113 | 114 | return editor 115 | .chain() 116 | .insertContentAt( 117 | { from, to: currentActiveNodeTo }, 118 | { 119 | type: this.name, 120 | content, 121 | } 122 | ) 123 | .focus(from + 4) 124 | .run(); 125 | }, 126 | }; 127 | }, 128 | }); 129 | -------------------------------------------------------------------------------- /src/tiptap/Tiptap.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import { Editor } from "@tiptap/core"; 3 | import { EditorContent, useEditor } from "@tiptap/react"; 4 | import { debounce } from 'lodash'; 5 | import { useCallback, useState } from "react"; 6 | import "tippy.js/animations/shift-toward-subtle.css"; 7 | // import applyDevTools from "prosemirror-dev-tools"; 8 | 9 | import { getExtensions } from "./extensions"; 10 | import { CustomBubbleMenu, LinkBubbleMenu } from "./menus"; 11 | import { content } from "./mocks"; 12 | import { notitapEditorClass } from './proseClassString' 13 | 14 | import "./styles/tiptap.scss"; 15 | 16 | export const Tiptap = () => { 17 | const logContent = useCallback( 18 | (e: Editor) => console.log(e.getJSON()), 19 | [] 20 | ); 21 | 22 | const [isAddingNewLink, setIsAddingNewLink] = useState(false); 23 | 24 | const openLinkModal = () => setIsAddingNewLink(true); 25 | 26 | const closeLinkModal = () => setIsAddingNewLink(false); 27 | 28 | const addImage = () => 29 | editor?.commands.setMedia({ 30 | src: "https://source.unsplash.com/8xznAGy4HcY/800x400", 31 | "media-type": "img", 32 | alt: "Something else", 33 | title: "Something", 34 | width: "800", 35 | height: "400", 36 | }); 37 | 38 | const videoUrl = 39 | "https://user-images.githubusercontent.com/45892659/178123048-0257e732-8cc2-466b-8447-1e2b7cd1b5d9.mov"; 40 | 41 | const addVideo = () => 42 | editor?.commands.setMedia({ 43 | src: videoUrl, 44 | "media-type": "video", 45 | alt: "Some Video", 46 | title: "Some Title Video", 47 | width: "400", 48 | height: "400", 49 | }); 50 | 51 | const editor = useEditor({ 52 | extensions: getExtensions({ openLinkModal }), 53 | content, 54 | editorProps: { 55 | attributes: { 56 | class: `${notitapEditorClass} focus:outline-none w-full`, 57 | spellcheck: "false", 58 | suppressContentEditableWarning: "true", 59 | }, 60 | }, 61 | onUpdate: debounce(({ editor: e }) => { 62 | logContent(e); 63 | }, 500), 64 | }); 65 | 66 | const addTable = () => editor?.commands.insertTable({ rows: 3, cols: 3, withHeaderRow: true }) 67 | 68 | 69 | return ( 70 | editor && ( 71 |
72 | 73 | 80 | 87 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 |
102 | ) 103 | ); 104 | }; 105 | -------------------------------------------------------------------------------- /src/tiptap/extensions/resizableMedia/mediaPasteDropPlugin/mediaPasteDropPlugin.ts: -------------------------------------------------------------------------------- 1 | import { Plugin, PluginKey } from "prosemirror-state"; 2 | 3 | export type UploadFnType = (image: File) => Promise; 4 | 5 | export const getMediaPasteDropPlugin = (upload: UploadFnType) => { 6 | return new Plugin({ 7 | key: new PluginKey("media-paste-drop"), 8 | props: { 9 | handlePaste(view, event) { 10 | const items = Array.from(event.clipboardData?.items || []); 11 | const { schema } = view.state; 12 | 13 | items.forEach((item) => { 14 | const file = item.getAsFile(); 15 | 16 | const isImageOrVideo = 17 | file?.type.indexOf("image") === 0 || 18 | file?.type.indexOf("video") === 0; 19 | 20 | if (isImageOrVideo) { 21 | event.preventDefault(); 22 | 23 | if (upload && file) { 24 | upload(file).then((src) => { 25 | const node = schema.nodes.resizableMedia.create({ 26 | src, 27 | "media-type": 28 | file.type.indexOf("image") === 0 ? "img" : "video", 29 | }); 30 | 31 | const transaction = view.state.tr.replaceSelectionWith(node); 32 | view.dispatch(transaction); 33 | }); 34 | } 35 | } else { 36 | const reader = new FileReader(); 37 | 38 | reader.onload = (readerEvent) => { 39 | const node = schema.nodes.resizableMedia.create({ 40 | src: readerEvent.target?.result, 41 | "media-type": "", 42 | }); 43 | 44 | const transaction = view.state.tr.replaceSelectionWith(node); 45 | view.dispatch(transaction); 46 | }; 47 | 48 | if (!file) return; 49 | 50 | reader.readAsDataURL(file); 51 | } 52 | }); 53 | 54 | return false; 55 | }, 56 | handleDrop(view, event) { 57 | const hasFiles = 58 | event.dataTransfer && 59 | event.dataTransfer.files && 60 | event.dataTransfer.files.length; 61 | 62 | if (!hasFiles) { 63 | return false; 64 | } 65 | 66 | const imagesAndVideos = Array.from( 67 | event.dataTransfer?.files ?? [] 68 | ).filter(({ type: t }) => /image|video/i.test(t)); 69 | 70 | if (imagesAndVideos.length === 0) return false; 71 | 72 | event.preventDefault(); 73 | 74 | const { schema } = view.state; 75 | 76 | const coordinates = view.posAtCoords({ 77 | left: event.clientX, 78 | top: event.clientY, 79 | }); 80 | 81 | if (!coordinates) return false; 82 | 83 | imagesAndVideos.forEach(async (imageOrVideo) => { 84 | const reader = new FileReader(); 85 | 86 | if (upload) { 87 | const node = schema.nodes.resizableMedia.create({ 88 | src: await upload(imageOrVideo), 89 | "media-type": imageOrVideo.type.includes("image") 90 | ? "img" 91 | : "video", 92 | }); 93 | 94 | const transaction = view.state.tr.insert(coordinates.pos, node); 95 | 96 | view.dispatch(transaction); 97 | } else { 98 | reader.onload = (readerEvent) => { 99 | const node = schema.nodes.resizableMedia.create({ 100 | src: readerEvent.target?.result, 101 | 102 | "media-type": imageOrVideo.type.includes("image") 103 | ? "img" 104 | : "video", 105 | }); 106 | 107 | const transaction = view.state.tr.insert(coordinates.pos, node); 108 | 109 | view.dispatch(transaction); 110 | }; 111 | 112 | reader.readAsDataURL(imageOrVideo); 113 | } 114 | }); 115 | 116 | return true; 117 | }, 118 | }, 119 | }); 120 | }; 121 | -------------------------------------------------------------------------------- /src/tiptap/extensions/link/helpers/autolink.ts: -------------------------------------------------------------------------------- 1 | import { 2 | combineTransactionSteps, 3 | findChildrenInRange, 4 | getChangedRanges, 5 | getMarksBetween, 6 | } from "@tiptap/core"; 7 | import { find, test } from "linkifyjs"; 8 | import { MarkType } from "prosemirror-model"; 9 | import { Plugin, PluginKey } from "prosemirror-state"; 10 | 11 | type AutolinkOptions = { 12 | type: MarkType; 13 | validate?: (url: string) => boolean; 14 | }; 15 | 16 | export function autolink(options: AutolinkOptions): Plugin { 17 | return new Plugin({ 18 | key: new PluginKey("autolink"), 19 | appendTransaction: (transactions, oldState, newState) => { 20 | const docChanges = 21 | transactions.some((transaction) => transaction.docChanged) && 22 | !oldState.doc.eq(newState.doc); 23 | const preventAutolink = transactions.some((transaction) => 24 | transaction.getMeta("preventAutolink") 25 | ); 26 | 27 | if (!docChanges || preventAutolink) { 28 | return; 29 | } 30 | 31 | const { tr } = newState; 32 | const transform = combineTransactionSteps(oldState.doc, [ 33 | ...transactions, 34 | ]); 35 | const { mapping } = transform; 36 | const changes = getChangedRanges(transform); 37 | 38 | changes.forEach(({ oldRange, newRange }) => { 39 | // at first we check if we have to remove links 40 | getMarksBetween(oldRange.from, oldRange.to, oldState.doc) 41 | .filter((item) => item.mark.type === options.type) 42 | .forEach((oldMark) => { 43 | const newFrom = mapping.map(oldMark.from); 44 | const newTo = mapping.map(oldMark.to); 45 | const newMarks = getMarksBetween( 46 | newFrom, 47 | newTo, 48 | newState.doc 49 | ).filter((item) => item.mark.type === options.type); 50 | 51 | if (!newMarks.length) { 52 | return; 53 | } 54 | 55 | const newMark = newMarks[0]; 56 | const oldLinkText = oldState.doc.textBetween( 57 | oldMark.from, 58 | oldMark.to, 59 | undefined, 60 | " " 61 | ); 62 | const newLinkText = newState.doc.textBetween( 63 | newMark.from, 64 | newMark.to, 65 | undefined, 66 | " " 67 | ); 68 | const wasLink = test(oldLinkText); 69 | const isLink = test(newLinkText); 70 | 71 | // remove only the link, if it was a link before too 72 | // because we don’t want to remove links that were set manually 73 | if (wasLink && !isLink) { 74 | tr.removeMark(newMark.from, newMark.to, options.type); 75 | } 76 | }); 77 | 78 | // now let’s see if we can add new links 79 | findChildrenInRange( 80 | newState.doc, 81 | newRange, 82 | (node) => node.isTextblock 83 | ).forEach((textBlock) => { 84 | // we need to define a placeholder for leaf nodes 85 | // so that the link position can be calculated correctly 86 | const text = newState.doc.textBetween( 87 | textBlock.pos, 88 | textBlock.pos + textBlock.node.nodeSize, 89 | undefined, 90 | " " 91 | ); 92 | 93 | find(text) 94 | .filter((link) => link.isLink) 95 | .filter((link) => { 96 | if (options.validate) { 97 | return options.validate(link.value); 98 | } 99 | 100 | return true; 101 | }) 102 | // calculate link position 103 | .map((link) => ({ 104 | ...link, 105 | from: textBlock.pos + link.start + 1, 106 | to: textBlock.pos + link.end + 1, 107 | })) 108 | // check if link is within the changed range 109 | .filter((link) => { 110 | const fromIsInRange = 111 | newRange.from >= link.from && newRange.from <= link.to; 112 | const toIsInRange = 113 | newRange.to >= link.from && newRange.to <= link.to; 114 | 115 | return fromIsInRange || toIsInRange; 116 | }) 117 | // add link mark 118 | .forEach((link) => { 119 | tr.addMark( 120 | link.from, 121 | link.to, 122 | options.type.create({ 123 | href: link.href, 124 | }) 125 | ); 126 | }); 127 | }); 128 | }); 129 | 130 | if (!tr.steps.length) { 131 | return; 132 | } 133 | 134 | return tr; 135 | }, 136 | }); 137 | } 138 | -------------------------------------------------------------------------------- /src/tiptap/extensions/supercharged-table/extension-table-cell/TableCellNodeView.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable jsx-a11y/no-noninteractive-tabindex */ 2 | /* eslint-disable jsx-a11y/label-has-associated-control */ 3 | import React, { FC, useEffect, useRef, useState } from "react"; 4 | import { NodeViewContent, NodeViewWrapper, NodeViewProps } from "@tiptap/react"; 5 | import { Editor } from "@tiptap/core"; 6 | import Tippy from "@tippyjs/react"; 7 | 8 | import "./styles.scss"; 9 | 10 | interface CellButton { 11 | name: string; 12 | action: (editor: Editor) => boolean; 13 | iconClass?: string; 14 | } 15 | 16 | const cellButtonsConfig: CellButton[] = [ 17 | { 18 | name: "Add row above", 19 | action: (editor) => editor.chain().focus().addRowBefore().run(), 20 | iconClass: "i-mdi-table-row-plus-before", 21 | }, 22 | { 23 | name: "Add row below", 24 | action: (editor) => editor.chain().focus().addRowAfter().run(), 25 | iconClass: "i-mdi-table-row-plus-after", 26 | }, 27 | { 28 | name: "Add column before", 29 | action: (editor) => editor.chain().focus().addColumnBefore().run(), 30 | iconClass: "i-mdi-table-column-plus-before", 31 | }, 32 | { 33 | name: "Add column after", 34 | action: (editor) => editor.chain().focus().addColumnAfter().run(), 35 | iconClass: "i-mdi-table-column-plus-after", 36 | }, 37 | { 38 | name: "Remove row", 39 | action: (editor) => editor.chain().focus().deleteRow().run(), 40 | iconClass: "i-mdi-table-row-remove", 41 | }, 42 | { 43 | name: "Remove col", 44 | action: (editor) => editor.chain().focus().deleteColumn().run(), 45 | iconClass: "i-mdi-table-column-remove", 46 | }, 47 | { 48 | name: "Toggle header row", 49 | action: (editor) => editor.chain().focus().toggleHeaderRow().run(), 50 | iconClass: "i-mdi-table-row", 51 | }, 52 | { 53 | name: "Toggle header column", 54 | action: (editor) => editor.chain().focus().toggleHeaderColumn().run(), 55 | iconClass: "i-mdi-table-column", 56 | }, 57 | { 58 | name: "Toggle header cell", 59 | action: (editor) => editor.chain().focus().toggleHeaderCell().run(), 60 | iconClass: "i-mdi-table-border", 61 | }, 62 | { 63 | name: "Remove table", 64 | action: (editor) => editor.chain().focus().deleteTable().run(), 65 | iconClass: "i-mdi-table-remove", 66 | }, 67 | ]; 68 | 69 | export const TableCellNodeView: FC = ({ 70 | node, 71 | getPos, 72 | selected, 73 | editor, 74 | }) => { 75 | const [isCurrentCellActive, setIsCurrentCellActive] = useState(false); 76 | 77 | const tableCellOptionsButtonRef = useRef(null); 78 | 79 | const calculateActiveSateOfCurrentCell = () => { 80 | const { from, to } = editor.state.selection; 81 | 82 | const nodeFrom = getPos(); 83 | const nodeTo = nodeFrom + node.nodeSize; 84 | 85 | setIsCurrentCellActive(nodeFrom <= from && to <= nodeTo); 86 | }; 87 | 88 | useEffect(() => { 89 | editor.on("selectionUpdate", calculateActiveSateOfCurrentCell); 90 | 91 | setTimeout(calculateActiveSateOfCurrentCell, 100); 92 | 93 | return () => { 94 | editor.off("selectionUpdate", calculateActiveSateOfCurrentCell); 95 | }; 96 | }); 97 | 98 | const gimmeDropdownStyles = (): React.CSSProperties => { 99 | let top = tableCellOptionsButtonRef.current?.clientTop; 100 | if (top) top += 5; 101 | 102 | let left = tableCellOptionsButtonRef.current?.clientLeft; 103 | if (left) left += 5; 104 | 105 | return { 106 | top: `${top}px`, 107 | left: `${left}px`, 108 | }; 109 | }; 110 | 111 | return ( 112 | 113 | 114 | 115 | {(isCurrentCellActive || selected) && ( 116 | 124 |
    129 | {cellButtonsConfig.map((btn) => { 130 | return ( 131 |
  • 132 | 143 |
  • 144 | ); 145 | })} 146 |
147 | 148 | } 149 | > 150 | 157 |
158 | )} 159 |
160 | ); 161 | }; 162 | -------------------------------------------------------------------------------- /src/tiptap/extensions/resizableMedia/resizableMedia.ts: -------------------------------------------------------------------------------- 1 | import { mergeAttributes, Node, nodeInputRule } from "@tiptap/core"; 2 | import { ReactNodeViewRenderer } from "@tiptap/react"; 3 | 4 | import { getMediaPasteDropPlugin, UploadFnType } from "./mediaPasteDropPlugin"; 5 | 6 | import { ResizableMediaNodeView } from "./ResizableMediaNodeView"; 7 | 8 | declare module "@tiptap/core" { 9 | interface Commands { 10 | resizableMedia: { 11 | /** 12 | * Set media 13 | */ 14 | setMedia: (options: { 15 | "media-type": "img" | "video"; 16 | src: string; 17 | alt?: string; 18 | title?: string; 19 | width?: string; 20 | height?: string; 21 | }) => ReturnType; 22 | }; 23 | } 24 | } 25 | 26 | export interface MediaOptions { 27 | // inline: boolean, // we have floating support, so block is good enough 28 | // allowBase64: boolean, // we're not going to allow this 29 | HTMLAttributes: Record; 30 | uploadFn: UploadFnType; 31 | } 32 | 33 | export const IMAGE_INPUT_REGEX = 34 | /(?:^|\s)(!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\))$/; 35 | 36 | export const VIDEO_INPUT_REGEX = 37 | /!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\)/; 38 | 39 | export const ResizableMedia = Node.create({ 40 | name: "resizableMedia", 41 | 42 | addOptions() { 43 | return { 44 | HTMLAttributes: {}, 45 | uploadFn: async () => { 46 | return ""; 47 | }, 48 | }; 49 | }, 50 | 51 | inline: false, 52 | 53 | group: "block", 54 | 55 | draggable: true, 56 | 57 | addAttributes() { 58 | return { 59 | src: { 60 | default: null, 61 | }, 62 | "media-type": { 63 | default: null, 64 | }, 65 | alt: { 66 | default: null, 67 | }, 68 | title: { 69 | default: null, 70 | }, 71 | width: { 72 | default: "100%", 73 | }, 74 | height: { 75 | default: "auto", 76 | }, 77 | dataAlign: { 78 | default: "left", // 'left' | 'center' | 'right' 79 | }, 80 | dataFloat: { 81 | default: null, // 'left' | 'right' 82 | }, 83 | }; 84 | }, 85 | 86 | selectable: true, 87 | 88 | parseHTML() { 89 | return [ 90 | { 91 | tag: 'img[src]:not([src^="data:"])', 92 | getAttrs: (el) => ({ 93 | src: (el as HTMLImageElement).getAttribute("src"), 94 | "media-type": "img", 95 | }), 96 | }, 97 | { 98 | tag: "video", 99 | getAttrs: (el) => ({ 100 | src: (el as HTMLVideoElement).getAttribute("src"), 101 | "media-type": "video", 102 | }), 103 | }, 104 | ]; 105 | }, 106 | 107 | renderHTML({ HTMLAttributes }) { 108 | const { "media-type": mediaType } = HTMLAttributes; 109 | 110 | if (mediaType === "img") { 111 | return [ 112 | "img", 113 | mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 114 | ]; 115 | } 116 | if (mediaType === "video") { 117 | return [ 118 | "video", 119 | { controls: "true", style: "width: 100%", ...HTMLAttributes }, 120 | ["source", HTMLAttributes], 121 | ]; 122 | } 123 | 124 | if (!mediaType) 125 | console.error( 126 | "TiptapMediaExtension-renderHTML method: Media Type not set, going default with image" 127 | ); 128 | 129 | return [ 130 | "img", 131 | mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 132 | ]; 133 | }, 134 | 135 | addCommands() { 136 | return { 137 | setMedia: 138 | (options) => 139 | ({ commands }) => { 140 | const { "media-type": mediaType } = options; 141 | 142 | if (mediaType === "img") { 143 | return commands.insertContent({ 144 | type: this.name, 145 | attrs: options, 146 | }); 147 | } 148 | if (mediaType === "video") { 149 | return commands.insertContent({ 150 | type: this.name, 151 | attrs: { 152 | ...options, 153 | controls: "true", 154 | }, 155 | }); 156 | } 157 | 158 | if (!mediaType) 159 | console.error( 160 | "TiptapMediaExtension-setMedia: Media Type not set, going default with image" 161 | ); 162 | 163 | return commands.insertContent({ 164 | type: this.name, 165 | attrs: options, 166 | }); 167 | }, 168 | }; 169 | }, 170 | 171 | addNodeView() { 172 | return ReactNodeViewRenderer(ResizableMediaNodeView); 173 | }, 174 | 175 | addInputRules() { 176 | return [ 177 | nodeInputRule({ 178 | find: IMAGE_INPUT_REGEX, 179 | type: this.type, 180 | getAttributes: (match) => { 181 | const [, , alt, src, title] = match; 182 | 183 | return { 184 | src, 185 | alt, 186 | title, 187 | "media-type": "img", 188 | }; 189 | }, 190 | }), 191 | nodeInputRule({ 192 | find: VIDEO_INPUT_REGEX, 193 | type: this.type, 194 | getAttributes: (match) => { 195 | const [, , src] = match; 196 | 197 | return { 198 | src, 199 | "media-type": "video", 200 | }; 201 | }, 202 | }), 203 | ]; 204 | }, 205 | 206 | addProseMirrorPlugins() { 207 | return [getMediaPasteDropPlugin(this.options.uploadFn)]; 208 | }, 209 | }); 210 | -------------------------------------------------------------------------------- /src/tiptap/extensions/link/link.ts: -------------------------------------------------------------------------------- 1 | import { Mark, markPasteRule, mergeAttributes } from "@tiptap/core"; 2 | import { find, registerCustomProtocol } from "linkifyjs"; 3 | 4 | import { autolink } from "./helpers/autolink"; 5 | import { clickHandler } from "./helpers/clickHandler"; 6 | import { pasteHandler } from "./helpers/pasteHandler"; 7 | 8 | export interface LinkOptions { 9 | /** 10 | * If enabled, it adds links as you type. 11 | */ 12 | autolink: boolean; 13 | /** 14 | * An array of custom protocols to be registered with linkifyjs. 15 | */ 16 | protocols: Array; 17 | /** 18 | * If enabled, links will be opened on click. 19 | */ 20 | openOnClick: boolean; 21 | /** 22 | * Adds a link to the current selection if the pasted content only contains an url. 23 | */ 24 | linkOnPaste: boolean; 25 | /** 26 | * A list of HTML attributes to be rendered. 27 | */ 28 | HTMLAttributes: Record; 29 | /** 30 | * A validation function that modifies link verification for the auto linker. 31 | * @param url - The url to be validated. 32 | * @returns - True if the url is valid, false otherwise. 33 | */ 34 | validate?: (url: string) => boolean; 35 | /** 36 | * Runs a provided function when `Mod-k` is pressed 37 | */ 38 | onModKPressed?: () => any; 39 | } 40 | 41 | declare module "@tiptap/core" { 42 | interface Commands { 43 | link: { 44 | /** 45 | * Set a link mark 46 | */ 47 | setLink: (attributes: { href: string; target?: string }) => ReturnType; 48 | /** 49 | * Toggle a link mark 50 | */ 51 | toggleLink: (attributes: { href: string; target?: string }) => ReturnType; 52 | /** 53 | * Unset a link mark 54 | */ 55 | unsetLink: () => ReturnType; 56 | }; 57 | } 58 | } 59 | 60 | export const Link = Mark.create({ 61 | name: "link", 62 | 63 | priority: 1000, 64 | 65 | keepOnSplit: false, 66 | 67 | onCreate() { 68 | this.options.protocols.forEach(registerCustomProtocol); 69 | }, 70 | 71 | inclusive() { 72 | return this.options.autolink; 73 | }, 74 | 75 | addOptions() { 76 | return { 77 | openOnClick: true, 78 | linkOnPaste: true, 79 | autolink: true, 80 | protocols: [], 81 | HTMLAttributes: { 82 | target: "_blank", 83 | rel: "noopener noreferrer nofollow", 84 | class: null, 85 | }, 86 | validate: undefined, 87 | onModKPressed: () => false, 88 | }; 89 | }, 90 | 91 | addAttributes() { 92 | return { 93 | href: { 94 | default: null, 95 | }, 96 | target: { 97 | default: this.options.HTMLAttributes.target, 98 | }, 99 | class: { 100 | default: this.options.HTMLAttributes.class, 101 | }, 102 | }; 103 | }, 104 | 105 | parseHTML() { 106 | return [{ tag: 'a[href]:not([href *= "javascript:" i])' }]; 107 | }, 108 | 109 | renderHTML({ HTMLAttributes }) { 110 | return [ 111 | "a", 112 | mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 113 | 0, 114 | ]; 115 | }, 116 | 117 | addCommands() { 118 | return { 119 | setLink: 120 | (attributes) => 121 | ({ chain }) => { 122 | return chain() 123 | .setMark(this.name, attributes) 124 | .setMeta("preventAutolink", true) 125 | .run(); 126 | }, 127 | 128 | toggleLink: 129 | (attributes) => 130 | ({ chain }) => { 131 | return chain() 132 | .toggleMark(this.name, attributes, { extendEmptyMarkRange: true }) 133 | .setMeta("preventAutolink", true) 134 | .run(); 135 | }, 136 | 137 | unsetLink: 138 | () => 139 | ({ chain }) => { 140 | return chain() 141 | .unsetMark(this.name, { extendEmptyMarkRange: true }) 142 | .setMeta("preventAutolink", true) 143 | .run(); 144 | }, 145 | }; 146 | }, 147 | 148 | addPasteRules() { 149 | return [ 150 | markPasteRule({ 151 | find: (text) => 152 | find(text) 153 | .filter((link) => { 154 | if (this.options.validate) { 155 | return this.options.validate(link.value); 156 | } 157 | 158 | return true; 159 | }) 160 | .filter((link) => link.isLink) 161 | .map((link) => ({ 162 | text: link.value, 163 | index: link.start, 164 | data: link, 165 | })), 166 | type: this.type, 167 | getAttributes: (match) => ({ 168 | href: match.data?.href, 169 | }), 170 | }), 171 | ]; 172 | }, 173 | 174 | addKeyboardShortcuts() { 175 | return { 176 | "Mod-k": () => { 177 | this.options.onModKPressed?.(); 178 | return false; 179 | }, 180 | }; 181 | }, 182 | 183 | addProseMirrorPlugins() { 184 | const plugins = []; 185 | 186 | if (this.options.autolink) { 187 | plugins.push( 188 | autolink({ 189 | type: this.type, 190 | validate: this.options.validate, 191 | }) 192 | ); 193 | } 194 | 195 | if (this.options.openOnClick) { 196 | plugins.push( 197 | clickHandler({ 198 | type: this.type, 199 | }) 200 | ); 201 | } 202 | 203 | if (this.options.linkOnPaste) { 204 | plugins.push( 205 | pasteHandler({ 206 | editor: this.editor, 207 | type: this.type, 208 | }) 209 | ); 210 | } 211 | 212 | return plugins; 213 | }, 214 | }); 215 | -------------------------------------------------------------------------------- /src/tiptap/extensions/starter-kit.ts: -------------------------------------------------------------------------------- 1 | import { AnyExtension, Editor, Extension } from "@tiptap/core"; 2 | import Text from "@tiptap/extension-text"; 3 | import BulletList from "@tiptap/extension-bullet-list"; 4 | import OrderedList from "@tiptap/extension-ordered-list"; 5 | import ListItem from "@tiptap/extension-list-item"; 6 | import Bold from "@tiptap/extension-bold"; 7 | import Italic from "@tiptap/extension-italic"; 8 | import Strike from "@tiptap/extension-strike"; 9 | import Underline from "@tiptap/extension-underline"; 10 | import DropCursor from "@tiptap/extension-dropcursor"; 11 | import GapCursor from "@tiptap/extension-gapcursor"; 12 | import History from "@tiptap/extension-history"; 13 | import HardBreak from "@tiptap/extension-hard-break"; 14 | import Heading from "@tiptap/extension-heading"; 15 | 16 | import { Node as ProsemirrorNode } from "prosemirror-model"; 17 | import { Plugin } from "prosemirror-state"; 18 | import { Decoration, DecorationSet } from "prosemirror-view"; 19 | 20 | import { Document } from "./doc"; 21 | import { DBlock } from "./dBlock"; 22 | import { Link } from "./link"; 23 | import { Paragraph } from "./paragraph"; 24 | import { SuperchargedTableExtensions } from "./supercharged-table"; 25 | import { ResizableMedia } from "./resizableMedia"; 26 | import { TrailingNode } from "./trailingNode"; 27 | 28 | export interface PlaceholderOptions { 29 | emptyEditorClass: string; 30 | emptyNodeClass: string; 31 | placeholder: 32 | | ((PlaceholderProps: { 33 | editor: Editor; 34 | node: ProsemirrorNode; 35 | pos: number; 36 | hasAnchor: boolean; 37 | }) => string) 38 | | string; 39 | showOnlyWhenEditable: boolean; 40 | showOnlyCurrent: boolean; 41 | includeChildren: boolean; 42 | } 43 | 44 | export const Placeholder = Extension.create({ 45 | name: "placeholder", 46 | 47 | addOptions() { 48 | return { 49 | emptyEditorClass: "is-editor-empty", 50 | emptyNodeClass: "is-empty", 51 | placeholder: "Write something …", 52 | showOnlyWhenEditable: true, 53 | showOnlyCurrent: true, 54 | includeChildren: false, 55 | }; 56 | }, 57 | 58 | addProseMirrorPlugins() { 59 | return [ 60 | new Plugin({ 61 | props: { 62 | decorations: ({ doc, selection }) => { 63 | const active = 64 | this.editor.isEditable || !this.options.showOnlyWhenEditable; 65 | const { anchor } = selection; 66 | const decorations: Decoration[] = []; 67 | 68 | if (!active) { 69 | return null; 70 | } 71 | 72 | doc.descendants((node, pos) => { 73 | const hasAnchor = anchor >= pos && anchor <= pos + node.nodeSize; 74 | const isEmpty = !node.isLeaf && !node.childCount; 75 | 76 | if ((hasAnchor || !this.options.showOnlyCurrent) && isEmpty) { 77 | const classes = [this.options.emptyNodeClass]; 78 | 79 | if (this.editor.isEmpty) { 80 | classes.push(this.options.emptyEditorClass); 81 | } 82 | 83 | const decoration = Decoration.node(pos, pos + node.nodeSize, { 84 | class: classes.join(" "), 85 | "data-placeholder": 86 | typeof this.options.placeholder === "function" 87 | ? this.options.placeholder({ 88 | editor: this.editor, 89 | node, 90 | pos, 91 | hasAnchor, 92 | }) 93 | : this.options.placeholder, 94 | }); 95 | 96 | decorations.push(decoration); 97 | } 98 | 99 | return this.options.includeChildren; 100 | }); 101 | 102 | return DecorationSet.create(doc, decorations); 103 | }, 104 | }, 105 | }), 106 | ]; 107 | }, 108 | }); 109 | 110 | interface GetExtensionsProps { 111 | openLinkModal: () => void; 112 | } 113 | 114 | export const getExtensions = ({ 115 | openLinkModal, 116 | }: GetExtensionsProps): AnyExtension[] => { 117 | return [ 118 | // Necessary 119 | Document, 120 | DBlock, 121 | Paragraph, 122 | Text, 123 | DropCursor.configure({ 124 | width: 2, 125 | class: "notitap-dropcursor", 126 | color: "skyblue", 127 | }), 128 | GapCursor, 129 | History, 130 | HardBreak, 131 | 132 | // marks 133 | Bold, 134 | Italic, 135 | Strike, 136 | Underline, 137 | Link.configure({ 138 | autolink: true, 139 | linkOnPaste: true, 140 | protocols: ["mailto"], 141 | openOnClick: false, 142 | onModKPressed: openLinkModal, 143 | }), 144 | 145 | // Node 146 | ListItem, 147 | BulletList, 148 | OrderedList, 149 | Heading.configure({ 150 | levels: [1, 2, 3], 151 | }), 152 | TrailingNode, 153 | 154 | // Table 155 | ...SuperchargedTableExtensions, 156 | 157 | // Resizable Media 158 | ResizableMedia.configure({ 159 | uploadFn: async (image) => { 160 | const fd = new FormData(); 161 | 162 | fd.append("file", image); 163 | 164 | try { 165 | const response = await fetch("https://api.imgur.com/3/image", { 166 | method: "POST", 167 | body: fd, 168 | }); 169 | 170 | console.log(await response.json()); 171 | } catch { 172 | // do your thing 173 | } finally { 174 | // do your thing 175 | } 176 | 177 | return "https://source.unsplash.com/8xznAGy4HcY/800x400"; 178 | }, 179 | }), 180 | 181 | Placeholder.configure({ 182 | placeholder: "Type `/` for commands", 183 | includeChildren: true, 184 | }), 185 | ]; 186 | }; 187 | -------------------------------------------------------------------------------- /src/tiptap/menus/bubble-menu/NodeTypeDropdown.tsx: -------------------------------------------------------------------------------- 1 | import { Editor } from "@tiptap/core"; 2 | import { useState } from "react"; 3 | import Tippy from "@tippyjs/react"; 4 | 5 | export const NodeTypeDropdown = ({ editor }: { editor: Editor }) => { 6 | const [isOpen, setIsOpen] = useState(false); 7 | 8 | const buttonText = () => { 9 | if (editor.isActive("heading", { level: 1 })) { 10 | return "Heading 1"; 11 | } 12 | if (editor.isActive("heading", { level: 2 })) { 13 | return "Heading 2"; 14 | } 15 | if (editor.isActive("heading", { level: 3 })) { 16 | return "Heading 3"; 17 | } 18 | if (editor.isActive("orderedList")) { 19 | return "Numbered list"; 20 | } 21 | if (editor.isActive("bulletList")) { 22 | return "Bulleted list"; 23 | } 24 | 25 | return "Normal text"; 26 | }; 27 | 28 | const isOnlyParagraph = 29 | !editor.isActive("bulletList") && 30 | !editor.isActive("orderedList") && 31 | !editor.isActive("heading"); 32 | 33 | return ( 34 | 42 |
43 | Turn into 44 |
45 | 56 | 76 | 96 | 116 | 132 | 148 | 149 | } 150 | > 151 | 159 |
160 | ); 161 | }; 162 | -------------------------------------------------------------------------------- /src/tiptap/extensions/bubble-menu/bubble-menu-plugin.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable class-methods-use-this */ 2 | import { 3 | Editor, 4 | isNodeSelection, 5 | isTextSelection, 6 | posToDOMRect, 7 | } from "@tiptap/core"; 8 | import { EditorState, Plugin, PluginKey } from "prosemirror-state"; 9 | import { EditorView } from "prosemirror-view"; 10 | import tippy, { Instance, Props } from "tippy.js"; 11 | 12 | export interface BubbleMenuPluginProps { 13 | pluginKey: PluginKey | string; 14 | editor: Editor; 15 | element: HTMLElement; 16 | tippyOptions?: Partial; 17 | shouldShow?: 18 | | ((props: { 19 | editor: Editor; 20 | view: EditorView; 21 | state: EditorState; 22 | oldState?: EditorState; 23 | from: number; 24 | to: number; 25 | }) => boolean) 26 | | null; 27 | } 28 | 29 | export type BubbleMenuViewProps = BubbleMenuPluginProps & { 30 | view: EditorView; 31 | }; 32 | 33 | export class BubbleMenuView { 34 | public editor: Editor; 35 | 36 | public element: HTMLElement; 37 | 38 | public view: EditorView; 39 | 40 | public preventHide = false; 41 | 42 | public tippy: Instance | undefined; 43 | 44 | public tippyOptions?: Partial; 45 | 46 | public shouldShow: Exclude = ({ 47 | view, 48 | state, 49 | from, 50 | to, 51 | }) => { 52 | const { doc, selection } = state; 53 | const { empty } = selection; 54 | 55 | // Sometime check for `empty` is not enough. 56 | // Doubleclick an empty paragraph returns a node size of 2. 57 | // So we check also for an empty text size. 58 | const isEmptyTextBlock = 59 | !doc.textBetween(from, to).length && isTextSelection(state.selection); 60 | 61 | if (!view.hasFocus() || empty || isEmptyTextBlock) { 62 | return false; 63 | } 64 | 65 | return true; 66 | }; 67 | 68 | constructor({ 69 | editor, 70 | element, 71 | view, 72 | tippyOptions = {}, 73 | shouldShow, 74 | }: BubbleMenuViewProps) { 75 | this.editor = editor; 76 | this.element = element; 77 | this.view = view; 78 | 79 | if (shouldShow) { 80 | this.shouldShow = shouldShow; 81 | } 82 | 83 | this.element.addEventListener("mousedown", this.mousedownHandler, { 84 | capture: true, 85 | }); 86 | this.view.dom.addEventListener("dragstart", this.dragstartHandler); 87 | this.editor.on("focus", this.focusHandler); 88 | // this.editor.on("blur", this.blurHandler); 89 | this.tippyOptions = tippyOptions; 90 | // Detaches menu content from its current parent 91 | this.element.remove(); 92 | this.element.style.visibility = "visible"; 93 | } 94 | 95 | mousedownHandler = () => { 96 | this.preventHide = true; 97 | }; 98 | 99 | dragstartHandler = () => { 100 | this.hide(); 101 | }; 102 | 103 | focusHandler = () => { 104 | // we use `setTimeout` to make sure `selection` is already updated 105 | setTimeout(() => this.update(this.editor.view)); 106 | }; 107 | 108 | blurHandler = ({ event }: { event: FocusEvent }) => { 109 | if (this.preventHide) { 110 | this.preventHide = false; 111 | 112 | return; 113 | } 114 | 115 | if ( 116 | event?.relatedTarget && 117 | this.element.parentNode?.contains(event.relatedTarget as Node) 118 | ) { 119 | return; 120 | } 121 | 122 | this.hide(); 123 | }; 124 | 125 | updateHandler = (view: EditorView, oldState?: EditorState) => { 126 | const { state, composing } = view; 127 | const { selection } = state; 128 | 129 | console.log("updated"); 130 | 131 | if (composing) return; 132 | 133 | this.createTooltip(); 134 | 135 | // support for CellSelections 136 | const { ranges } = selection; 137 | const from = Math.min(...ranges.map((range) => range.$from.pos)); 138 | const to = Math.max(...ranges.map((range) => range.$to.pos)); 139 | 140 | const shouldShow = this.shouldShow?.({ 141 | editor: this.editor, 142 | view, 143 | state, 144 | oldState, 145 | from, 146 | to, 147 | }); 148 | 149 | if (!shouldShow) { 150 | this.hide(); 151 | 152 | return; 153 | } 154 | 155 | this.tippy?.setProps({ 156 | getReferenceClientRect: 157 | this.tippyOptions?.getReferenceClientRect || 158 | (() => { 159 | if (isNodeSelection(state.selection)) { 160 | const node = view.nodeDOM(from) as HTMLElement; 161 | 162 | if (node) { 163 | return node.getBoundingClientRect(); 164 | } 165 | } 166 | 167 | return posToDOMRect(view, from, to); 168 | }), 169 | }); 170 | 171 | this.show(); 172 | }; 173 | 174 | createTooltip() { 175 | const { element: editorElement } = this.editor.options; 176 | const editorIsAttached = !!editorElement.parentElement; 177 | 178 | if (this.tippy || !editorIsAttached) { 179 | return; 180 | } 181 | 182 | this.tippy = tippy(editorElement, { 183 | duration: 0, 184 | getReferenceClientRect: null, 185 | content: this.element, 186 | interactive: true, 187 | trigger: "manual", 188 | placement: "top", 189 | hideOnClick: "toggle", 190 | ...this.tippyOptions, 191 | }); 192 | 193 | // maybe we have to hide tippy on its own blur event as well 194 | if (this.tippy.popper.firstChild) { 195 | (this.tippy.popper.firstChild as HTMLElement).addEventListener( 196 | "blur", 197 | (event) => { 198 | this.blurHandler({ event }); 199 | } 200 | ); 201 | } 202 | } 203 | 204 | update(view: EditorView, oldState?: EditorState) { 205 | setTimeout(() => { 206 | this.updateHandler(view, oldState); 207 | }, 250); 208 | } 209 | 210 | show() { 211 | this.tippy?.show(); 212 | } 213 | 214 | hide() { 215 | this.tippy?.hide(); 216 | } 217 | 218 | destroy() { 219 | this.tippy?.destroy(); 220 | this.element.removeEventListener("mousedown", this.mousedownHandler, { 221 | capture: true, 222 | }); 223 | this.view.dom.removeEventListener("dragstart", this.dragstartHandler); 224 | 225 | this.editor.off("focus", this.focusHandler); 226 | // this.editor.off("blur", this.blurHandler); 227 | } 228 | } 229 | 230 | export const BubbleMenuPlugin = (options: BubbleMenuPluginProps) => { 231 | return new Plugin({ 232 | key: 233 | typeof options.pluginKey === "string" 234 | ? new PluginKey(options.pluginKey) 235 | : options.pluginKey, 236 | view: (view) => new BubbleMenuView({ view, ...options }), 237 | }); 238 | }; 239 | -------------------------------------------------------------------------------- /src/tiptap/menus/bubble-menu/buttons.ts: -------------------------------------------------------------------------------- 1 | /* @unocss-include */ 2 | import { Editor } from "@tiptap/react"; 3 | 4 | interface BubbleMenuItem { 5 | tooltip: string; 6 | action: (editor: Editor) => boolean; 7 | isActive: (editor: Editor) => boolean; 8 | iconClass: string; 9 | } 10 | 11 | export const generalButtons: BubbleMenuItem[] = [ 12 | { 13 | tooltip: "Bold", 14 | action: (editor: Editor) => editor.chain().focus().toggleBold().run(), 15 | isActive: (editor: Editor) => editor.isActive("bold"), 16 | iconClass: "i-ri-bold", 17 | }, 18 | { 19 | tooltip: "Italic", 20 | action: (editor: Editor) => editor.chain().focus().toggleItalic().run(), 21 | isActive: (editor: Editor) => editor.isActive("italic"), 22 | iconClass: "i-ri-italic", 23 | }, 24 | // { 25 | // name: "underline", 26 | // label: "Underline", 27 | // action: (editor: Editor) => editor.chain().focus().toggleUnderline().run(), 28 | // isActive: (editor: Editor) => editor.isActive("underline"), 29 | // icon: RiUnderline, 30 | // }, 31 | // { 32 | // name: "strike", 33 | // label: "Strike", 34 | // action: (editor: Editor) => editor.chain().focus().toggleStrike().run(), 35 | // isActive: (editor: Editor) => editor.isActive("strike"), 36 | // icon: RiStrikethrough, 37 | // }, 38 | // { 39 | // name: "divider", 40 | // }, 41 | // { 42 | // name: "link", 43 | // label: "Link", 44 | // action: (openLinkModal: Function) => openLinkModal(), 45 | // isActive: (editor: Editor) => editor.isActive("link"), 46 | // icon: RiLink, 47 | // }, 48 | // { 49 | // name: "code", 50 | // label: "Code", 51 | // action: (editor: Editor) => editor.chain().focus().toggleCode().run(), 52 | // isActive: (editor: Editor) => editor.isActive("code"), 53 | // icon: RiCodeSSlashLine, 54 | // }, 55 | // { 56 | // name: "divider", 57 | // }, 58 | // { 59 | // name: "alignLeft", 60 | // label: "Align Left", 61 | // action: (editor: Editor) => 62 | // editor.chain().focus().setTextAlign("left").run(), 63 | // isActive: (editor: Editor) => editor.isActive({ textAlign: "left" }), 64 | // icon: RiAlignLeft, 65 | // }, 66 | // { 67 | // name: "alignCenter", 68 | // label: "Align Center", 69 | // action: (editor: Editor) => 70 | // editor.chain().focus().setTextAlign("center").run(), 71 | // isActive: (editor: Editor) => editor.isActive({ textAlign: "center" }), 72 | // icon: RiAlignCenter, 73 | // }, 74 | // { 75 | // name: "alignRight", 76 | // label: "Align Right", 77 | // action: (editor: Editor) => 78 | // editor.chain().focus().setTextAlign("right").run(), 79 | // isActive: (editor: Editor) => editor.isActive({ textAlign: "right" }), 80 | // icon: RiAlignRight, 81 | // }, 82 | // { 83 | // name: "alignJustify", 84 | // label: "Align Justify", 85 | // action: (editor: Editor) => 86 | // editor.chain().focus().setTextAlign("justify").run(), 87 | // isActive: (editor: Editor) => editor.isActive({ textAlign: "justify" }), 88 | // icon: RiAlignJustify, 89 | // }, 90 | // { 91 | // name: "divider", 92 | // }, 93 | // { 94 | // name: "h1", 95 | // label: "H1", 96 | // action: (editor: Editor) => 97 | // editor.chain().focus().toggleHeading({ level: 1 }).run(), 98 | // isActive: (editor: Editor) => editor.isActive("heading", { level: 1 }), 99 | // icon: RiH1, 100 | // }, 101 | // { 102 | // name: "h2", 103 | // label: "H2", 104 | // action: (editor: Editor) => 105 | // editor.chain().focus().toggleHeading({ level: 2 }).run(), 106 | // isActive: (editor: Editor) => editor.isActive("heading", { level: 2 }), 107 | // icon: RiH2, 108 | // }, 109 | // { 110 | // name: "h3", 111 | // label: "H3", 112 | // action: (editor: Editor) => 113 | // editor.chain().focus().toggleHeading({ level: 3 }).run(), 114 | // isActive: (editor: Editor) => editor.isActive("heading", { level: 3 }), 115 | // icon: RiH3, 116 | // }, 117 | // { 118 | // name: "divider", 119 | // }, 120 | // { 121 | // name: "orderedList", 122 | // label: "Ordered List", 123 | // action: (editor: Editor) => 124 | // editor.chain().focus().toggleOrderedList().run(), 125 | // isActive: (editor: Editor) => editor.isActive("orderedList"), 126 | // icon: RiListOrdered, 127 | // }, 128 | // { 129 | // name: "bulletList", 130 | // label: "Bullet List", 131 | // action: (editor: Editor) => editor.chain().focus().toggleBulletList().run(), 132 | // isActive: (editor: Editor) => editor.isActive("bulletList"), 133 | // icon: RiListUnordered, 134 | // }, 135 | // { 136 | // name: "taskList", 137 | // label: "Task List", 138 | // action: (editor: Editor) => editor.chain().focus().toggleTaskList().run(), 139 | // isActive: (editor: Editor) => editor.isActive("taskList"), 140 | // icon: RiListCheck2, 141 | // }, 142 | // { 143 | // name: "divider", 144 | // }, 145 | // { 146 | // name: "table", 147 | // label: "Table", 148 | // action: () => null, 149 | // isActive: (editor: Editor) => editor.can().deleteTable(), 150 | // icon: RiTableLine, 151 | // }, 152 | // { 153 | // name: "blockquote", 154 | // label: "Blockquote", 155 | // action: (editor: Editor) => editor.chain().focus().toggleBlockquote().run(), 156 | // isActive: (editor: Editor) => editor.isActive("blockquote"), 157 | // icon: RiDoubleQuotesL, 158 | // }, 159 | // { 160 | // name: "codeBlock", 161 | // label: "Code Block", 162 | // action: (editor: Editor) => editor.chain().focus().toggleCodeBlock().run(), 163 | // isActive: (editor: Editor) => editor.isActive("codeBlock"), 164 | // icon: RiCodeBoxLine, 165 | // }, 166 | // { 167 | // name: "divider", 168 | // }, 169 | // { 170 | // name: 'horizontalRule', 171 | // label: 'Horizontal Rule', 172 | // action: (editor: Editor) => editor.chain().focus().setHorizontalRule().run(), 173 | // icon: RiSeparator, 174 | // }, 175 | // { 176 | // name: 'hardBreak', 177 | // label: 'Hard Break', 178 | // action: (editor: Editor) => editor.chain().focus().setHardBreak().run(), 179 | // icon: RiTextWrap, 180 | // }, 181 | // { 182 | // name: 'divider', 183 | // }, 184 | // { 185 | // name: 'undo', 186 | // label: 'Undo', 187 | // action: (editor: Editor) => editor.chain().focus().undo().run(), 188 | // icon: RiArrowGoBackLine, 189 | // }, 190 | // { 191 | // name: 'redo', 192 | // label: 'Redo', 193 | // action: (editor: Editor) => editor.chain().focus().redo().run(), 194 | // icon: RiArrowGoForwardLine, 195 | // } 196 | ]; 197 | -------------------------------------------------------------------------------- /src/tiptap/extensions/slash-menu/suggestions.ts: -------------------------------------------------------------------------------- 1 | /* @unocss-include */ 2 | import { Editor, Range } from "@tiptap/core"; 3 | import { ReactRenderer } from "@tiptap/react"; 4 | import fuzzysort from "fuzzysort"; 5 | import tippy from "tippy.js"; 6 | 7 | import { stopPrevent } from "../../utils"; 8 | import { CommandList } from "./CommandList"; 9 | 10 | interface SlashMenuItem { 11 | title: string; 12 | command: (params: { editor: Editor; range: Range }) => void; 13 | iconClass: string; 14 | shortcut: string; 15 | type: string; 16 | desc: string; 17 | } 18 | 19 | const SlashMenuItems: Partial[] = [ 20 | // { 21 | // type: "divider", 22 | // title: "Basic Blocks", 23 | // }, 24 | { 25 | title: "Heading 1", 26 | command: ({ editor, range }) => { 27 | editor 28 | .chain() 29 | .focus() 30 | .deleteRange(range) 31 | .setNode("heading", { level: 1 }) 32 | .run(); 33 | }, 34 | iconClass: "i-ri-h1", 35 | shortcut: "#", 36 | }, 37 | { 38 | title: "Heading 2", 39 | command: ({ editor, range }) => { 40 | editor 41 | .chain() 42 | .focus() 43 | .deleteRange(range) 44 | .setNode("heading", { level: 2 }) 45 | .run(); 46 | }, 47 | iconClass: "i-ri-h2", 48 | shortcut: "##", 49 | }, 50 | { 51 | title: "Heading 3", 52 | command: ({ editor, range }) => { 53 | editor 54 | .chain() 55 | .focus() 56 | .deleteRange(range) 57 | .setNode("heading", { level: 3 }) 58 | .run(); 59 | }, 60 | iconClass: "i-ri-h3", 61 | shortcut: "###", 62 | }, 63 | // { 64 | // title: "Ordered List", 65 | // command: ({ editor, range }) => { 66 | // editor.chain().focus().deleteRange(range).toggleOrderedList().run(); 67 | // }, 68 | // iconClass: "i-ri-list-ordered", 69 | // shortcut: "1. L", 70 | // }, 71 | // { 72 | // title: "Bullet List", 73 | // command: ({ editor, range }) => { 74 | // editor.chain().focus().deleteRange(range).toggleBulletList().run(); 75 | // }, 76 | // iconClass: "i-ri-list-unordered", 77 | // shortcut: "- L", 78 | // }, 79 | // { 80 | // title: "Task List", 81 | // command: ({ editor, range }) => { 82 | // editor.chain().focus().deleteRange(range).toggleTaskList().run(); 83 | // }, 84 | // iconClass: "i-ri-list-check-2", 85 | // }, 86 | // { 87 | // title: "Blockquote", 88 | // command: ({ editor, range }) => { 89 | // editor.chain().focus().deleteRange(range).setBlockquote().run(); 90 | // }, 91 | // iconClass: "i-ri-double-quotes-l", 92 | // shortcut: ">", 93 | // }, 94 | // { 95 | // title: "Code Block", 96 | // command: ({ editor, range }) => { 97 | // editor 98 | // .chain() 99 | // .focus() 100 | // .deleteRange(range) 101 | // .setCodeBlock({ language: "auto" }) 102 | // .run(); 103 | // }, 104 | // iconClass: "i-ri-code-box-line", 105 | // shortcut: "```", 106 | // }, 107 | { 108 | title: "Bold", 109 | command: ({ editor, range }) => { 110 | editor.chain().focus().deleteRange(range).setMark("bold").run(); 111 | }, 112 | iconClass: "i-ri-bold", 113 | shortcut: "**b**", 114 | }, 115 | { 116 | title: "Italic", 117 | command: ({ editor, range }) => { 118 | editor.chain().focus().deleteRange(range).setMark("italic").run(); 119 | }, 120 | iconClass: "i-ri-italic", 121 | shortcut: "_i_", 122 | }, 123 | // { 124 | // title: "Underline", 125 | // command: ({ editor, range }) => { 126 | // editor.chain().focus().deleteRange(range).setMark("underline").run(); 127 | // }, 128 | // iconClass: "i-ri-underline", 129 | // }, 130 | // { 131 | // title: "Strike", 132 | // command: ({ editor, range }) => { 133 | // editor.chain().focus().deleteRange(range).setMark("strike").run(); 134 | // }, 135 | // iconClass: "i-ri-strikethrough", 136 | // shortcut: "~~s~~", 137 | // }, 138 | // { 139 | // title: "Code", 140 | // command: ({ editor, range }) => { 141 | // editor.chain().focus().deleteRange(range).setMark("code").run(); 142 | // }, 143 | // iconClass: "i-ri-code-s-slash-line", 144 | // shortcut: "`i`", 145 | // }, 146 | ]; 147 | 148 | export const suggestions = { 149 | items: ({ query: q }: { query: string }) => { 150 | const query = q.toLowerCase().trim(); 151 | 152 | if (!query) return SlashMenuItems; 153 | 154 | const fuzzyResults = fuzzysort 155 | .go(query, SlashMenuItems, { key: "title" }) 156 | .map((item) => ({ 157 | ...item, 158 | highlightedTitle: fuzzysort.highlight(item, "", ""), 159 | })); 160 | 161 | return fuzzyResults.map(({ obj, highlightedTitle }) => ({ 162 | ...obj, 163 | highlightedTitle, 164 | })); 165 | }, 166 | 167 | render: () => { 168 | let component: ReactRenderer; 169 | let popup: { destroy: () => void }[]; 170 | let localProps: Record | undefined; 171 | 172 | return { 173 | onStart: (props: Record | undefined) => { 174 | localProps = { ...props, event: "" }; 175 | 176 | component = new ReactRenderer(CommandList, { 177 | props: localProps, 178 | editor: localProps?.editor, 179 | }); 180 | 181 | popup = tippy("body", { 182 | getReferenceClientRect: localProps.clientRect, 183 | appendTo: () => document.body, 184 | content: component.element, 185 | showOnCreate: true, 186 | interactive: true, 187 | trigger: "manual", 188 | placement: "bottom-start", 189 | animation: "shift-toward-subtle", 190 | duration: 250, 191 | }); 192 | }, 193 | 194 | onUpdate(props: Record | undefined) { 195 | localProps = { ...props, event: "" }; 196 | 197 | component.updateProps(localProps); 198 | 199 | (popup[0] as any).setProps({ 200 | getReferenceClientRect: localProps.clientRect, 201 | }); 202 | }, 203 | 204 | onKeyDown(props: { event: KeyboardEvent }) { 205 | component.updateProps({ ...localProps, event: props.event }); 206 | 207 | (component.ref as any).onKeyDown({ event: props.event }); 208 | 209 | if (props.event.key === "Escape") { 210 | (popup[0] as any).hide(); 211 | 212 | return true; 213 | } 214 | 215 | if (props.event.key === "Enter") { 216 | stopPrevent(props.event); 217 | 218 | return true; 219 | } 220 | 221 | return false; 222 | }, 223 | 224 | onExit() { 225 | if (popup && popup[0]) popup[0]?.destroy(); 226 | if (component) component.destroy(); 227 | }, 228 | }; 229 | }, 230 | }; 231 | -------------------------------------------------------------------------------- /src/tiptap/extensions/supercharged-table/extension-table-row/table-row.ts: -------------------------------------------------------------------------------- 1 | import { mergeAttributes, Node } from "@tiptap/core"; 2 | import { NodeSelection } from "prosemirror-state"; 3 | import { stopPrevent } from "@/tiptap/utils"; 4 | 5 | export interface TableRowOptions { 6 | HTMLAttributes: Record; 7 | } 8 | 9 | const isScrollable = function (ele: any) { 10 | const hasScrollableContent = ele.scrollHeight > ele.clientHeight; 11 | 12 | const overflowYStyle = window.getComputedStyle(ele).overflowY; 13 | const isOverflowHidden = overflowYStyle.indexOf("hidden") !== -1; 14 | 15 | return hasScrollableContent && !isOverflowHidden; 16 | }; 17 | 18 | const getScrollableParent = function (ele: any): any { 19 | // eslint-disable-next-line no-nested-ternary 20 | return !ele || ele === document.body 21 | ? document.body 22 | : isScrollable(ele) 23 | ? ele 24 | : getScrollableParent(ele.parentNode); 25 | }; 26 | 27 | const getElementWithAttributes = ( 28 | name: string, 29 | attrs?: Record, 30 | events?: Record 31 | ) => { 32 | const el = document.createElement(name); 33 | 34 | if (!el) throw new Error(`Element with name ${name} can't be created.`); 35 | 36 | if (attrs) { 37 | Object.entries(attrs).forEach(([key, val]) => el.setAttribute(key, val)); 38 | } 39 | 40 | if (events) { 41 | Object.entries(events).forEach(([key, val]) => 42 | el.addEventListener(key, val) 43 | ); 44 | } 45 | 46 | return el; 47 | }; 48 | 49 | export const TableRow = Node.create({ 50 | name: "tableRow", 51 | 52 | addOptions() { 53 | return { 54 | HTMLAttributes: {}, 55 | }; 56 | }, 57 | 58 | content: "(tableCell | tableHeader)*", 59 | 60 | tableRole: "row", 61 | 62 | parseHTML() { 63 | return [{ tag: "tr" }]; 64 | }, 65 | 66 | renderHTML({ HTMLAttributes }) { 67 | return [ 68 | "tr", 69 | mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 70 | 0, 71 | ]; 72 | }, 73 | 74 | addNodeView() { 75 | return ({ editor, HTMLAttributes, getPos }) => { 76 | // Markup 77 | /* 78 | 79 |
80 | 81 |
82 | 83 | 84 | 85 | */ 86 | 87 | const pos = () => (getPos as () => number)(); 88 | 89 | const scrollableParent = getScrollableParent( 90 | editor.options.element 91 | ) as HTMLDivElement; 92 | 93 | let isCursorInsideControlSection = false; 94 | 95 | const actions = { 96 | deleteRow: () => { 97 | this.editor.chain().deleteNode("tableRow").focus().run(); 98 | }, 99 | selectRow: () => { 100 | const from = pos(); 101 | 102 | const resolvedFrom = editor.state.doc.resolve(from); 103 | 104 | const nodeSel = new NodeSelection(resolvedFrom); 105 | 106 | editor.view.dispatch(editor.state.tr.setSelection(nodeSel)); 107 | }, 108 | }; 109 | 110 | const setCursorInsideControlSection = () => { 111 | isCursorInsideControlSection = true; 112 | }; 113 | 114 | const setCursorOutsideControlSection = () => { 115 | isCursorInsideControlSection = false; 116 | }; 117 | 118 | const controlSection = getElementWithAttributes( 119 | "section", 120 | { 121 | class: 122 | "absolute hidden flex items-center w-2 bg-gray-200 z-50 cursor-pointer border-1 border-indigo-600 rounded-l opacity-25 hover:opacity-100", 123 | contenteditable: "false", 124 | }, 125 | { 126 | click: (e: any) => { 127 | if (e) stopPrevent(e); 128 | 129 | actions.selectRow(); 130 | }, 131 | mouseenter: () => { 132 | setCursorInsideControlSection(); 133 | }, 134 | mouseover: () => { 135 | setCursorInsideControlSection(); 136 | }, 137 | mouseleave: () => { 138 | setCursorOutsideControlSection(); 139 | hideControls(); 140 | }, 141 | } 142 | ); 143 | 144 | const deleteButton = getElementWithAttributes( 145 | "button", 146 | { 147 | class: 148 | "text-sm px-1 absolute -translate-x-[125%] hover:active:-translate-x-[125%] mr-2", 149 | }, 150 | { 151 | click: (e: any) => { 152 | if (e) stopPrevent(e); 153 | 154 | actions.deleteRow(); 155 | }, 156 | } 157 | ); 158 | 159 | const showControls = () => { 160 | repositionControlsCenter(); 161 | controlSection.classList.remove("hidden"); 162 | }; 163 | 164 | const hideControls = () => { 165 | setTimeout(() => { 166 | if (isCursorInsideControlSection) return; 167 | controlSection.classList.add("hidden"); 168 | }, 100); 169 | }; 170 | 171 | // const tableRow = getElementWithAttributes("tr", { class: "content" }); 172 | const tableRow = getElementWithAttributes( 173 | "tr", 174 | { ...HTMLAttributes }, 175 | { 176 | mouseenter: showControls, 177 | mouseover: showControls, 178 | mouseleave: hideControls, 179 | } 180 | ); 181 | 182 | deleteButton.textContent = "x"; 183 | 184 | controlSection.append(deleteButton); 185 | 186 | document.body.append(controlSection); 187 | 188 | let rectBefore = ""; 189 | 190 | const repositionControlsCenter = () => { 191 | setTimeout(() => { 192 | const rowCoords = tableRow.getBoundingClientRect(); 193 | const stringifiedRowCoords = JSON.stringify(rowCoords); 194 | 195 | if (rectBefore === stringifiedRowCoords) return; 196 | 197 | controlSection.style.top = `${ 198 | rowCoords.top + document.documentElement.scrollTop 199 | }px`; 200 | controlSection.style.left = `${ 201 | rowCoords.x + document.documentElement.scrollLeft - 8 202 | }px`; 203 | controlSection.style.height = `${rowCoords.height + 1}px`; 204 | 205 | rectBefore = stringifiedRowCoords; 206 | }); 207 | }; 208 | 209 | setTimeout(() => { 210 | repositionControlsCenter(); 211 | }, 100); 212 | 213 | editor.on("selectionUpdate", repositionControlsCenter); 214 | editor.on("update", repositionControlsCenter); 215 | scrollableParent?.addEventListener("scroll", repositionControlsCenter); 216 | document.addEventListener("scroll", repositionControlsCenter); 217 | 218 | const destroy = () => { 219 | controlSection.remove(); 220 | editor.off("selectionUpdate", repositionControlsCenter); 221 | editor.off("update", repositionControlsCenter); 222 | scrollableParent?.removeEventListener( 223 | "scroll", 224 | repositionControlsCenter 225 | ); 226 | document.removeEventListener("scroll", repositionControlsCenter); 227 | }; 228 | 229 | return { 230 | dom: tableRow, 231 | contentDOM: tableRow, 232 | destroy, 233 | }; 234 | }; 235 | }, 236 | }); 237 | -------------------------------------------------------------------------------- /src/tiptap/extensions/resizableMedia/ResizableMediaNodeView.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react-hooks/exhaustive-deps */ 2 | /* eslint-disable jsx-a11y/no-static-element-interactions */ 3 | /* eslint-disable jsx-a11y/media-has-caption */ 4 | 5 | import { useEffect, useRef, useState } from "react"; 6 | import { NodeViewWrapper, NodeViewProps } from "@tiptap/react"; 7 | 8 | import { resizableMediaActions } from "./resizableMediaMenuUtil"; 9 | 10 | import "./styles.scss"; 11 | 12 | // ! had to manage this state outside of the component because `useState` isn't fast enough and creates problem cause 13 | // ! the function is getting old data even though new data is set by `useState` before the execution of function 14 | let lastClientX: number; 15 | 16 | export const ResizableMediaNodeView = ({ 17 | node, 18 | updateAttributes, 19 | deleteNode, 20 | }: NodeViewProps) => { 21 | const [mediaType, setMediaType] = useState<"img" | "video">(); 22 | 23 | useEffect(() => { 24 | setMediaType(node.attrs["media-type"]); 25 | }, [node.attrs]); 26 | 27 | const [aspectRatio, setAspectRatio] = useState(0); 28 | 29 | const [proseMirrorContainerWidth, setProseMirrorContainerWidth] = useState(0); 30 | 31 | const [mediaActionActiveState, setMediaActionActiveState] = useState< 32 | Record 33 | >({}); 34 | 35 | const resizableImgRef = useRef( 36 | null 37 | ); 38 | 39 | const calculateMediaActionActiveStates = () => { 40 | const activeStates: Record = {}; 41 | 42 | resizableMediaActions.forEach(({ tooltip, isActive }) => { 43 | activeStates[tooltip] = !!isActive?.(node.attrs); 44 | }); 45 | 46 | setMediaActionActiveState(activeStates); 47 | }; 48 | 49 | useEffect(() => { 50 | calculateMediaActionActiveStates(); 51 | }, [node.attrs]); 52 | 53 | const mediaSetupOnLoad = () => { 54 | // ! TODO: move this to extension storage 55 | const proseMirrorContainerDiv = document.querySelector(".ProseMirror"); 56 | 57 | if (proseMirrorContainerDiv) 58 | setProseMirrorContainerWidth(proseMirrorContainerDiv?.clientWidth); 59 | 60 | // When the media has loaded 61 | if (!resizableImgRef.current) return; 62 | 63 | if (mediaType === "video") { 64 | const video = resizableImgRef.current as HTMLVideoElement; 65 | 66 | video.addEventListener("loadeddata", function () { 67 | // Aspect Ratio from its original size 68 | setAspectRatio(video.videoWidth / video.videoHeight); 69 | 70 | // for the first time when video is added with custom width and height 71 | // and we have to adjust the video height according to it's width 72 | onHorizontalResize("left", 0); 73 | }); 74 | } else { 75 | resizableImgRef.current.onload = () => { 76 | // Aspect Ratio from its original size 77 | setAspectRatio( 78 | (resizableImgRef.current as HTMLImageElement).naturalWidth / 79 | (resizableImgRef.current as HTMLImageElement).naturalHeight 80 | ); 81 | }; 82 | } 83 | 84 | setTimeout(() => calculateMediaActionActiveStates(), 200); 85 | }; 86 | 87 | const setLastClientX = (x: number) => { 88 | lastClientX = x; 89 | }; 90 | 91 | useEffect(() => { 92 | mediaSetupOnLoad(); 93 | }); 94 | 95 | const [isHorizontalResizeActive, setIsHorizontalResizeActive] = 96 | useState(false); 97 | 98 | interface WidthAndHeight { 99 | width: number; 100 | height: number; 101 | } 102 | 103 | const limitWidthOrHeightToFiftyPixels = ({ width, height }: WidthAndHeight) => 104 | width < 100 || height < 100; 105 | 106 | const documentHorizontalMouseMove = (e: MouseEvent) => { 107 | setTimeout(() => onHorizontalMouseMove(e)); 108 | }; 109 | 110 | const startHorizontalResize = (e: { clientX: number }) => { 111 | setIsHorizontalResizeActive(true); 112 | lastClientX = e.clientX; 113 | 114 | setTimeout(() => { 115 | document.addEventListener("mousemove", documentHorizontalMouseMove); 116 | document.addEventListener("mouseup", stopHorizontalResize); 117 | }); 118 | }; 119 | 120 | const stopHorizontalResize = () => { 121 | setIsHorizontalResizeActive(false); 122 | lastClientX = -1; 123 | 124 | document.removeEventListener("mousemove", documentHorizontalMouseMove); 125 | document.removeEventListener("mouseup", stopHorizontalResize); 126 | }; 127 | 128 | const onHorizontalResize = ( 129 | directionOfMouseMove: "right" | "left", 130 | diff: number 131 | ) => { 132 | if (!resizableImgRef.current) { 133 | console.error("Media ref is undefined|null", { 134 | resizableImg: resizableImgRef.current, 135 | }); 136 | return; 137 | } 138 | 139 | const currentMediaDimensions = { 140 | width: resizableImgRef.current?.width, 141 | height: resizableImgRef.current?.height, 142 | }; 143 | 144 | const newMediaDimensions = { 145 | width: -1, 146 | height: -1, 147 | }; 148 | 149 | if (directionOfMouseMove === "left") { 150 | newMediaDimensions.width = currentMediaDimensions.width - Math.abs(diff); 151 | } else { 152 | newMediaDimensions.width = currentMediaDimensions.width + Math.abs(diff); 153 | } 154 | 155 | if (newMediaDimensions.width > proseMirrorContainerWidth) 156 | newMediaDimensions.width = proseMirrorContainerWidth; 157 | 158 | newMediaDimensions.height = newMediaDimensions.width / aspectRatio; 159 | 160 | if (limitWidthOrHeightToFiftyPixels(newMediaDimensions)) return; 161 | 162 | updateAttributes(newMediaDimensions); 163 | }; 164 | 165 | const onHorizontalMouseMove = (e: MouseEvent) => { 166 | if (lastClientX === -1) return; 167 | 168 | const { clientX } = e; 169 | 170 | const diff = lastClientX - clientX; 171 | 172 | if (diff === 0) return; 173 | 174 | const directionOfMouseMove: "left" | "right" = diff > 0 ? "left" : "right"; 175 | 176 | setTimeout(() => { 177 | onHorizontalResize(directionOfMouseMove, Math.abs(diff)); 178 | lastClientX = clientX; 179 | }); 180 | }; 181 | 182 | const [isFloat, setIsFloat] = useState(); 183 | 184 | useEffect(() => { 185 | setIsFloat(node.attrs.dataFloat); 186 | }, [node.attrs]); 187 | 188 | const [isAlign, setIsAlign] = useState(); 189 | 190 | useEffect(() => { 191 | setIsAlign(node.attrs.dataAlign); 192 | }, [node.attrs]); 193 | 194 | return ( 195 | 203 |
204 | {mediaType === "img" && ( 205 | {node.attrs.src} 213 | )} 214 | 215 | {mediaType === "video" && ( 216 | 225 | )} 226 | 227 |
setLastClientX(clientX)} 231 | onMouseDown={startHorizontalResize} 232 | onMouseUp={stopHorizontalResize} 233 | /> 234 | 235 |
236 | {resizableMediaActions.map((btn) => { 237 | return ( 238 | // TODO: figure out why tooltips are not working 239 | 253 | ); 254 | })} 255 |
256 |
257 | 258 | ); 259 | }; 260 | -------------------------------------------------------------------------------- /src/tiptap/extensions/supercharged-table/extension-table/table.ts: -------------------------------------------------------------------------------- 1 | import { 2 | addColumnAfter, 3 | addColumnBefore, 4 | addRowAfter, 5 | addRowBefore, 6 | CellSelection, 7 | columnResizing, 8 | deleteColumn, 9 | deleteRow, 10 | deleteTable, 11 | fixTables, 12 | goToNextCell, 13 | mergeCells, 14 | setCellAttr, 15 | splitCell, 16 | tableEditing, 17 | toggleHeader, 18 | toggleHeaderCell, 19 | } from "@_ueberdosis/prosemirror-tables"; 20 | import { 21 | callOrReturn, 22 | getExtensionField, 23 | mergeAttributes, 24 | Node, 25 | ParentConfig, 26 | } from "@tiptap/core"; 27 | import { TextSelection } from "prosemirror-state"; 28 | import { NodeView } from "prosemirror-view"; 29 | 30 | import { TableView } from "./TableView"; 31 | import { createTable } from "./utilities/createTable"; 32 | import { deleteTableWhenAllCellsSelected } from "./utilities/deleteTableWhenAllCellsSelected"; 33 | 34 | export interface TableOptions { 35 | HTMLAttributes: Record; 36 | resizable: boolean; 37 | handleWidth: number; 38 | cellMinWidth: number; 39 | View: NodeView; 40 | lastColumnResizable: boolean; 41 | allowTableNodeSelection: boolean; 42 | } 43 | 44 | declare module "@tiptap/core" { 45 | interface Commands { 46 | table: { 47 | insertTable: (options?: { 48 | rows?: number; 49 | cols?: number; 50 | withHeaderRow?: boolean; 51 | }) => ReturnType; 52 | addColumnBefore: () => ReturnType; 53 | addColumnAfter: () => ReturnType; 54 | deleteColumn: () => ReturnType; 55 | addRowBefore: () => ReturnType; 56 | addRowAfter: () => ReturnType; 57 | deleteRow: () => ReturnType; 58 | deleteTable: () => ReturnType; 59 | mergeCells: () => ReturnType; 60 | splitCell: () => ReturnType; 61 | toggleHeaderColumn: () => ReturnType; 62 | toggleHeaderRow: () => ReturnType; 63 | toggleHeaderCell: () => ReturnType; 64 | mergeOrSplit: () => ReturnType; 65 | setCellAttribute: (name: string, value: any) => ReturnType; 66 | goToNextCell: () => ReturnType; 67 | goToPreviousCell: () => ReturnType; 68 | fixTables: () => ReturnType; 69 | setCellSelection: (position: { 70 | anchorCell: number; 71 | headCell?: number; 72 | }) => ReturnType; 73 | }; 74 | } 75 | 76 | interface NodeConfig { 77 | /** 78 | * Table Role 79 | */ 80 | tableRole?: 81 | | string 82 | | ((this: { 83 | name: string; 84 | options: Options; 85 | storage: Storage; 86 | parent: ParentConfig>["tableRole"]; 87 | }) => string); 88 | } 89 | } 90 | 91 | export const Table = Node.create({ 92 | name: "table", 93 | 94 | // @ts-ignore 95 | addOptions() { 96 | return { 97 | HTMLAttributes: {}, 98 | resizable: false, 99 | handleWidth: 5, 100 | cellMinWidth: 25, 101 | // TODO: fix 102 | View: TableView, 103 | lastColumnResizable: true, 104 | allowTableNodeSelection: false, 105 | }; 106 | }, 107 | 108 | content: "tableRow+", 109 | 110 | tableRole: "table", 111 | 112 | isolating: true, 113 | 114 | group: "block", 115 | 116 | parseHTML() { 117 | return [{ tag: "table" }]; 118 | }, 119 | 120 | renderHTML({ HTMLAttributes }) { 121 | return [ 122 | "table", 123 | mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 124 | ["tbody", 0], 125 | ]; 126 | }, 127 | 128 | addCommands() { 129 | return { 130 | insertTable: 131 | ({ rows = 3, cols = 3, withHeaderRow = true } = {}) => 132 | ({ tr, dispatch, editor }) => { 133 | const node = createTable(editor.schema, rows, cols, withHeaderRow); 134 | 135 | if (dispatch) { 136 | const offset = tr.selection.anchor + 1; 137 | 138 | tr.replaceSelectionWith(node) 139 | .scrollIntoView() 140 | .setSelection(TextSelection.near(tr.doc.resolve(offset))); 141 | } 142 | 143 | return true; 144 | }, 145 | addColumnBefore: 146 | () => 147 | ({ state, dispatch }) => { 148 | return addColumnBefore(state, dispatch); 149 | }, 150 | addColumnAfter: 151 | () => 152 | ({ state, dispatch }) => { 153 | return addColumnAfter(state, dispatch); 154 | }, 155 | deleteColumn: 156 | () => 157 | ({ state, dispatch }) => { 158 | return deleteColumn(state, dispatch); 159 | }, 160 | addRowBefore: 161 | () => 162 | ({ state, dispatch }) => { 163 | return addRowBefore(state, dispatch); 164 | }, 165 | addRowAfter: 166 | () => 167 | ({ state, dispatch }) => { 168 | return addRowAfter(state, dispatch); 169 | }, 170 | deleteRow: 171 | () => 172 | ({ state, dispatch }) => { 173 | return deleteRow(state, dispatch); 174 | }, 175 | deleteTable: 176 | () => 177 | ({ state, dispatch }) => { 178 | return deleteTable(state, dispatch); 179 | }, 180 | mergeCells: 181 | () => 182 | ({ state, dispatch }) => { 183 | return mergeCells(state, dispatch); 184 | }, 185 | splitCell: 186 | () => 187 | ({ state, dispatch }) => { 188 | return splitCell(state, dispatch); 189 | }, 190 | toggleHeaderColumn: 191 | () => 192 | ({ state, dispatch }) => { 193 | return toggleHeader("column")(state, dispatch); 194 | }, 195 | toggleHeaderRow: 196 | () => 197 | ({ state, dispatch }) => { 198 | return toggleHeader("row")(state, dispatch); 199 | }, 200 | toggleHeaderCell: 201 | () => 202 | ({ state, dispatch }) => { 203 | return toggleHeaderCell(state, dispatch); 204 | }, 205 | mergeOrSplit: 206 | () => 207 | ({ state, dispatch }) => { 208 | if (mergeCells(state, dispatch)) { 209 | return true; 210 | } 211 | 212 | return splitCell(state, dispatch); 213 | }, 214 | setCellAttribute: 215 | (name, value) => 216 | ({ state, dispatch }) => { 217 | return setCellAttr(name, value)(state, dispatch); 218 | }, 219 | goToNextCell: 220 | () => 221 | ({ state, dispatch }) => { 222 | return goToNextCell(1)(state, dispatch); 223 | }, 224 | goToPreviousCell: 225 | () => 226 | ({ state, dispatch }) => { 227 | return goToNextCell(-1)(state, dispatch); 228 | }, 229 | fixTables: 230 | () => 231 | ({ state, dispatch }) => { 232 | if (dispatch) { 233 | fixTables(state); 234 | } 235 | 236 | return true; 237 | }, 238 | setCellSelection: 239 | (position) => 240 | ({ tr, dispatch }) => { 241 | if (dispatch) { 242 | const selection = CellSelection.create( 243 | tr.doc, 244 | position.anchorCell, 245 | position.headCell 246 | ); 247 | 248 | tr.setSelection(selection as any); 249 | } 250 | 251 | return true; 252 | }, 253 | }; 254 | }, 255 | 256 | addKeyboardShortcuts() { 257 | return { 258 | Tab: () => { 259 | if (this.editor.commands.goToNextCell()) { 260 | return true; 261 | } 262 | 263 | if (!this.editor.can().addRowAfter()) { 264 | return false; 265 | } 266 | 267 | return this.editor.chain().addRowAfter().goToNextCell().run(); 268 | }, 269 | "Shift-Tab": () => this.editor.commands.goToPreviousCell(), 270 | Backspace: deleteTableWhenAllCellsSelected, 271 | "Mod-Backspace": deleteTableWhenAllCellsSelected, 272 | Delete: deleteTableWhenAllCellsSelected, 273 | "Mod-Delete": deleteTableWhenAllCellsSelected, 274 | }; 275 | }, 276 | 277 | addProseMirrorPlugins() { 278 | const isResizable = this.options.resizable && this.editor.isEditable; 279 | 280 | return [ 281 | ...(isResizable 282 | ? [ 283 | columnResizing({ 284 | handleWidth: this.options.handleWidth, 285 | cellMinWidth: this.options.cellMinWidth, 286 | View: this.options.View, 287 | // TODO: PR for @types/prosemirror-tables 288 | // @ts-ignore (incorrect type) 289 | lastColumnResizable: this.options.lastColumnResizable, 290 | }), 291 | ] 292 | : []), 293 | tableEditing({ 294 | allowTableNodeSelection: this.options.allowTableNodeSelection, 295 | }), 296 | ]; 297 | }, 298 | 299 | extendNodeSchema(extension) { 300 | const context = { 301 | name: extension.name, 302 | options: extension.options, 303 | storage: extension.storage, 304 | }; 305 | 306 | return { 307 | tableRole: callOrReturn( 308 | getExtensionField(extension, "tableRole", context) 309 | ), 310 | }; 311 | }, 312 | }); 313 | -------------------------------------------------------------------------------- /src/tiptap/mocks/defaultContent.ts: -------------------------------------------------------------------------------- 1 | import { Content } from "@tiptap/react"; 2 | 3 | export const content: Content = { 4 | type: "doc", 5 | content: [ 6 | { 7 | type: "dBlock", 8 | content: [ 9 | { 10 | type: "heading", 11 | attrs: { 12 | level: 1, 13 | }, 14 | content: [ 15 | { 16 | type: "text", 17 | text: "Welcome to notitap.", 18 | }, 19 | ], 20 | }, 21 | ], 22 | }, 23 | { 24 | type: "dBlock", 25 | content: [ 26 | { 27 | type: "heading", 28 | attrs: { 29 | level: 3, 30 | }, 31 | content: [ 32 | { 33 | type: "text", 34 | text: "A notion like editor built with Tiptap", 35 | }, 36 | ], 37 | }, 38 | ], 39 | }, 40 | { 41 | type: "dBlock", 42 | content: [ 43 | { 44 | type: "heading", 45 | attrs: { 46 | level: 2, 47 | }, 48 | content: [ 49 | { 50 | type: "text", 51 | text: "Features:", 52 | }, 53 | ], 54 | }, 55 | ], 56 | }, 57 | { 58 | type: "dBlock", 59 | content: [ 60 | { 61 | type: "bulletList", 62 | content: [ 63 | { 64 | type: "listItem", 65 | content: [ 66 | { 67 | type: "paragraph", 68 | content: [ 69 | { 70 | type: "text", 71 | text: "Block Level Drag and Drop", 72 | }, 73 | ], 74 | }, 75 | ], 76 | }, 77 | { 78 | type: "listItem", 79 | content: [ 80 | { 81 | type: "paragraph", 82 | content: [ 83 | { 84 | type: "text", 85 | text: "Resizable Media(Images, Videos)", 86 | }, 87 | ], 88 | }, 89 | ], 90 | }, 91 | { 92 | type: "listItem", 93 | content: [ 94 | { 95 | type: "paragraph", 96 | content: [ 97 | { 98 | type: "text", 99 | text: "Supercharged Tables", 100 | }, 101 | ], 102 | }, 103 | ], 104 | }, 105 | { 106 | type: "listItem", 107 | content: [ 108 | { 109 | type: "paragraph", 110 | content: [ 111 | { 112 | type: "text", 113 | text: "Link Previews", 114 | }, 115 | ], 116 | }, 117 | ], 118 | }, 119 | ], 120 | }, 121 | ], 122 | }, 123 | { 124 | type: "dBlock", 125 | content: [ 126 | { 127 | type: "heading", 128 | attrs: { 129 | level: 2, 130 | }, 131 | content: [ 132 | { 133 | type: "text", 134 | text: "Resizable Media", 135 | }, 136 | ], 137 | }, 138 | ], 139 | }, 140 | { 141 | type: "dBlock", 142 | content: [ 143 | { 144 | type: "heading", 145 | attrs: { 146 | level: 3, 147 | }, 148 | content: [ 149 | { 150 | type: "text", 151 | text: "Images:", 152 | }, 153 | ], 154 | }, 155 | ], 156 | }, 157 | { 158 | type: "dBlock", 159 | content: [ 160 | { 161 | type: "resizableMedia", 162 | attrs: { 163 | src: "https://source.unsplash.com/8xznAGy4HcY/800x400", 164 | "media-type": "img", 165 | alt: "Something else", 166 | title: "Something", 167 | width: 574, 168 | height: 287, 169 | dataAlign: "center", 170 | dataFloat: null, 171 | }, 172 | }, 173 | ], 174 | }, 175 | { 176 | type: "dBlock", 177 | content: [ 178 | { 179 | type: "heading", 180 | attrs: { 181 | level: 3, 182 | }, 183 | content: [ 184 | { 185 | type: "text", 186 | text: "Videos:", 187 | }, 188 | ], 189 | }, 190 | ], 191 | }, 192 | { 193 | type: "dBlock", 194 | content: [ 195 | { 196 | type: "resizableMedia", 197 | attrs: { 198 | src: "https://user-images.githubusercontent.com/45892659/178123048-0257e732-8cc2-466b-8447-1e2b7cd1b5d9.mov", 199 | "media-type": "video", 200 | alt: "Some Video", 201 | title: "Some Title Video", 202 | width: 400, 203 | height: null, 204 | dataAlign: "center", 205 | dataFloat: null, 206 | }, 207 | }, 208 | ], 209 | }, 210 | { 211 | type: "dBlock", 212 | content: [ 213 | { 214 | type: "heading", 215 | attrs: { 216 | level: 2, 217 | }, 218 | }, 219 | ], 220 | }, 221 | { 222 | type: "dBlock", 223 | content: [ 224 | { 225 | type: "heading", 226 | attrs: { 227 | level: 2, 228 | }, 229 | content: [ 230 | { 231 | type: "text", 232 | text: "SuperCharged Tables:", 233 | }, 234 | ], 235 | }, 236 | ], 237 | }, 238 | { 239 | type: "dBlock", 240 | content: [ 241 | { 242 | type: "table", 243 | content: [ 244 | { 245 | type: "tableRow", 246 | content: [ 247 | { 248 | type: "tableHeader", 249 | attrs: { 250 | colspan: 1, 251 | rowspan: 1, 252 | colwidth: null, 253 | }, 254 | content: [ 255 | { 256 | type: "paragraph", 257 | content: [ 258 | { 259 | type: "text", 260 | text: "Number", 261 | }, 262 | ], 263 | }, 264 | ], 265 | }, 266 | { 267 | type: "tableHeader", 268 | attrs: { 269 | colspan: 1, 270 | rowspan: 1, 271 | colwidth: null, 272 | }, 273 | content: [ 274 | { 275 | type: "paragraph", 276 | content: [ 277 | { 278 | type: "text", 279 | text: "Name", 280 | }, 281 | ], 282 | }, 283 | ], 284 | }, 285 | { 286 | type: "tableHeader", 287 | attrs: { 288 | colspan: 1, 289 | rowspan: 1, 290 | colwidth: null, 291 | }, 292 | content: [ 293 | { 294 | type: "paragraph", 295 | content: [ 296 | { 297 | type: "text", 298 | text: "Importance", 299 | }, 300 | ], 301 | }, 302 | ], 303 | }, 304 | { 305 | type: "tableHeader", 306 | attrs: { 307 | colspan: 1, 308 | rowspan: 1, 309 | colwidth: null, 310 | }, 311 | content: [ 312 | { 313 | type: "paragraph", 314 | content: [ 315 | { 316 | type: "text", 317 | text: "Reason", 318 | }, 319 | ], 320 | }, 321 | ], 322 | }, 323 | ], 324 | }, 325 | { 326 | type: "tableRow", 327 | content: [ 328 | { 329 | type: "tableCell", 330 | attrs: { 331 | colspan: 1, 332 | rowspan: 1, 333 | colwidth: null, 334 | }, 335 | content: [ 336 | { 337 | type: "paragraph", 338 | content: [ 339 | { 340 | type: "text", 341 | text: "1", 342 | }, 343 | ], 344 | }, 345 | ], 346 | }, 347 | { 348 | type: "tableCell", 349 | attrs: { 350 | colspan: 1, 351 | rowspan: 1, 352 | colwidth: null, 353 | }, 354 | content: [ 355 | { 356 | type: "paragraph", 357 | content: [ 358 | { 359 | type: "text", 360 | text: "Laptop", 361 | }, 362 | ], 363 | }, 364 | ], 365 | }, 366 | { 367 | type: "tableCell", 368 | attrs: { 369 | colspan: 1, 370 | rowspan: 1, 371 | colwidth: null, 372 | }, 373 | content: [ 374 | { 375 | type: "paragraph", 376 | content: [ 377 | { 378 | type: "text", 379 | text: "High Importance ", 380 | }, 381 | ], 382 | }, 383 | ], 384 | }, 385 | { 386 | type: "tableCell", 387 | attrs: { 388 | colspan: 1, 389 | rowspan: 1, 390 | colwidth: null, 391 | }, 392 | content: [ 393 | { 394 | type: "paragraph", 395 | content: [ 396 | { 397 | type: "text", 398 | text: "Can do almost everything that a watch and a phone can do ", 399 | }, 400 | ], 401 | }, 402 | ], 403 | }, 404 | ], 405 | }, 406 | { 407 | type: "tableRow", 408 | content: [ 409 | { 410 | type: "tableCell", 411 | attrs: { 412 | colspan: 1, 413 | rowspan: 1, 414 | colwidth: null, 415 | }, 416 | content: [ 417 | { 418 | type: "paragraph", 419 | content: [ 420 | { 421 | type: "text", 422 | text: "2", 423 | }, 424 | ], 425 | }, 426 | ], 427 | }, 428 | { 429 | type: "tableCell", 430 | attrs: { 431 | colspan: 1, 432 | rowspan: 1, 433 | colwidth: null, 434 | }, 435 | content: [ 436 | { 437 | type: "paragraph", 438 | content: [ 439 | { 440 | type: "text", 441 | text: "Mobile", 442 | }, 443 | ], 444 | }, 445 | ], 446 | }, 447 | { 448 | type: "tableCell", 449 | attrs: { 450 | colspan: 1, 451 | rowspan: 1, 452 | colwidth: null, 453 | }, 454 | content: [ 455 | { 456 | type: "paragraph", 457 | content: [ 458 | { 459 | type: "text", 460 | text: "Medium importance", 461 | }, 462 | ], 463 | }, 464 | ], 465 | }, 466 | { 467 | type: "tableCell", 468 | attrs: { 469 | colspan: 1, 470 | rowspan: 1, 471 | colwidth: null, 472 | }, 473 | content: [ 474 | { 475 | type: "paragraph", 476 | content: [ 477 | { 478 | type: "text", 479 | text: "Can do everything a watch can do, but not everything a laptop can do ", 480 | }, 481 | ], 482 | }, 483 | ], 484 | }, 485 | ], 486 | }, 487 | { 488 | type: "tableRow", 489 | content: [ 490 | { 491 | type: "tableCell", 492 | attrs: { 493 | colspan: 1, 494 | rowspan: 1, 495 | colwidth: null, 496 | }, 497 | content: [ 498 | { 499 | type: "paragraph", 500 | content: [ 501 | { 502 | type: "text", 503 | text: "3", 504 | }, 505 | ], 506 | }, 507 | ], 508 | }, 509 | { 510 | type: "tableCell", 511 | attrs: { 512 | colspan: 1, 513 | rowspan: 1, 514 | colwidth: null, 515 | }, 516 | content: [ 517 | { 518 | type: "paragraph", 519 | content: [ 520 | { 521 | type: "text", 522 | text: "Watch", 523 | }, 524 | ], 525 | }, 526 | ], 527 | }, 528 | { 529 | type: "tableCell", 530 | attrs: { 531 | colspan: 1, 532 | rowspan: 1, 533 | colwidth: null, 534 | }, 535 | content: [ 536 | { 537 | type: "paragraph", 538 | content: [ 539 | { 540 | type: "text", 541 | text: "Low Importance", 542 | }, 543 | ], 544 | }, 545 | ], 546 | }, 547 | { 548 | type: "tableCell", 549 | attrs: { 550 | colspan: 1, 551 | rowspan: 1, 552 | colwidth: null, 553 | }, 554 | content: [ 555 | { 556 | type: "paragraph", 557 | content: [ 558 | { 559 | type: "text", 560 | text: "Can't do everything a phone or a laptop can do.", 561 | }, 562 | ], 563 | }, 564 | ], 565 | }, 566 | ], 567 | }, 568 | ], 569 | }, 570 | ], 571 | }, 572 | { 573 | type: "dBlock", 574 | content: [ 575 | { 576 | type: "paragraph", 577 | }, 578 | ], 579 | }, 580 | { 581 | type: "dBlock", 582 | content: [ 583 | { 584 | type: "paragraph", 585 | }, 586 | ], 587 | }, 588 | { 589 | type: "dBlock", 590 | content: [ 591 | { 592 | type: "paragraph", 593 | }, 594 | ], 595 | }, 596 | { 597 | type: "dBlock", 598 | content: [ 599 | { 600 | type: "paragraph", 601 | }, 602 | ], 603 | }, 604 | { 605 | type: "dBlock", 606 | content: [ 607 | { 608 | type: "paragraph", 609 | }, 610 | ], 611 | }, 612 | { 613 | type: "dBlock", 614 | content: [ 615 | { 616 | type: "paragraph", 617 | }, 618 | ], 619 | }, 620 | { 621 | type: "dBlock", 622 | content: [ 623 | { 624 | type: "paragraph", 625 | }, 626 | ], 627 | }, 628 | { 629 | type: "dBlock", 630 | content: [ 631 | { 632 | type: "paragraph", 633 | }, 634 | ], 635 | }, 636 | { 637 | type: "dBlock", 638 | content: [ 639 | { 640 | type: "paragraph", 641 | }, 642 | ], 643 | }, 644 | { 645 | type: "dBlock", 646 | content: [ 647 | { 648 | type: "paragraph", 649 | }, 650 | ], 651 | }, 652 | { 653 | type: "dBlock", 654 | content: [ 655 | { 656 | type: "paragraph", 657 | }, 658 | ], 659 | }, 660 | ], 661 | }; 662 | -------------------------------------------------------------------------------- /sponsorkit/sponsors.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | Sold((silver + gold)/2) Sponsors 20 | Memberspot GmbH 21 | 22 | Silver Sponsors 23 | 10lift 24 | 25 | 26 | 27 | Soul 28 | 29 | Sponsors 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | --------------------------------------------------------------------------------