├── .gitignore ├── .npmrc ├── .playground ├── app.config.ts ├── eslint.config.mjs └── nuxt.config.ts ├── README.md ├── app ├── app.config.ts ├── app.vue ├── assets │ └── css │ │ ├── ProseMirror.scss │ │ ├── editor.scss │ │ ├── index.scss │ │ └── main.css ├── components │ ├── ActionButton.vue │ ├── ActionMenuButton.vue │ ├── ColorPicker.vue │ ├── LeazyEditor.vue │ └── Toolbar.vue ├── composables │ ├── useContext.ts │ ├── useSearchMedia.ts │ └── useStore.ts ├── constants │ └── index.ts ├── extensions │ ├── Ai │ │ ├── Ai.ts │ │ ├── components │ │ │ └── AiButton.vue │ │ └── index.ts │ ├── Alert │ │ ├── Alert.ts │ │ ├── components │ │ │ ├── ActionAlertButton.vue │ │ │ └── Alert.vue │ │ ├── index.ts │ │ └── menus │ │ │ └── AlertMenus.vue │ ├── Blockquote │ │ ├── Blockquote.ts │ │ └── index.ts │ ├── Bold │ │ ├── Bold.ts │ │ └── index.ts │ ├── BulletList │ │ ├── BulletList.ts │ │ └── index.ts │ ├── Clear │ │ ├── Clear.ts │ │ └── index.ts │ ├── Code │ │ ├── Code.ts │ │ └── index.ts │ ├── CodeBlock │ │ ├── CodeBlock.ts │ │ ├── components │ │ │ └── CodeBlockView.vue │ │ └── index.ts │ ├── Collaboration │ │ ├── Collaboration.ts │ │ └── index.ts │ ├── CollaborationCursor │ │ ├── CollaborationCursor.ts │ │ └── index.ts │ ├── Color │ │ ├── Color.ts │ │ ├── components │ │ │ └── ColorActionButton.vue │ │ └── index.ts │ ├── Comment │ │ ├── Comment.ts │ │ └── index.ts │ ├── Details │ │ ├── Details.ts │ │ └── index.ts │ ├── Document │ │ ├── Document.ts │ │ └── index.ts │ ├── Emoji │ │ ├── Emoji.ts │ │ ├── components │ │ │ └── EmojiList.vue │ │ └── index.ts │ ├── Export │ │ ├── Export.ts │ │ ├── components │ │ │ └── ActionButton.vue │ │ └── index.ts │ ├── FontSize │ │ ├── FontSize.ts │ │ ├── components │ │ │ └── FontSizeMenuButton.vue │ │ └── index.ts │ ├── FormatPainter │ │ ├── FormatPainter.ts │ │ └── index.ts │ ├── Fullscreen │ │ ├── Fullscreen.ts │ │ ├── components │ │ │ └── FullscreenActionButton.vue │ │ └── index.ts │ ├── Heading │ │ ├── Heading.ts │ │ ├── components │ │ │ └── HeadingButton.vue │ │ └── index.ts │ ├── Highlight │ │ ├── Highlight.ts │ │ ├── components │ │ │ └── HighlightActionButton.vue │ │ └── index.ts │ ├── History │ │ ├── History.ts │ │ └── index.ts │ ├── HorizontalRule │ │ ├── HorizontalRule.ts │ │ └── index.ts │ ├── Iframe │ │ ├── Iframe.ts │ │ ├── components │ │ │ └── IframeNodeView.vue │ │ ├── embed.ts │ │ └── index.ts │ ├── Image │ │ ├── Image.ts │ │ ├── components │ │ │ ├── ImageActionButton.vue │ │ │ └── ImageView.vue │ │ ├── index.ts │ │ └── types.ts │ ├── ImageUpload │ │ ├── ImageUpload.ts │ │ ├── components │ │ │ └── ImageUploader.vue │ │ └── index.ts │ ├── Import │ │ ├── Import.ts │ │ ├── components │ │ │ └── ActionButton.vue │ │ └── index.ts │ ├── ImportWord │ │ ├── ImportWord.ts │ │ ├── components │ │ │ └── ImportWordButton.vue │ │ └── index.ts │ ├── Indent │ │ ├── Indent.ts │ │ └── index.ts │ ├── Italic │ │ ├── Italic.ts │ │ └── index.ts │ ├── LineHeight │ │ ├── LineHeight.ts │ │ ├── components │ │ │ └── LineHeightDropdown.vue │ │ └── index.ts │ ├── Link │ │ ├── Link.ts │ │ ├── components │ │ │ ├── LinkEditBlock.vue │ │ │ ├── LinkEditPopover.vue │ │ │ └── LinkViewBlock.vue │ │ └── index.ts │ ├── ListItem │ │ ├── ListItem.ts │ │ └── index.ts │ ├── Math │ │ ├── Math.ts │ │ ├── index.ts │ │ ├── inline-math-node.ts │ │ ├── latex-evaluation │ │ │ ├── evaluate-expression.ts │ │ │ └── update-evaluation.ts │ │ └── util │ │ │ ├── generate-id.ts │ │ │ └── options.ts │ ├── MoreMark │ │ ├── MoreMark.ts │ │ ├── components │ │ │ └── ActionMoreButton.vue │ │ └── index.ts │ ├── MultiColumn │ │ ├── Column.ts │ │ ├── Columns.ts │ │ ├── index.ts │ │ └── menus │ │ │ └── ColumnsMenu.vue │ ├── OrderedList │ │ ├── OrderedList.ts │ │ └── index.ts │ ├── Paper │ │ ├── Paper.ts │ │ ├── components │ │ │ └── Paper.vue │ │ └── index.ts │ ├── Print │ │ ├── Print.ts │ │ └── index.ts │ ├── SearchAndReplace │ │ ├── SearchAndReplace.ts │ │ ├── components │ │ │ ├── SearchAndReplaceBlock.vue │ │ │ └── SearchAndReplacePopover.vue │ │ └── index.ts │ ├── Selection │ │ ├── Selection.ts │ │ └── index.ts │ ├── SlashCommand │ │ ├── CommandsList.vue │ │ ├── SlashCommand.ts │ │ ├── groups.ts │ │ ├── index.ts │ │ └── types.ts │ ├── SpeechRecognition │ │ ├── SpeechRecognition.ts │ │ └── index.ts │ ├── SpeechSynthesis │ │ ├── SpeechSynthesis.ts │ │ └── index.ts │ ├── Strike │ │ ├── Strike.ts │ │ └── index.ts │ ├── Subscript │ │ ├── Subscript.ts │ │ └── index.ts │ ├── Table │ │ ├── cell-background.ts │ │ ├── cell.ts │ │ ├── components │ │ │ ├── CreateTablePopover.vue │ │ │ └── TableActionButton.vue │ │ ├── header.ts │ │ ├── index.ts │ │ ├── menus │ │ │ ├── TableBubbleMenu.vue │ │ │ ├── TableCell │ │ │ │ └── index.vue │ │ │ ├── TableColumn │ │ │ │ └── index.vue │ │ │ └── TableRow │ │ │ │ └── index.vue │ │ ├── row.ts │ │ ├── table.ts │ │ └── utils.ts │ ├── TableOfContents │ │ ├── TableOfContents.ts │ │ ├── components │ │ │ └── TableOfContents.vue │ │ └── index.ts │ ├── TaskList │ │ ├── TaskList.ts │ │ └── index.ts │ ├── TextAlign │ │ ├── TextAlign.ts │ │ ├── components │ │ │ └── TextAlignMenuButton.vue │ │ └── index.ts │ ├── TextBubble │ │ ├── TextBubble.ts │ │ ├── components │ │ │ └── TextDropdown.vue │ │ └── index.ts │ ├── TrailingNode │ │ ├── TrailingNode.ts │ │ └── index.ts │ ├── UnderLine │ │ ├── Underline.ts │ │ └── index.ts │ ├── UniqueId │ │ ├── UniqueId.ts │ │ └── index.ts │ ├── Video │ │ ├── Video.ts │ │ └── index.ts │ ├── VideoUpload │ │ ├── VideoUpload.ts │ │ ├── components │ │ │ └── VideoUploader.vue │ │ └── index.ts │ └── index.ts ├── features │ └── bubble │ │ ├── AIMenu.vue │ │ ├── AiCompletion.vue │ │ ├── BubbleMenu.vue │ │ ├── ContentMenu.vue │ │ ├── LinkBubbleMenu.vue │ │ ├── Menu.vue │ │ ├── index.ts │ │ └── types.ts ├── types │ ├── extensions.ts │ └── index.ts └── utils │ ├── drag.ts │ ├── get-render-container.ts │ ├── image.ts │ ├── indent.ts │ ├── index.ts │ ├── is-mobile.ts │ ├── line-height.ts │ ├── mitt.ts │ ├── plateform.ts │ ├── pm-utils.ts │ └── print.ts ├── i18n ├── localeDetector.ts └── locales │ ├── en.json │ └── fr.json ├── nuxt.config.ts ├── package.json ├── renovate.json ├── server ├── api │ └── yjs │ │ └── [slug].ts └── tsconfig.json └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | .nuxt 4 | nuxt.d.ts 5 | .output 6 | .data 7 | .env 8 | framework 9 | dist 10 | .DS_Store 11 | .idea 12 | 13 | # Yarn 14 | .yarn/cache 15 | .yarn/*state* 16 | 17 | # bun 18 | bun.lockb 19 | .bun 20 | 21 | # Local History 22 | .history 23 | 24 | # VSCode 25 | .vscode/ 26 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | strict-peer-dependencies=false 3 | @tiptap-pro:registry=https://registry.tiptap.dev/ 4 | //registry.tiptap.dev/:_authToken=${NUXT_TIPTAP_TOKEN} 5 | -------------------------------------------------------------------------------- /.playground/app.config.ts: -------------------------------------------------------------------------------- 1 | export default defineAppConfig({ 2 | leazyEditor: { 3 | name: 'My amazing Nuxt layer (overwritten)' 4 | } 5 | }) 6 | -------------------------------------------------------------------------------- /.playground/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import withNuxt from './.nuxt/eslint.config.mjs' 3 | 4 | export default withNuxt( 5 | // Your custom configs here 6 | ) 7 | -------------------------------------------------------------------------------- /.playground/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtConfig({ 2 | extends: ['..'], 3 | modules: ['@nuxt/eslint'], 4 | compatibilityDate: '2024-10-03' 5 | }) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | A modern WYSIWYG rich-text editor for Nuxt, based on Tiptap and Nuxt UI V3 2 | -------------------------------------------------------------------------------- /app/app.config.ts: -------------------------------------------------------------------------------- 1 | export default defineAppConfig({ 2 | leazyEditor: { 3 | name: 'Hello from Leazy Editor layer' 4 | }, 5 | ui: { 6 | colors: { 7 | primary: 'indigo', 8 | neutral: 'zinc', 9 | }, 10 | button: { 11 | slots: { 12 | base: 'cursor-pointer' 13 | } 14 | } 15 | } 16 | }) 17 | 18 | declare module '@nuxt/schema' { 19 | interface AppConfigInput { 20 | leazyEditor?: { 21 | name?: string 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/app.vue: -------------------------------------------------------------------------------- 1 | 106 | 107 | 108 | 109 | 110 | console.log(editor.editor.storage.comment.comments)" class="flex-1" max-width="800" /> 111 | 112 | 113 | 114 | -------------------------------------------------------------------------------- /app/assets/css/index.scss: -------------------------------------------------------------------------------- 1 | @use './editor'; 2 | @use './ProseMirror'; 3 | 4 | @page { 5 | margin: 0; 6 | padding: 30px; 7 | size: auto; 8 | } -------------------------------------------------------------------------------- /app/assets/css/main.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss" theme(static); 2 | @import "@nuxt/ui"; 3 | 4 | @theme static { 5 | --color-green-50: #EFFDF5; 6 | --color-green-100: #D9FBE8; 7 | --color-green-200: #B3F5D1; 8 | --color-green-300: #75EDAE; 9 | --color-green-400: #00DC82; 10 | --color-green-500: #00C16A; 11 | --color-green-600: #00A155; 12 | --color-green-700: #007F45; 13 | --color-green-800: #016538; 14 | --color-green-900: #0A5331; 15 | --color-green-950: #052E16; 16 | } -------------------------------------------------------------------------------- /app/components/ActionButton.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 38 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /app/components/ActionMenuButton.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 23 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /app/components/Toolbar.vue: -------------------------------------------------------------------------------- 1 | 58 | 59 | 60 | 61 | 64 | 65 | 66 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | -------------------------------------------------------------------------------- /app/composables/useContext.ts: -------------------------------------------------------------------------------- 1 | import { reactive } from 'vue' 2 | import type { AnyExtension } from '@tiptap/core' 3 | 4 | interface Instance { 5 | /** 6 | * List of extensions 7 | * 8 | * @default [] 9 | */ 10 | extensions: AnyExtension[] 11 | 12 | /** 13 | * Default language setting 14 | * 15 | * @default DEFAULT_LANG_VALUE 16 | */ 17 | defaultLang?: string 18 | } 19 | 20 | const state: Instance = reactive({ 21 | extensions: [], 22 | }) as unknown as Instance 23 | 24 | export function createContext(instance: Partial) { 25 | state.defaultLang = instance.defaultLang 26 | state.extensions = instance.extensions ?? [] 27 | } 28 | 29 | export function useContext() { 30 | return { 31 | state, 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/composables/useStore.ts: -------------------------------------------------------------------------------- 1 | import { computed, reactive, watchEffect } from 'vue' 2 | import type { AnyExtension } from '@tiptap/core' 3 | import { createInjectionState } from '@vueuse/core' 4 | import { useContext } from './useContext' 5 | 6 | import { DEFAULT_LANG_VALUE } from '../constants' 7 | 8 | /** 9 | * Interface representing an tiptap editor instance. 10 | */ 11 | interface Instance { 12 | /** 13 | * List of extensions 14 | * 15 | * @default [] 16 | */ 17 | extensions: AnyExtension[] 18 | 19 | /** 20 | * Default language setting 21 | * 22 | * @default DEFAULT_LANG_VALUE 23 | */ 24 | defaultLang?: string 25 | 26 | /** 27 | * Whether it is in fullscreen mode 28 | * 29 | * @default false 30 | */ 31 | isFullscreen: boolean 32 | 33 | /** Text color */ 34 | color?: string 35 | 36 | /** Highlight color */ 37 | highlight?: string 38 | 39 | /** AI Menu */ 40 | AIMenu: boolean 41 | } 42 | 43 | export const [useProvideTiptapStore, useTiptapStore] = createInjectionState(() => { 44 | const { state: _state } = useContext() 45 | 46 | const state: Instance = reactive({ 47 | extensions: _state.extensions ?? [], 48 | defaultLang: DEFAULT_LANG_VALUE, 49 | isFullscreen: false, 50 | color: undefined, 51 | highlight: undefined, 52 | AIMenu: false 53 | }) 54 | 55 | const isFullscreen = computed(() => state.isFullscreen) 56 | 57 | function toggleFullscreen() { 58 | state.isFullscreen = !state.isFullscreen 59 | } 60 | 61 | watchEffect(() => { 62 | state.extensions = _state.extensions 63 | state.defaultLang = _state.defaultLang 64 | }) 65 | 66 | return { 67 | state, 68 | isFullscreen, 69 | toggleFullscreen, 70 | } 71 | }) 72 | -------------------------------------------------------------------------------- /app/constants/index.ts: -------------------------------------------------------------------------------- 1 | /** Default lang */ 2 | export const DEFAULT_LANG_VALUE = 'fr' as const 3 | 4 | /** Throttle time for editor input (milliseconds) */ 5 | export const EDITOR_UPDATE_THROTTLE_WAIT_TIME = 200 as const 6 | 7 | /** 8 | * watch throttling time must be less than the update time 9 | * otherwise the cursor position will reach the end 10 | */ 11 | export const EDITOR_UPDATE_WATCH_THROTTLE_WAIT_TIME = EDITOR_UPDATE_THROTTLE_WAIT_TIME - 80 12 | 13 | /** Minimum size for image adjustments */ 14 | export const IMAGE_MIN_SIZE = 20 as const 15 | /** Maximum size for image adjustments */ 16 | export const IMAGE_MAX_SIZE = 100000 as const 17 | /** Throttle time during adjustments for images (milliseconds) */ 18 | export const IMAGE_THROTTLE_WAIT_TIME = 16 as const 19 | 20 | /** Default number of rows and columns for grids when creating a table */ 21 | export const TABLE_INIT_GRID_SIZE = 6 as const 22 | /** Maximum number of rows and columns for grids when creating a table */ 23 | export const TABLE_MAX_GRID_SIZE = 10 as const 24 | /** Minimum number of rows and columns for grids when creating a table */ 25 | export const TABLE_DEFAULT_SELECTED_GRID_SIZE = 2 as const 26 | 27 | export const DEFAULT_COLOR = '#262626' 28 | /** Default color list for text color and text highlight */ 29 | export const COLORS_LIST = [ 30 | '#000000', 31 | '#262626', 32 | '#595959', 33 | '#8C8C8C', 34 | '#BFBFBF', 35 | '#D9D9D9', 36 | '#E9E9E9', 37 | '#F5F5F5', 38 | '#FAFAFA', 39 | '#FFFFFF', 40 | '#F5222D', 41 | '#FA541C', 42 | '#FA8C16', 43 | '#FADB14', 44 | '#52C41A', 45 | '#13C2C2', 46 | '#1890FF', 47 | '#2F54EB', 48 | '#722ED1', 49 | '#EB2F96', 50 | '#FFE8E6', 51 | '#FFECE0', 52 | '#FFEFD1', 53 | '#FCFCCA', 54 | '#E4F7D2', 55 | '#D3F5F0', 56 | '#D4EEFC', 57 | '#DEE8FC', 58 | '#EFE1FA', 59 | '#FAE1EB', 60 | '#FFA39E', 61 | '#FFBB96', 62 | '#FFD591', 63 | '#FFFB8F', 64 | '#B7EB8F', 65 | '#87E8DE', 66 | '#91D5FF', 67 | '#ADC6FF', 68 | '#D3ADF7', 69 | '#FFADD2', 70 | '#FF4D4F', 71 | '#FF7A45', 72 | '#FFA940', 73 | '#FFEC3D', 74 | '#73D13D', 75 | '#36CFC9', 76 | '#40A9FF', 77 | '#597EF7', 78 | '#9254DE', 79 | '#F759AB', 80 | '#CF1322', 81 | '#D4380D', 82 | '#D46B08', 83 | '#D4B106', 84 | '#389E0D', 85 | '#08979C', 86 | '#096DD9', 87 | '#1D39C4', 88 | '#531DAB', 89 | '#C41D7F', 90 | '#820014', 91 | '#871400', 92 | '#873800', 93 | '#614700', 94 | '#135200', 95 | '#00474F', 96 | '#003A8C', 97 | '#061178', 98 | '#22075E', 99 | '#780650', 100 | ] as const 101 | 102 | /** Default font size list */ 103 | export const DEFAULT_FONT_SIZE_LIST = [12, 13, 14, 15, 16, 19, 22, 24, 29, 32, 40, 48] as const 104 | 105 | /** Default font size value */ 106 | export const DEFAULT_FONT_SIZE_VALUE = 'default' as const 107 | 108 | /** Options for setting image size in the bubble menu */ 109 | export enum IMAGE_SIZE { 110 | 'size-small' = 200, 111 | 'size-medium' = 500, 112 | 'size-large' = '100%', 113 | } 114 | 115 | /** Options for setting video size in the bubble menu */ 116 | export enum VIDEO_SIZE { 117 | 'size-small' = 480, 118 | 'size-medium' = 640, 119 | 'size-large' = '100%', 120 | } 121 | 122 | export const LINE_HEIGHT_100 = 1.7 123 | export const DEFAULT_LINE_HEIGHT = '100%' 124 | 125 | /** display in menus */ 126 | export const NODE_TYPE_MENU: any = { 127 | image: [ 128 | 'divider', 129 | 'image-size-small', 130 | 'image-size-medium', 131 | 'image-size-large', 132 | 'divider', 133 | 'textAlign', 134 | 'divider', 135 | 'image-aspect-ratio', 136 | 'remove', 137 | ], 138 | text: [ 139 | 'Ai', 140 | 'divider', 141 | 'text-bubble', 142 | 'divider', 143 | 'bold', 144 | 'italic', 145 | 'underline', 146 | 'strike', 147 | 'code', 148 | 'link', 149 | 'divider', 150 | 'color', 151 | 'highlight', 152 | 'textAlign', 153 | 'divider', 154 | 'comment', 155 | ], 156 | video: ['video-size-small', 'video-size-medium', 'video-size-large', 'divider', 'remove'], 157 | table: ['removeTable'], 158 | } 159 | -------------------------------------------------------------------------------- /app/extensions/Ai/Ai.ts: -------------------------------------------------------------------------------- 1 | import { Node } from '@tiptap/core' 2 | import ActionButton from './components/AiButton.vue' 3 | import type { GeneralOptions } from '../../types' 4 | 5 | export interface MenuItem { 6 | label: string 7 | prompt?: string 8 | children?: MenuItem[] 9 | } 10 | export interface AIOptions extends GeneralOptions { 11 | completions: (prompt: string, text: string, signal?: AbortSignal) => Promise 12 | /** 13 | * AI Shortcuts Menu 14 | */ 15 | shortcuts: MenuItem[] 16 | } 17 | 18 | export const AI = Node.create({ 19 | name: 'Ai', 20 | group: 'block', 21 | addOptions() { 22 | return { 23 | ...this.parent?.(), 24 | toolbar: false, 25 | button: ({ editor }) => ({ 26 | component: ActionButton, 27 | componentProps: { 28 | editor, 29 | icon: 'i-lucide-sparkles', 30 | tooltip: 'AI', 31 | }, 32 | }), 33 | } 34 | }, 35 | }) 36 | -------------------------------------------------------------------------------- /app/extensions/Ai/components/AiButton.vue: -------------------------------------------------------------------------------- 1 | 61 | 62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /app/extensions/Ai/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Ai' 2 | -------------------------------------------------------------------------------- /app/extensions/Alert/Alert.ts: -------------------------------------------------------------------------------- 1 | import { Node, mergeAttributes } from '@tiptap/core' 2 | import { VueNodeViewRenderer } from '@tiptap/vue-3' 3 | import type { GeneralOptions } from '../../types' 4 | import AlertComponent from './components/Alert.vue' 5 | import ActionAlertButton from './components/ActionAlertButton.vue' 6 | 7 | export interface AlertOptions extends GeneralOptions { 8 | type: string 9 | } 10 | 11 | declare module '@tiptap/core' { 12 | interface Commands { 13 | alert: { 14 | /** 15 | * Add an alert 16 | * 17 | * @param attributes - The alert attributes 18 | */ 19 | setAlert: (attributes: { type: string }) => ReturnType, 20 | /** 21 | * Set the alert type 22 | * 23 | * @param type - The alert type 24 | */ 25 | setAlertType: (type: string) => ReturnType, 26 | } 27 | } 28 | } 29 | 30 | export const Alert = Node.create({ 31 | name: 'alert', 32 | 33 | group: 'block', 34 | 35 | defining: true, 36 | 37 | isolating: true, 38 | // content: 'block+', 39 | 40 | content: 'inline*', 41 | 42 | addAttributes() { 43 | return { 44 | type: { 45 | default: 'note', 46 | }, 47 | } 48 | }, 49 | 50 | parseHTML() { 51 | return [ 52 | { 53 | tag: 'div[data-type="alert"]', 54 | }, 55 | ] 56 | }, 57 | 58 | renderHTML({ HTMLAttributes }) { 59 | return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'alert' }), 0] 60 | }, 61 | 62 | addCommands() { 63 | return { 64 | setAlert: (attributes) => ({ commands }) => { 65 | return commands.setNode(this.name, attributes) 66 | }, 67 | setAlertType: (type: string) => ({ commands }) => { 68 | return commands.updateAttributes(this.name, { type }) 69 | } 70 | } 71 | }, 72 | 73 | addOptions() { 74 | return { 75 | ...this.parent?.(), 76 | button: ({ editor, t }) => { 77 | return { 78 | component: ActionAlertButton, 79 | componentProps: { 80 | editor, 81 | icon: 'i-lucide-sticky-note', 82 | tooltip: t('editor.alert.tooltip'), 83 | }, 84 | } 85 | } 86 | } 87 | }, 88 | 89 | addNodeView() { 90 | return VueNodeViewRenderer(AlertComponent) 91 | }, 92 | }) 93 | -------------------------------------------------------------------------------- /app/extensions/Alert/components/ActionAlertButton.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 33 | 40 | 47 | 54 | 61 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /app/extensions/Alert/components/Alert.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 38 | 42 | 47 | 48 | 49 | 50 | 51 | 55 | 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /app/extensions/Alert/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Alert' 2 | -------------------------------------------------------------------------------- /app/extensions/Alert/menus/AlertMenus.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 30 | 45 | 46 | 47 | 54 | 61 | 68 | 75 | 82 | 83 | 93 | 94 | 95 | 96 | 97 | 98 | -------------------------------------------------------------------------------- /app/extensions/Blockquote/Blockquote.ts: -------------------------------------------------------------------------------- 1 | import { Blockquote as TiptapBlockquote, type BlockquoteOptions as TiptapBlockquoteOptions } from '@tiptap/extension-blockquote' 2 | import type { GeneralOptions } from '../../types' 3 | import ActionButton from '../../components/ActionButton.vue' 4 | 5 | export interface BlockquoteOptions extends TiptapBlockquoteOptions, GeneralOptions {} 6 | 7 | export const Blockquote = TiptapBlockquote.extend({ 8 | addOptions() { 9 | return { 10 | ...this.parent?.(), 11 | HTMLAttributes: { 12 | class: 'blockquote', 13 | }, 14 | button: ({ editor, t }) => ({ 15 | component: ActionButton, 16 | componentProps: { 17 | action: () => editor.commands.toggleBlockquote(), 18 | isActive: () => editor.isActive('blockquote') || false, 19 | disabled: !editor.can().toggleBlockquote(), 20 | icon: 'i-lucide-text-quote', 21 | shortcuts: ['shift', 'mod', 'B'], 22 | tooltip: t('editor.blockquote.tooltip'), 23 | }, 24 | }), 25 | } 26 | }, 27 | }) 28 | -------------------------------------------------------------------------------- /app/extensions/Blockquote/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Blockquote' 2 | -------------------------------------------------------------------------------- /app/extensions/Bold/Bold.ts: -------------------------------------------------------------------------------- 1 | import { Bold as TiptapBold, type BoldOptions as TiptapImageOptions } from '@tiptap/extension-bold' 2 | import type { GeneralOptions } from '../../types' 3 | import ActionButton from '../../components/ActionButton.vue' 4 | 5 | export interface BoldOptions extends TiptapImageOptions, GeneralOptions {} 6 | 7 | export const Bold = TiptapBold.extend({ 8 | addOptions() { 9 | return { 10 | ...this.parent?.(), 11 | button: ({ editor, t }) => ({ 12 | component: ActionButton, 13 | componentProps: { 14 | action: () => editor.commands.toggleBold(), 15 | isActive: () => editor.isActive('bold') || false, 16 | disabled: !editor.can().toggleBold(), 17 | icon: 'i-lucide-bold', 18 | shortcuts: ['mod', 'B'], 19 | tooltip: t('editor.bold.tooltip'), 20 | }, 21 | }), 22 | } 23 | }, 24 | }) 25 | -------------------------------------------------------------------------------- /app/extensions/Bold/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Bold' 2 | -------------------------------------------------------------------------------- /app/extensions/BulletList/BulletList.ts: -------------------------------------------------------------------------------- 1 | import { BulletList as TiptapBulletList, type BulletListOptions as TiptapBulletListOptions } from '@tiptap/extension-bullet-list' 2 | import type { GeneralOptions } from '../../types' 3 | import ActionButton from '../../components/ActionButton.vue' 4 | 5 | export interface BulletListOptions extends TiptapBulletListOptions, GeneralOptions {} 6 | 7 | export const BulletList = TiptapBulletList.extend({ 8 | addOptions() { 9 | return { 10 | ...this.parent?.(), 11 | button: ({ editor, t }) => ({ 12 | component: ActionButton, 13 | componentProps: { 14 | action: () => editor.commands.toggleBulletList(), 15 | isActive: () => editor.isActive('bulletList') || false, 16 | disabled: !editor.can().toggleBulletList(), 17 | shortcuts: ['shift', 'mod', '8'], 18 | icon: 'i-lucide-list', 19 | tooltip: t('editor.bulletlist.tooltip'), 20 | }, 21 | }), 22 | } 23 | }, 24 | }) 25 | -------------------------------------------------------------------------------- /app/extensions/BulletList/index.ts: -------------------------------------------------------------------------------- 1 | export * from './BulletList' 2 | -------------------------------------------------------------------------------- /app/extensions/Clear/Clear.ts: -------------------------------------------------------------------------------- 1 | import { Node } from '@tiptap/core' 2 | import type { GeneralOptions } from '../../types' 3 | import ActionButton from '../../components/ActionButton.vue' 4 | 5 | export interface ClearOptions extends GeneralOptions {} 6 | 7 | export const Clear = Node.create({ 8 | name: 'clear', 9 | addOptions() { 10 | return { 11 | ...this.parent?.(), 12 | button: ({ editor, t }) => ({ 13 | component: ActionButton, 14 | componentProps: { 15 | action: () => editor.chain().focus().clearNodes().unsetAllMarks().run(), 16 | disabled: !editor.can().chain().focus().clearNodes().unsetAllMarks().run(), 17 | icon: 'i-lucide-eraser', 18 | tooltip: t('editor.clear.tooltip'), 19 | }, 20 | }), 21 | } 22 | }, 23 | }) 24 | -------------------------------------------------------------------------------- /app/extensions/Clear/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Clear' 2 | -------------------------------------------------------------------------------- /app/extensions/Code/Code.ts: -------------------------------------------------------------------------------- 1 | import { Code as TiptapCode, type CodeOptions as TiptapCodeOptions } from '@tiptap/extension-code' 2 | import type { GeneralOptions } from '../../types' 3 | import ActionButton from '../../components/ActionButton.vue' 4 | 5 | export interface CodeOptions extends TiptapCodeOptions, GeneralOptions {} 6 | 7 | export const Code = TiptapCode.extend({ 8 | addOptions() { 9 | return { 10 | ...this.parent?.(), 11 | toolbar: false, 12 | button: ({ editor, t }) => ({ 13 | component: ActionButton, 14 | componentProps: { 15 | action: () => editor.commands.toggleCode(), 16 | isActive: () => editor.isActive('code') || false, 17 | disabled: !editor.can().toggleCode(), 18 | icon: 'i-lucide-code', 19 | shortcuts: ['mod', 'E'], 20 | tooltip: t('editor.code.tooltip'), 21 | }, 22 | }), 23 | } 24 | }, 25 | }) 26 | -------------------------------------------------------------------------------- /app/extensions/Code/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Code' 2 | -------------------------------------------------------------------------------- /app/extensions/CodeBlock/CodeBlock.ts: -------------------------------------------------------------------------------- 1 | import { CodeBlockLowlight as TiptapCodeBlock, type CodeBlockLowlightOptions as TiptapCodeBlockOptions } from '@tiptap/extension-code-block-lowlight' 2 | import type { GeneralOptions } from '../../types' 3 | import ActionButton from '../../components/ActionButton.vue' 4 | import CodeBlockView from './components/CodeBlockView.vue' 5 | import { VueNodeViewRenderer } from '@tiptap/vue-3' 6 | 7 | export interface CodeBlockOptions extends TiptapCodeBlockOptions, GeneralOptions {} 8 | 9 | export const CodeBlock = TiptapCodeBlock.extend({ 10 | addOptions() { 11 | return { 12 | ...this.parent?.(), 13 | defaultLanguage: null, 14 | button: ({ editor, t }) => ({ 15 | component: ActionButton, 16 | componentProps: { 17 | action: () => editor.commands.toggleCodeBlock(), 18 | isActive: () => editor.isActive('codeBlock') || false, 19 | disabled: !editor.can().toggleCodeBlock(), 20 | icon: 'i-lucide-code-xml', 21 | tooltip: t('editor.codeblock.tooltip'), 22 | }, 23 | }), 24 | } 25 | }, 26 | addNodeView() { 27 | return VueNodeViewRenderer(CodeBlockView) 28 | }, 29 | }) 30 | -------------------------------------------------------------------------------- /app/extensions/CodeBlock/components/CodeBlockView.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 19 | 20 | 21 | 22 | {{ selectedLanguage }} 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /app/extensions/CodeBlock/index.ts: -------------------------------------------------------------------------------- 1 | export * from './CodeBlock' 2 | -------------------------------------------------------------------------------- /app/extensions/Collaboration/Collaboration.ts: -------------------------------------------------------------------------------- 1 | import { Collaboration as TiptapCollaboration, type CollaborationOptions as TiptapCollaborationOptions } from '@tiptap/extension-collaboration' 2 | import type { GeneralOptions } from '../../types' 3 | 4 | export interface CollaborationOptions extends TiptapCollaborationOptions, GeneralOptions {} 5 | 6 | export const Collaboration = TiptapCollaboration.extend({ 7 | addOptions() { 8 | return { 9 | document: null, 10 | } 11 | } 12 | }) 13 | 14 | export default Collaboration 15 | -------------------------------------------------------------------------------- /app/extensions/Collaboration/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Collaboration' 2 | -------------------------------------------------------------------------------- /app/extensions/CollaborationCursor/CollaborationCursor.ts: -------------------------------------------------------------------------------- 1 | import { CollaborationCursor as TiptapCollaborationCursor, type CollaborationCursorOptions as TiptapCollaborationCursorOptions } from '@tiptap/extension-collaboration-cursor' 2 | import type { GeneralOptions } from '../../types' 3 | 4 | export interface CollaborationCursorOptions extends TiptapCollaborationCursorOptions, GeneralOptions {} 5 | 6 | export const CollaborationCursor = TiptapCollaborationCursor.extend({ 7 | addOptions() { 8 | return { 9 | provider: null, 10 | } 11 | } 12 | }) 13 | -------------------------------------------------------------------------------- /app/extensions/CollaborationCursor/index.ts: -------------------------------------------------------------------------------- 1 | export * from './CollaborationCursor' 2 | -------------------------------------------------------------------------------- /app/extensions/Color/Color.ts: -------------------------------------------------------------------------------- 1 | import TiptapColor, { type ColorOptions as TiptapColorOptions } from '@tiptap/extension-color' 2 | import type { GeneralOptions } from '../../types' 3 | import ColorActionButton from './components/ColorActionButton.vue' 4 | 5 | export interface ColorOptions extends TiptapColorOptions, GeneralOptions {} 6 | 7 | export const Color = TiptapColor.extend({ 8 | addOptions() { 9 | return { 10 | ...this.parent?.(), 11 | button({ editor, t }) { 12 | return { 13 | component: ColorActionButton, 14 | componentProps: { 15 | action: (color?: unknown) => { 16 | if (typeof color === 'undefined') editor.chain().focus().unsetColor().run() 17 | if (typeof color === 'string') editor.chain().focus().setColor(color).run() 18 | }, 19 | isActive: () => { 20 | const { color } = editor.getAttributes('textStyle') 21 | if (!color) return false 22 | return editor.isActive({ color }) || false 23 | }, 24 | editor, 25 | disabled: !editor.can().setColor(''), 26 | tooltip: t('editor.color.tooltip'), 27 | }, 28 | } 29 | }, 30 | } 31 | }, 32 | }) 33 | -------------------------------------------------------------------------------- /app/extensions/Color/components/ColorActionButton.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 44 | 49 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /app/extensions/Color/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Color' 2 | -------------------------------------------------------------------------------- /app/extensions/Comment/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Comment' 2 | -------------------------------------------------------------------------------- /app/extensions/Details/Details.ts: -------------------------------------------------------------------------------- 1 | import { Details as DetailsTiptap, type DetailsOptions } from '@tiptap-pro/extension-details' 2 | import { DetailsSummary, type DetailsSummaryOptions } from '@tiptap-pro/extension-details-summary' 3 | import { DetailsContent, type DetailsContentOptions } from '@tiptap-pro/extension-details-content' 4 | import type { GeneralOptions } from '../../types' 5 | import ActionButton from '../../components/ActionButton.vue' 6 | 7 | /** 8 | * Represents the interface for details options, extending DetailsOptions and GeneralOptions. 9 | */ 10 | export interface DetailsOptions extends DetailsOptions, GeneralOptions { 11 | /** options for details summary */ 12 | detailsSummary: Partial 13 | /** options for details content */ 14 | detailsContent: Partial 15 | } 16 | 17 | export const Details = DetailsTiptap.extend({ 18 | addOptions() { 19 | return { 20 | ...this.parent?.(), 21 | detailsSummary: { 22 | HTMLAttributes: { 23 | class: 'details-summary', 24 | }, 25 | }, 26 | detailsContent: { 27 | HTMLAttributes: { 28 | class: 'details-content', 29 | }, 30 | }, 31 | button: ({ editor, t }) => ({ 32 | component: ActionButton, 33 | componentProps: { 34 | action: () => editor.commands.setDetails(), 35 | isActive: () => editor.isActive('details') || false, 36 | disabled: !editor.can().setDetails(), 37 | icon: 'i-lucide-list-collapse', 38 | shortcuts: ['mod', 'D'], 39 | tooltip: t('editor.details.tooltip'), 40 | }, 41 | }), 42 | } 43 | }, 44 | 45 | addExtensions() { 46 | return [ 47 | DetailsSummary.configure(this.options.detailsSummary), 48 | DetailsContent.configure(this.options.detailsContent), 49 | ] 50 | }, 51 | }) 52 | -------------------------------------------------------------------------------- /app/extensions/Details/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Details' 2 | -------------------------------------------------------------------------------- /app/extensions/Document/Document.ts: -------------------------------------------------------------------------------- 1 | import { Document as TiptapDocument } from '@tiptap/extension-document' 2 | 3 | export const Document = TiptapDocument.extend({ 4 | content: '(block|columns)+', 5 | // leazy editor is a block editor 6 | }) 7 | 8 | export default Document 9 | -------------------------------------------------------------------------------- /app/extensions/Document/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Document' 2 | -------------------------------------------------------------------------------- /app/extensions/Emoji/Emoji.ts: -------------------------------------------------------------------------------- 1 | import { VueRenderer } from '@tiptap/vue-3' 2 | import tippy from 'tippy.js' 3 | import EmojiList from './components/EmojiList' 4 | 5 | export default { 6 | items: ({ editor, query }) => { 7 | return editor.storage.emoji.emojis 8 | .filter(({ shortcodes, tags }) => { 9 | return ( 10 | shortcodes.find(shortcode => shortcode.startsWith(query.toLowerCase())) 11 | || tags.find(tag => tag.startsWith(query.toLowerCase())) 12 | ) 13 | }) 14 | .slice(0, 5) 15 | }, 16 | 17 | render: () => { 18 | let component 19 | let popup 20 | 21 | return { 22 | onStart: props => { 23 | component = new VueRenderer(EmojiList, { 24 | props, 25 | editor: props.editor, 26 | }) 27 | 28 | popup = tippy('body', { 29 | getReferenceClientRect: props.clientRect, 30 | appendTo: () => document.body, 31 | content: component.element, 32 | showOnCreate: true, 33 | interactive: true, 34 | trigger: 'manual', 35 | placement: 'bottom-start', 36 | }) 37 | }, 38 | 39 | onUpdate(props) { 40 | component.updateProps(props) 41 | 42 | popup[0].setProps({ 43 | getReferenceClientRect: props.clientRect, 44 | }) 45 | }, 46 | 47 | onKeyDown(props) { 48 | if (props.event.key === 'Escape') { 49 | popup[0].hide() 50 | component.destroy() 51 | 52 | return true 53 | } 54 | 55 | return component.ref?.onKeyDown(props) 56 | }, 57 | 58 | onExit() { 59 | popup[0].destroy() 60 | component.destroy() 61 | } 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /app/extensions/Emoji/components/EmojiList.vue: -------------------------------------------------------------------------------- 1 | 2 | 5 | 14 | 15 | 17 | 18 | 19 | 20 | 21 | :{{ item.name }}: 22 | 23 | 24 | 25 | 26 | 99 | -------------------------------------------------------------------------------- /app/extensions/Emoji/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Emoji' 2 | -------------------------------------------------------------------------------- /app/extensions/Export/Export.ts: -------------------------------------------------------------------------------- 1 | import { Export as TiptapExport, type ExportOptions as TiptapExportOptions } from '@tiptap-pro/extension-export' 2 | import ActionButton from './components/ActionButton.vue' 3 | import type { GeneralOptions } from '../../types' 4 | 5 | export interface ExportOptions extends TiptapExportOptions, GeneralOptions {} 6 | 7 | export const Export = TiptapExport.extend({ 8 | addOptions() { 9 | const onExport = (context: any) => { 10 | context?.download?.() 11 | } 12 | 13 | return { 14 | ...this.parent?.(), 15 | button: ({ editor, t }) => ({ 16 | component: ActionButton, 17 | componentProps: { 18 | icon: 'i-lucide-file-up', 19 | disabled: !editor.can().export, 20 | tooltip: t('editor.export.tooltip'), 21 | items: [ 22 | { 23 | action: () => { 24 | editor.commands.export({ format: 'docx', onExport }) 25 | }, 26 | icon: 'i-simple-icons-microsoftword', 27 | disabled: !editor.can().export, 28 | tooltip: 'DOCX', 29 | label: 'Docx' 30 | }, 31 | { 32 | action: () => { 33 | editor.commands.export({ format: 'odt', onExport }) 34 | }, 35 | icon: 'i-lucide-file-text', 36 | disabled: !editor.can().export, 37 | tooltip: 'ODT', 38 | label: 'Odt' 39 | }, 40 | { 41 | action: () => { 42 | editor.commands.export({ format: 'md', onExport }) 43 | }, 44 | icon: 'i-simple-icons-markdown', 45 | disabled: !editor.can().export, 46 | tooltip: 'Markdown', 47 | label: 'Markdown' 48 | }, 49 | { 50 | action: () => { 51 | editor.commands.export({ format: 'gfm', onExport }) 52 | }, 53 | icon: 'i-simple-icons-github', 54 | disabled: !editor.can().export, 55 | tooltip: 'GitHub Markdown', 56 | label: 'GitHub Markdown' 57 | } 58 | ] 59 | } 60 | }) 61 | } 62 | } 63 | }) 64 | -------------------------------------------------------------------------------- /app/extensions/Export/components/ActionButton.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /app/extensions/Export/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Export' 2 | -------------------------------------------------------------------------------- /app/extensions/FontSize/components/FontSizeMenuButton.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 66 | 67 | 68 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /app/extensions/FontSize/index.ts: -------------------------------------------------------------------------------- 1 | export * from './FontSize' 2 | -------------------------------------------------------------------------------- /app/extensions/FormatPainter/FormatPainter.ts: -------------------------------------------------------------------------------- 1 | import { Extension } from '@tiptap/core' 2 | import { Plugin, PluginKey } from '@tiptap/pm/state' 3 | import { Mark } from '@tiptap/pm/model' 4 | import type { GeneralOptions } from '../../types' 5 | import ActionButton from '../../components/ActionButton.vue' 6 | 7 | /** 8 | * Represents the interface for font size options, extending GeneralOptions. 9 | */ 10 | export interface FormatPainterOptions extends GeneralOptions {} 11 | 12 | declare module '@tiptap/core' { 13 | interface Commands { 14 | painter: { 15 | setPainter: (marks: Mark[]) => ReturnType 16 | } 17 | } 18 | } 19 | 20 | export type PainterAction = { 21 | type: 'start' | 'end' 22 | marks: Mark[] 23 | } 24 | /** 25 | * 格式刷 26 | */ 27 | export const FormatPainter = Extension.create({ 28 | name: 'painter', 29 | addOptions() { 30 | return { 31 | ...this.parent?.(), 32 | button: ({ editor, extension, t }) => ({ 33 | component: ActionButton, 34 | componentProps: { 35 | action: () => { 36 | editor.commands.setPainter(editor?.state.selection.$head.marks() as Mark[]) 37 | }, 38 | icon: 'i-lucide-paint-bucket', 39 | tooltip: t('editor.format'), 40 | }, 41 | }), 42 | } 43 | }, 44 | addCommands() { 45 | return { 46 | setPainter: 47 | (marks: Mark[]) => 48 | ({ 49 | view: { 50 | dispatch, 51 | state: { tr }, 52 | dom, 53 | }, 54 | }) => { 55 | const svgCursor = `` 56 | const encodedSvg = encodeURIComponent(svgCursor) 57 | dom.style.cursor = `url("data:image/svg+xml;utf8,${encodedSvg}"), auto` 58 | dispatch(tr.setMeta('painterAction', { type: 'start', marks })) 59 | return true 60 | }, 61 | } 62 | }, 63 | 64 | addProseMirrorPlugins() { 65 | return [ 66 | new Plugin({ 67 | key: new PluginKey('format-painter'), 68 | state: { 69 | init: () => [] as Mark[], 70 | apply: (tr, set) => { 71 | const action = tr.getMeta('painterAction') as PainterAction 72 | if (action && action.type === 'start') { 73 | set = action.marks 74 | } else if (action && action.type === 'end') { 75 | set = [] 76 | } 77 | return set 78 | }, 79 | }, 80 | props: { 81 | handleDOMEvents: { 82 | mousedown(view, _) { 83 | const marks = this.getState(view.state) as Mark[] 84 | if (!marks || marks.length == 0) { 85 | view.dom.style.cursor = '' 86 | return false 87 | } 88 | const mouseup = () => { 89 | document.removeEventListener('mouseup', mouseup) 90 | 91 | let { 92 | dispatch, 93 | state: { tr, selection }, 94 | dom, 95 | } = view 96 | dom.style.cursor = '' 97 | 98 | tr = tr.removeMark(selection.from, selection.to) 99 | for (let mark of marks) { 100 | if (mark.type.name != 'link') { 101 | tr = tr.addMark(selection.from, selection.to, mark) 102 | } 103 | } 104 | 105 | dispatch(tr.setMeta('painterAction', { type: 'end' })) 106 | } 107 | document.addEventListener('mouseup', mouseup) 108 | return true 109 | }, 110 | }, 111 | }, 112 | }), 113 | ] 114 | }, 115 | }) 116 | -------------------------------------------------------------------------------- /app/extensions/FormatPainter/index.ts: -------------------------------------------------------------------------------- 1 | export * from './FormatPainter' 2 | -------------------------------------------------------------------------------- /app/extensions/Fullscreen/Fullscreen.ts: -------------------------------------------------------------------------------- 1 | import { Extension } from '@tiptap/core' 2 | import FullscreenActionButton from './components/FullscreenActionButton.vue' 3 | import type { GeneralOptions } from '../../types' 4 | 5 | export interface FullscreenOptions extends GeneralOptions {} 6 | 7 | export const Fullscreen = Extension.create({ 8 | name: 'fullscreen', 9 | addOptions() { 10 | return { 11 | ...this.parent?.(), 12 | useWindow: false, 13 | button: () => ({ 14 | component: FullscreenActionButton, 15 | componentProps: {}, 16 | }), 17 | } 18 | }, 19 | }) 20 | -------------------------------------------------------------------------------- /app/extensions/Fullscreen/components/FullscreenActionButton.vue: -------------------------------------------------------------------------------- 1 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /app/extensions/Fullscreen/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Fullscreen' 2 | -------------------------------------------------------------------------------- /app/extensions/Heading/Heading.ts: -------------------------------------------------------------------------------- 1 | import type { Extension } from '@tiptap/core' 2 | import { Heading as TiptapHeading, type HeadingOptions as TiptapHeadingOptions } from '@tiptap/extension-heading' 3 | import type { Item } from './components/HeadingButton.vue' 4 | import HeadingButton from './components/HeadingButton.vue' 5 | import type { BaseKitOptions } from '../../extensions' 6 | import type { GeneralOptions } from '../../types' 7 | 8 | export interface HeadingOptions extends TiptapHeadingOptions, GeneralOptions {} 9 | 10 | export const Heading = TiptapHeading.extend({ 11 | addOptions() { 12 | return { 13 | ...this.parent?.(), 14 | levels: [1, 2, 3, 4, 5, 6], 15 | button({ editor, extension, t }) { 16 | const { extensions = [] } = editor.extensionManager ?? [] 17 | const levels = extension.options?.levels || [] 18 | const baseKitExt = extensions.find(k => k.name === 'base-kit') as Extension 19 | 20 | const items: Item[] = levels.map(level => ({ 21 | action: () => editor.commands.toggleHeading({ level }), 22 | isActive: () => editor.isActive('heading', { level }) || false, 23 | disabled: !editor.can().toggleHeading({ level }), 24 | title: t(`editor.heading.h${level}.tooltip`), 25 | level: level, 26 | shortcuts: ['alt', 'mod', `${level}`], 27 | })) 28 | if (baseKitExt && baseKitExt.options.paragraph !== false) { 29 | items.unshift({ 30 | action: () => editor.commands.setParagraph(), 31 | isActive: () => editor.isActive('paragraph') || false, 32 | disabled: !editor.can().setParagraph(), 33 | level: 0, 34 | title: t('editor.paragraph.tooltip'), 35 | shortcuts: ['alt', 'mod', '0'], 36 | }) 37 | } 38 | 39 | const disabled = items.filter((k: any) => k.disabled).length === items.length 40 | 41 | return { 42 | component: HeadingButton, 43 | componentProps: { 44 | tooltip: t('editor.heading.tooltip'), 45 | disabled, 46 | items, 47 | }, 48 | } 49 | }, 50 | } 51 | }, 52 | }) 53 | -------------------------------------------------------------------------------- /app/extensions/Heading/components/HeadingButton.vue: -------------------------------------------------------------------------------- 1 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 69 | {{ item.title }} 70 | 71 | 72 | 73 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /app/extensions/Heading/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Heading' 2 | -------------------------------------------------------------------------------- /app/extensions/Highlight/Highlight.ts: -------------------------------------------------------------------------------- 1 | import { Highlight as TiptapHighlight, type HighlightOptions as TiptapHighlightOptions } from '@tiptap/extension-highlight' 2 | import HighlightActionButton from './components/HighlightActionButton.vue' 3 | import type { GeneralOptions } from '../../types' 4 | 5 | export interface HighlightOptions extends TiptapHighlightOptions, GeneralOptions {} 6 | 7 | export const Highlight = TiptapHighlight.extend({ 8 | addOptions() { 9 | return { 10 | ...this.parent?.(), 11 | multicolor: true, 12 | button: ({ editor, t }) => ({ 13 | component: HighlightActionButton, 14 | componentProps: { 15 | action: (color?: unknown) => { 16 | if (typeof color === 'string') editor.chain().focus().setHighlight({ color }).run() 17 | if (typeof color === 'undefined') editor.chain().focus().unsetHighlight().run() 18 | }, 19 | editor, 20 | isActive: () => editor.isActive('highlight') || false, 21 | disabled: !editor.can().setHighlight(), 22 | shortcuts: ['⇧', 'mod', 'H'], 23 | tooltip: t('editor.highlight.tooltip'), 24 | }, 25 | }), 26 | } 27 | }, 28 | }) 29 | -------------------------------------------------------------------------------- /app/extensions/Highlight/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Highlight'; 2 | -------------------------------------------------------------------------------- /app/extensions/History/History.ts: -------------------------------------------------------------------------------- 1 | import { History as TiptapHistory, type HistoryOptions as TiptapHistoryOptions } from '@tiptap/extension-history' 2 | import type { GeneralOptions } from '../../types' 3 | import ActionButton from '../../components/ActionButton.vue' 4 | 5 | export interface HistoryOptions extends TiptapHistoryOptions, GeneralOptions {} 6 | 7 | export const History = TiptapHistory.extend({ 8 | addOptions() { 9 | return { 10 | ...this.parent?.(), 11 | depth: 10, 12 | button: ({ editor, t }) => { 13 | const historys: ['undo', 'redo'] = ['undo', 'redo'] 14 | return historys.map(item => ({ 15 | component: ActionButton, 16 | componentProps: { 17 | action: () => { 18 | if (item === 'undo') editor.commands.undo() 19 | if (item === 'redo') editor.commands.redo() 20 | }, 21 | shortcuts: item === 'undo' ? ['mod', 'Z'] : ['shift', 'mod', 'Z'], 22 | disabled: !editor.can()[item](), 23 | icon: item === 'undo' ? 'i-lucide-undo' : 'i-lucide-redo', 24 | tooltip: t(`editor.${item}.tooltip`), 25 | }, 26 | })) 27 | }, 28 | } 29 | }, 30 | }) 31 | -------------------------------------------------------------------------------- /app/extensions/History/index.ts: -------------------------------------------------------------------------------- 1 | export * from './History' 2 | -------------------------------------------------------------------------------- /app/extensions/HorizontalRule/HorizontalRule.ts: -------------------------------------------------------------------------------- 1 | import { mergeAttributes } from '@tiptap/core' 2 | import { HorizontalRule as TiptapHorizontalRule, type HorizontalRuleOptions as TiptapHorizontalRuleOptions } from '@tiptap/extension-horizontal-rule' 3 | import type { GeneralOptions } from '../../types' 4 | import ActionButton from '../../components/ActionButton.vue' 5 | 6 | export interface HorizontalRuleOptions extends TiptapHorizontalRuleOptions, GeneralOptions {} 7 | 8 | export const HorizontalRule = TiptapHorizontalRule.extend({ 9 | renderHTML() { 10 | return [ 11 | 'div', 12 | mergeAttributes(this.options.HTMLAttributes, { 13 | 'data-type': this.name, 14 | }), 15 | ['hr'], 16 | ] 17 | }, 18 | addOptions() { 19 | return { 20 | ...this.parent?.(), 21 | button: ({ editor, t }) => ({ 22 | component: ActionButton, 23 | componentProps: { 24 | action: () => editor.commands.setHorizontalRule(), 25 | disabled: !editor.can().setHorizontalRule(), 26 | icon: 'i-lucide-minus', 27 | shortcuts: ['mod', 'alt', 'S'], 28 | tooltip: t('editor.horizontalrule.tooltip'), 29 | }, 30 | }), 31 | } 32 | }, 33 | addKeyboardShortcuts() { 34 | return { 35 | 'Mod-Alt-s': () => this.editor.commands.setHorizontalRule(), 36 | } 37 | }, 38 | }) 39 | -------------------------------------------------------------------------------- /app/extensions/HorizontalRule/index.ts: -------------------------------------------------------------------------------- 1 | export * from './HorizontalRule' 2 | -------------------------------------------------------------------------------- /app/extensions/Iframe/Iframe.ts: -------------------------------------------------------------------------------- 1 | import { Node } from '@tiptap/core' 2 | import { VueNodeViewRenderer } from '@tiptap/vue-3' 3 | import IframeView from './components/IframeNodeView.vue' 4 | import type { GeneralOptions } from '../../types' 5 | 6 | export interface IframeOptions extends GeneralOptions { 7 | allowFullscreen: boolean 8 | HTMLAttributes: { 9 | [key: string]: any 10 | } 11 | } 12 | 13 | declare module '@tiptap/core' { 14 | interface Commands { 15 | iframe: { 16 | /** 17 | * Add an iframe 18 | */ 19 | setIframe: (options: { src: string; service: string }) => ReturnType 20 | } 21 | } 22 | } 23 | 24 | export default Node.create({ 25 | name: 'iframe', 26 | group: 'block', 27 | atom: true, 28 | draggable: true, 29 | addOptions() { 30 | return { 31 | ...this.parent?.(), 32 | allowFullscreen: true, 33 | HTMLAttributes: { 34 | class: 'iframe-wrapper', 35 | }, 36 | } 37 | }, 38 | addAttributes() { 39 | return { 40 | src: { 41 | default: null, 42 | }, 43 | service: { 44 | default: null, 45 | }, 46 | frameborder: { 47 | default: 0, 48 | }, 49 | allowfullscreen: { 50 | default: this.options.allowFullscreen, 51 | parseHTML: () => this.options.allowFullscreen, 52 | }, 53 | } 54 | }, 55 | parseHTML() { 56 | return [ 57 | { 58 | tag: 'iframe', 59 | }, 60 | ] 61 | }, 62 | renderHTML({ HTMLAttributes }) { 63 | return ['div', this.options.HTMLAttributes, ['iframe', HTMLAttributes]] 64 | }, 65 | addNodeView() { 66 | return VueNodeViewRenderer(IframeView) 67 | }, 68 | addCommands() { 69 | return { 70 | setIframe: 71 | (options: { src: string; service: string }) => 72 | ({ tr, dispatch }) => { 73 | const { selection } = tr 74 | const node = this.type.create(options) 75 | 76 | if (dispatch) { 77 | tr.replaceRangeWith(selection.from, selection.to, node) 78 | } 79 | return true 80 | }, 81 | } 82 | }, 83 | }) 84 | -------------------------------------------------------------------------------- /app/extensions/Iframe/components/IframeNodeView.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | Exemple 47 | Confirmer 48 | 49 | 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /app/extensions/Iframe/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Iframe' 2 | -------------------------------------------------------------------------------- /app/extensions/Image/Image.ts: -------------------------------------------------------------------------------- 1 | import { Image as TiptapImage, type ImageOptions as TiptapImageOptions } from '@tiptap/extension-image' 2 | import { VueNodeViewRenderer } from '@tiptap/vue-3' 3 | import ImageView from './components/ImageView.vue' 4 | import type { ImageNodeAttributes } from '../../utils/image' 5 | import type { ImageAttrsOptions, ImageTab, ImageTabKey } from './types' 6 | import { IMAGE_SIZE } from '../../constants' 7 | import type { GeneralOptions } from '../../types' 8 | 9 | /** 10 | * Represents the interface for image options, extending TiptapImageOptions and GeneralOptions. 11 | */ 12 | export interface ImageOptions extends TiptapImageOptions, GeneralOptions { 13 | /** Function for uploading images */ 14 | upload?: (files: File[]) => ImageNodeAttributes[] | Promise 15 | /** List of image tabs */ 16 | imageTabs: ImageTab[] 17 | /** List of hidden image tab keys */ 18 | hiddenTabs: ImageTabKey[] 19 | /** Component for the image dialog */ 20 | dialogComponent: any 21 | } 22 | 23 | /** 24 | * Represents the interface for options to set image attributes, extending ImageAttrsOptions and including the src property. 25 | */ 26 | interface SetImageAttrsOptions extends ImageAttrsOptions { 27 | /** The source URL of the image. */ 28 | src: string 29 | } 30 | 31 | declare module '@tiptap/core' { 32 | interface Commands { 33 | imageResize: { 34 | /** 35 | * Add an image 36 | */ 37 | setImage: (options: Partial) => ReturnType 38 | /** 39 | * Update an image 40 | */ 41 | updateImage: (options: Partial) => ReturnType 42 | } 43 | } 44 | } 45 | 46 | export const Image = TiptapImage.extend({ 47 | addAttributes() { 48 | return { 49 | ...this.parent?.(), 50 | src: { 51 | default: null, 52 | }, 53 | alt: { 54 | default: undefined, 55 | }, 56 | lockAspectRatio: { 57 | default: true, 58 | }, 59 | width: { 60 | default: IMAGE_SIZE['size-large'], 61 | }, 62 | height: { 63 | default: null, 64 | }, 65 | display: { 66 | default: 'inline', 67 | renderHTML: ({ display }) => { 68 | if (!display) { 69 | return {} 70 | } 71 | return { 72 | 'data-display': display, 73 | } 74 | }, 75 | parseHTML: element => { 76 | const display = element.getAttribute('data-display') 77 | return display || 'inline' 78 | }, 79 | }, 80 | } 81 | }, 82 | addNodeView() { 83 | return VueNodeViewRenderer(ImageView) 84 | }, 85 | addCommands() { 86 | return { 87 | ...this.parent?.(), 88 | updateImage: 89 | options => 90 | ({ commands }) => { 91 | return commands.updateAttributes(this.name, options) 92 | }, 93 | } 94 | }, 95 | addOptions() { 96 | return { 97 | ...this.parent?.(), 98 | upload: undefined, 99 | imageTabs: [], 100 | hiddenTabs: [], 101 | inline: true, 102 | } 103 | }, 104 | }) 105 | -------------------------------------------------------------------------------- /app/extensions/Image/components/ImageActionButton.vue: -------------------------------------------------------------------------------- 1 | 54 | 55 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /app/extensions/Image/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Image' 2 | -------------------------------------------------------------------------------- /app/extensions/Image/types.ts: -------------------------------------------------------------------------------- 1 | /** Represents the key types for different image tabs */ 2 | export type ImageTabKey = 'url' | 'upload' 3 | 4 | /** Represents the display options for images */ 5 | export type Display = 'block' | 'inline' | 'left' | 'right' 6 | 7 | /** Represents an image tab with specified properties */ 8 | export interface ImageTab { 9 | /** 10 | * The name of the image tab 11 | */ 12 | name: string 13 | /** 14 | * The type of the image tab key 15 | */ 16 | type?: ImageTabKey 17 | /** 18 | * The component associated with the image tab 19 | */ 20 | component: any 21 | } 22 | 23 | /** 24 | * Represents options for configuring image attributes 25 | */ 26 | export interface ImageAttrsOptions { 27 | /** The source URL of the image. */ 28 | src?: string 29 | /** The alternative text for the image. */ 30 | alt?: string 31 | /** The title of the image. */ 32 | title?: string 33 | /** Indicates whether the aspect ratio of the image should be locked. */ 34 | lockAspectRatio?: boolean 35 | /** The width of the image. */ 36 | width?: number | string | null 37 | /** The height of the image. */ 38 | height?: number | string | null 39 | /** The display style of the image. */ 40 | display?: Display 41 | } 42 | 43 | /** Represents a form for handling image attributes */ 44 | export interface ImageForm extends ImageAttrsOptions { 45 | /** An array of File objects representing the image file */ 46 | file?: File[] 47 | } 48 | -------------------------------------------------------------------------------- /app/extensions/ImageUpload/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ImageUpload' 2 | -------------------------------------------------------------------------------- /app/extensions/Import/Import.ts: -------------------------------------------------------------------------------- 1 | import { Import as TiptapImport, type ImportOptions as TiptapImportOptions } from '@tiptap-pro/extension-import' 2 | import ActionButton from './components/ActionButton.vue' 3 | import type { GeneralOptions } from '../../types' 4 | 5 | export interface ImportOptions extends TiptapImportOptions, GeneralOptions {} 6 | 7 | export const Import = TiptapImport.extend({ 8 | addOptions() { 9 | return { 10 | ...this.parent?.(), 11 | button: ({ editor, t }) => ({ 12 | component: ActionButton, 13 | componentProps: { 14 | icon: 'i-lucide-file-down', 15 | disabled: !editor.can().import, 16 | tooltip: t('editor.import.tooltip'), 17 | } 18 | }) 19 | } 20 | } 21 | }) 22 | -------------------------------------------------------------------------------- /app/extensions/Import/components/ActionButton.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /app/extensions/Import/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Import' 2 | -------------------------------------------------------------------------------- /app/extensions/ImportWord/ImportWord.ts: -------------------------------------------------------------------------------- 1 | import { Extension } from '@tiptap/core' 2 | import ActionButton from './components/ImportWordButton.vue' 3 | import type { ImageNodeAttributes } from '../../utils/image' 4 | import type { GeneralOptions } from '../../types' 5 | 6 | export interface ImportWordOptions extends GeneralOptions { 7 | /** 8 | * Function for converting files to HTML 9 | */ 10 | convert?: (file: File) => Promise 11 | 12 | /** Function for uploading images */ 13 | upload?: (files: File[]) => ImageNodeAttributes[] | Promise 14 | } 15 | 16 | export const ImportWord = Extension.create({ 17 | name: 'importWord', 18 | addOptions() { 19 | return { 20 | ...this.parent?.(), 21 | upload: undefined, 22 | convert: undefined, 23 | button: ({ editor, extension, t }) => { 24 | const { convert } = extension.options 25 | return { 26 | component: ActionButton, 27 | componentProps: { 28 | convert, 29 | action: () => editor.commands.setHorizontalRule(), 30 | disabled: !editor.can().setHorizontalRule(), 31 | icon: 'i-simple-icons-microsoftword', 32 | shortcuts: ['alt', 'mod', 'S'], 33 | tooltip: t('editor.importWord.tooltip'), 34 | }, 35 | } 36 | }, 37 | } 38 | }, 39 | }) 40 | -------------------------------------------------------------------------------- /app/extensions/ImportWord/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ImportWord' 2 | -------------------------------------------------------------------------------- /app/extensions/Indent/Indent.ts: -------------------------------------------------------------------------------- 1 | import { Extension, type Editor } from '@tiptap/core' 2 | import { createIndentCommand, IndentProps } from '../../utils/indent' 3 | import type { GeneralOptions } from '../../types' 4 | import ActionButton from '../../components/ActionButton.vue' 5 | export interface IndentOptions extends GeneralOptions { 6 | types: string[] 7 | minIndent: number 8 | maxIndent: number 9 | } 10 | 11 | declare module '@tiptap/core' { 12 | interface Commands { 13 | indent: { 14 | /** 15 | * Set the indent attribute 16 | */ 17 | indent: () => ReturnType 18 | /** 19 | * Set the outdent attribute 20 | */ 21 | outdent: () => ReturnType 22 | } 23 | } 24 | } 25 | 26 | export const Indent = Extension.create({ 27 | name: 'indent', 28 | addOptions() { 29 | return { 30 | ...this.parent?.(), 31 | types: ['paragraph', 'heading', 'blockquote'], 32 | minIndent: IndentProps.min, 33 | maxIndent: IndentProps.max, 34 | button({ editor, t }: { editor: Editor; t: (...args: any[]) => string }) { 35 | return [ 36 | { 37 | component: ActionButton, 38 | componentProps: { 39 | action: () => { 40 | editor.commands.indent() 41 | }, 42 | shortcuts: ['Tab'], 43 | icon: 'i-lucide-indent-increase', 44 | tooltip: t('editor.indent.tooltip'), 45 | }, 46 | }, 47 | { 48 | component: ActionButton, 49 | componentProps: { 50 | action: () => { 51 | editor.commands.outdent() 52 | }, 53 | shortcuts: ['Shift', 'Tab'], 54 | icon: 'i-lucide-indent-decrease', 55 | tooltip: t('editor.outdent.tooltip'), 56 | }, 57 | }, 58 | ] 59 | }, 60 | } 61 | }, 62 | 63 | addGlobalAttributes() { 64 | return [ 65 | { 66 | types: this.options.types, 67 | attributes: { 68 | indent: { 69 | default: 0, 70 | parseHTML: element => { 71 | const identAttr = element.getAttribute('data-indent') 72 | return (identAttr ? parseInt(identAttr, 10) : 0) || 0 73 | }, 74 | renderHTML: attributes => { 75 | if (!attributes.indent) { 76 | return {} 77 | } 78 | return { ['data-indent']: attributes.indent } 79 | }, 80 | }, 81 | }, 82 | }, 83 | ] 84 | }, 85 | 86 | addCommands() { 87 | return { 88 | indent: () => 89 | createIndentCommand({ 90 | delta: IndentProps.more, 91 | types: this.options.types, 92 | }), 93 | outdent: () => 94 | createIndentCommand({ 95 | delta: IndentProps.less, 96 | types: this.options.types, 97 | }), 98 | } 99 | }, 100 | 101 | addKeyboardShortcuts() { 102 | return { 103 | Tab: () => this.editor.commands.indent(), 104 | 'Shift-Tab': () => this.editor.commands.outdent(), 105 | } 106 | }, 107 | }) 108 | -------------------------------------------------------------------------------- /app/extensions/Indent/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Indent' 2 | -------------------------------------------------------------------------------- /app/extensions/Italic/Italic.ts: -------------------------------------------------------------------------------- 1 | import type { Editor } from '@tiptap/core' 2 | import TiptapItalic, { type ItalicOptions as TiptapItalicOptions} from '@tiptap/extension-italic' 3 | import type { GeneralOptions } from '../../types' 4 | import ActionButton from '../../components/ActionButton.vue' 5 | 6 | export interface ItalicOptions extends TiptapItalicOptions, GeneralOptions {} 7 | 8 | export const Italic = TiptapItalic.extend({ 9 | addOptions() { 10 | return { 11 | ...this.parent?.(), 12 | button({ editor, t }: { editor: Editor; t: (...args: any[]) => string }) { 13 | return { 14 | component: ActionButton, 15 | componentProps: { 16 | action: () => editor.commands.toggleItalic(), 17 | isActive: () => editor.isActive('italic') || false, 18 | disabled: !editor.can().toggleItalic(), 19 | shortcuts: ['mod', 'I'], 20 | icon: 'i-lucide-italic', 21 | tooltip: t('editor.italic.tooltip'), 22 | }, 23 | } 24 | }, 25 | } 26 | }, 27 | }) 28 | -------------------------------------------------------------------------------- /app/extensions/Italic/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Italic' 2 | -------------------------------------------------------------------------------- /app/extensions/LineHeight/LineHeight.ts: -------------------------------------------------------------------------------- 1 | import { Extension, type Editor } from '@tiptap/core' 2 | import { createLineHeightCommand } from '../../utils/line-height' 3 | import LineHeightDropdown from './components/LineHeightDropdown.vue' 4 | import type { GeneralOptions } from '../../types' 5 | import { DEFAULT_LINE_HEIGHT } from '../../constants' 6 | 7 | export interface LineHeightOptions extends GeneralOptions { 8 | types: string[] 9 | lineHeights: string[] 10 | defaultHeight: string 11 | } 12 | 13 | declare module '@tiptap/core' { 14 | interface Commands { 15 | lineHeight: { 16 | setLineHeight: (lineHeight: string) => ReturnType 17 | unsetLineHeight: () => ReturnType 18 | } 19 | } 20 | } 21 | 22 | export const LineHeight = Extension.create({ 23 | name: 'lineHeight', 24 | addOptions() { 25 | return { 26 | ...this.parent?.(), 27 | types: ['paragraph', 'heading', 'list_item', 'todo_item'], 28 | lineHeights: ['100%', '115%', '150%', '200%', '250%', '300%'], 29 | defaultHeight: DEFAULT_LINE_HEIGHT, 30 | button({ editor, t }: { editor: Editor; t: any }) { 31 | return { 32 | component: LineHeightDropdown, 33 | componentProps: { 34 | editor, 35 | tooltip: t('editor.lineheight.tooltip'), 36 | }, 37 | } 38 | }, 39 | } 40 | }, 41 | 42 | addGlobalAttributes() { 43 | return [ 44 | { 45 | types: this.options.types, 46 | attributes: { 47 | lineHeight: { 48 | default: null, 49 | parseHTML: element => { 50 | return element.style.lineHeight || this.options.defaultHeight 51 | }, 52 | renderHTML: attributes => { 53 | if (attributes.lineHeight === this.options.defaultHeight || !attributes.lineHeight) { 54 | return {} 55 | } 56 | return { style: `line-height: ${attributes.lineHeight}` } 57 | }, 58 | }, 59 | }, 60 | }, 61 | ] 62 | }, 63 | 64 | addCommands() { 65 | return { 66 | setLineHeight: lineHeight => createLineHeightCommand(lineHeight), 67 | unsetLineHeight: 68 | () => 69 | ({ commands }) => { 70 | return this.options.types.every(type => commands.resetAttributes(type, 'lineHeight')) 71 | }, 72 | } 73 | }, 74 | }) 75 | -------------------------------------------------------------------------------- /app/extensions/LineHeight/components/LineHeightDropdown.vue: -------------------------------------------------------------------------------- 1 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 77 | 78 | 79 | 80 | 81 | -------------------------------------------------------------------------------- /app/extensions/LineHeight/index.ts: -------------------------------------------------------------------------------- 1 | export * from './LineHeight' 2 | -------------------------------------------------------------------------------- /app/extensions/Link/Link.ts: -------------------------------------------------------------------------------- 1 | import { Link as TiptapLink, type LinkOptions as TiptapLinkOptions } from '@tiptap/extension-link' 2 | import { mergeAttributes } from '@tiptap/core' 3 | import { Plugin, TextSelection } from '@tiptap/pm/state' 4 | import { EditorView } from '@tiptap/pm/view' 5 | import { getMarkRange } from '@tiptap/core' 6 | import LinkEditPopover from './components/LinkEditPopover.vue' 7 | import type { GeneralOptions } from '../../types' 8 | 9 | export interface LinkOptions extends TiptapLinkOptions, GeneralOptions {} 10 | 11 | export const Link = TiptapLink.extend({ 12 | inclusive: false, 13 | parseHTML() { 14 | return [ 15 | { 16 | tag: 'a[href]:not([data-type="button"]):not([href *= "javascript:" i])', 17 | }, 18 | ] 19 | }, 20 | renderHTML({ HTMLAttributes }) { 21 | return [ 22 | 'a', 23 | mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { 24 | class: 'link', 25 | }), 26 | 0, 27 | ] 28 | }, 29 | 30 | addOptions() { 31 | return { 32 | ...this.parent?.(), 33 | openOnClick: true, 34 | button: ({ editor, t }) => { 35 | return { 36 | component: LinkEditPopover, 37 | componentProps: { 38 | action: value => { 39 | const { link, text, openInNewTab } = value as any 40 | editor 41 | .chain() 42 | .extendMarkRange('link') 43 | .insertContent({ 44 | type: 'text', 45 | text: text, 46 | marks: [ 47 | { 48 | type: 'link', 49 | attrs: { 50 | href: link, 51 | target: openInNewTab ? '_blank' : '', 52 | }, 53 | }, 54 | ], 55 | }) 56 | .setLink({ href: link }) 57 | .focus() 58 | .run() 59 | }, 60 | id: 'link', 61 | isActive: () => editor.isActive('link') || false, 62 | disabled: !editor.can().setLink({ href: '' }), 63 | icon: 'i-lucide-link', 64 | tooltip: t('editor.link.tooltip'), 65 | }, 66 | } 67 | }, 68 | } 69 | }, 70 | 71 | addProseMirrorPlugins() { 72 | return [ 73 | new Plugin({ 74 | props: { 75 | handleClick: (view: EditorView, pos: number) => { 76 | const { schema, doc, tr } = view.state 77 | const range = getMarkRange(doc.resolve(pos), schema.marks.link) 78 | if (!range) return false 79 | const $start = doc.resolve(range.from) 80 | const $end = doc.resolve(range.to) 81 | const transaction = tr.setSelection(new TextSelection($start, $end)) 82 | view.dispatch(transaction) 83 | }, 84 | }, 85 | }), 86 | ] 87 | }, 88 | }) 89 | -------------------------------------------------------------------------------- /app/extensions/Link/components/LinkEditBlock.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /app/extensions/Link/components/LinkEditPopover.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /app/extensions/Link/components/LinkViewBlock.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 27 | 30 | 31 | {{ 32 | truncate(link, { 33 | length: 50, 34 | omission: '…', 35 | }) 36 | }} 37 | 38 | 39 | 40 | 46 | 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /app/extensions/Link/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Link' 2 | -------------------------------------------------------------------------------- /app/extensions/ListItem/ListItem.ts: -------------------------------------------------------------------------------- 1 | import TiptapListItem from '@tiptap/extension-list-item' 2 | 3 | export default TiptapListItem 4 | -------------------------------------------------------------------------------- /app/extensions/ListItem/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ListItem' 2 | -------------------------------------------------------------------------------- /app/extensions/Math/Math.ts: -------------------------------------------------------------------------------- 1 | import { Extension } from '@tiptap/core' 2 | import { InlineMathNode } from './inline-math-node' 3 | import { DEFAULT_OPTIONS, type MathOptions } from './util/options' 4 | 5 | export const Math = Extension.create({ 6 | name: 'math', 7 | 8 | addOptions() { 9 | return DEFAULT_OPTIONS; 10 | }, 11 | 12 | addExtensions() { 13 | const extensions = []; 14 | if (this.options.addInlineMath !== false) { 15 | extensions.push(InlineMathNode.configure(this.options)); 16 | } 17 | 18 | return extensions; 19 | }, 20 | }); 21 | 22 | export { InlineMathNode, DEFAULT_OPTIONS }; 23 | export type { MathOptions }; 24 | -------------------------------------------------------------------------------- /app/extensions/Math/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Math' 2 | -------------------------------------------------------------------------------- /app/extensions/Math/latex-evaluation/update-evaluation.ts: -------------------------------------------------------------------------------- 1 | import { type VariableUpdateListeners, evaluateExpression } from './evaluate-expression' 2 | 3 | export function updateEvaluation(latex: string, id: string, resultSpan: HTMLSpanElement, showEvalResult: boolean, editorStorage: any) { 4 | let evalRes = evaluateExpression( 5 | latex, 6 | editorStorage.variables, 7 | editorStorage.variableListeners 8 | ); // Do not show if error occurs (in general, we probably want to make showing the result optional) 9 | const updateResultSpan = () => { 10 | if (evalRes?.result) { 11 | if (evalRes.result.toString().split(".")[1]?.length > 5) { 12 | resultSpan.innerText = "=" + evalRes.result.toFixed(4); 13 | } else { 14 | resultSpan.innerText = "=" + evalRes.result.toString(); 15 | } 16 | } else { 17 | resultSpan.innerText = "=Error"; 18 | } 19 | 20 | if (!showEvalResult) { 21 | resultSpan.style.display = "none"; 22 | } else { 23 | resultSpan.style.display = "inline-block"; 24 | } 25 | }; 26 | updateResultSpan(); 27 | if (evalRes?.variablesUsed) { 28 | for (const v of evalRes.variablesUsed) { 29 | // Register Listeners 30 | let listenersForV: VariableUpdateListeners = editorStorage.variableListeners[v]; 31 | if (listenersForV == undefined) { 32 | listenersForV = []; 33 | } 34 | listenersForV.push({ 35 | id: id, 36 | onUpdate: () => { 37 | { 38 | evalRes = evaluateExpression( 39 | latex, 40 | editorStorage.variables, 41 | editorStorage.variableListeners 42 | ); 43 | updateResultSpan(); 44 | } 45 | }, 46 | }); 47 | editorStorage.variableListeners[v] = listenersForV; 48 | } 49 | } 50 | return evalRes; 51 | } 52 | -------------------------------------------------------------------------------- /app/extensions/Math/util/generate-id.ts: -------------------------------------------------------------------------------- 1 | // import { v4 } from "uuid"; 2 | 3 | // This is not a secure/unpredictable ID, but this is simple and good enough for our case 4 | export function generateID() { 5 | // Note, that E is not included on purpose (to prevent any confusion with eulers number) 6 | const ALL_ALLOWED_CHARS_UPPER = [ 7 | 'A', 8 | 'B', 9 | 'C', 10 | 'D', 11 | 'F', 12 | 'G', 13 | 'H', 14 | 'I', 15 | 'J', 16 | 'K', 17 | 'L', 18 | 'M', 19 | 'N', 20 | 'O', 21 | 'P', 22 | 'Q', 23 | 'R', 24 | 'S', 25 | 'T', 26 | 'U', 27 | 'V', 28 | 'W', 29 | 'X', 30 | 'Y', 31 | 'Z', 32 | ] 33 | const RAND_ID_LEN = 36 34 | let id = '' 35 | for (let i = 1; i <= RAND_ID_LEN; i++) { 36 | const c = ALL_ALLOWED_CHARS_UPPER[Math.floor(Math.random() * ALL_ALLOWED_CHARS_UPPER.length)] 37 | if (Math.random() > 0.5) { 38 | id += c.toLowerCase() 39 | } 40 | else { 41 | id += c 42 | } 43 | } 44 | return id 45 | // Alternative: use uuidv4 46 | // return v4() 47 | } 48 | -------------------------------------------------------------------------------- /app/extensions/Math/util/options.ts: -------------------------------------------------------------------------------- 1 | import type { KatexOptions } from 'katex' 2 | 3 | export interface MathOptions { 4 | /** Evaluate LaTeX expressions */ 5 | evaluation: boolean 6 | /** Add InlineMath node type (currently required as inline is the only supported mode) */ 7 | addInlineMath: boolean 8 | /** KaTeX options to use for evaluation, see also https://katex.org/docs/options.html */ 9 | katexOptions?: KatexOptions 10 | } 11 | export const DEFAULT_OPTIONS = { addInlineMath: true, evaluation: false } 12 | -------------------------------------------------------------------------------- /app/extensions/MoreMark/MoreMark.ts: -------------------------------------------------------------------------------- 1 | import { Extension, type Extensions } from '@tiptap/core' 2 | import { Subscript as TiptapSubscript, type SubscriptExtensionOptions as TiptapSubscriptOptions } from '@tiptap/extension-subscript' 3 | import { Superscript as TiptapSuperscript, type SuperscriptExtensionOptions as TiptapSuperscriptOptions } from '@tiptap/extension-superscript' 4 | import type { Item } from './components/ActionMoreButton.vue' 5 | import ActionMoreButton from './components/ActionMoreButton.vue' 6 | import type { GeneralOptions } from '../../types' 7 | import { hasExtension } from '../../utils' 8 | 9 | export interface MoreMarkOptions extends GeneralOptions { 10 | /** 11 | * // Subscript 12 | * 13 | * @default true 14 | */ 15 | subscript: Partial | false 16 | /** 17 | * // Superscript 18 | * 19 | * @default true 20 | */ 21 | superscript: Partial | false 22 | } 23 | 24 | export const MoreMark = Extension.create({ 25 | name: 'moreMark', 26 | addOptions() { 27 | return { 28 | ...this.parent?.(), 29 | button({ editor, extension, t }) { 30 | const subscript = extension.options.subscript 31 | const superscript = extension.options.superscript 32 | const subBtn: Item = { 33 | action: () => editor.commands.toggleSubscript(), 34 | isActive: () => editor.isActive('subscript') || false, 35 | disabled: !editor.can().toggleSubscript(), 36 | icon: 'i-lucide-subscript', 37 | title: t('editor.subscript.tooltip'), 38 | shortcuts: ['mod', '.'], 39 | } 40 | 41 | const superBtn: Item = { 42 | action: () => editor.commands.toggleSuperscript(), 43 | isActive: () => editor.isActive('superscript') || false, 44 | disabled: !editor.can().toggleSuperscript(), 45 | icon: 'i-lucide-superscript', 46 | title: t('editor.superscript.tooltip'), 47 | shortcuts: ['mod', ','], 48 | } 49 | const hasCode = hasExtension(editor, 'code') 50 | 51 | const items: Item[] = [] 52 | 53 | if (subscript !== false) items.push(subBtn) 54 | if (superscript !== false) items.push(superBtn) 55 | if (hasCode) { 56 | const codeBtn: Item = { 57 | action: () => editor.commands.toggleCode(), 58 | isActive: () => editor.isActive('code') || false, 59 | disabled: !editor.can().toggleCode(), 60 | icon: 'i-lucide-code', 61 | title: t('editor.code.tooltip'), 62 | shortcuts: ['mod', 'E'], 63 | } 64 | if (hasCode) items.push(codeBtn) 65 | } 66 | 67 | return { 68 | component: ActionMoreButton, 69 | componentProps: { 70 | icon: 'i-lucide-ellipsis-vertical', 71 | tooltip: t('editor.moremark'), 72 | disabled: false, 73 | items, 74 | }, 75 | } 76 | }, 77 | } 78 | }, 79 | 80 | addExtensions() { 81 | const extensions: Extensions = [] 82 | 83 | if (this.options.subscript !== false) { 84 | extensions.push(TiptapSubscript.configure(this.options.subscript)) 85 | } 86 | 87 | if (this.options.superscript !== false) { 88 | extensions.push(TiptapSuperscript.configure(this.options.superscript)) 89 | } 90 | 91 | return extensions 92 | }, 93 | }) 94 | -------------------------------------------------------------------------------- /app/extensions/MoreMark/components/ActionMoreButton.vue: -------------------------------------------------------------------------------- 1 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | -------------------------------------------------------------------------------- /app/extensions/MoreMark/index.ts: -------------------------------------------------------------------------------- 1 | export * from './MoreMark' 2 | -------------------------------------------------------------------------------- /app/extensions/MultiColumn/Column.ts: -------------------------------------------------------------------------------- 1 | import { Node, mergeAttributes } from '@tiptap/core' 2 | 3 | export const Column = Node.create({ 4 | name: 'column', 5 | content: 'block+', 6 | isolating: true, 7 | addAttributes() { 8 | return { 9 | position: { 10 | default: '', 11 | parseHTML: element => element.getAttribute('data-position'), 12 | renderHTML: attributes => ({ 'data-position': attributes.position }), 13 | }, 14 | } 15 | }, 16 | 17 | renderHTML({ HTMLAttributes }) { 18 | return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'column' }), 0] 19 | }, 20 | 21 | parseHTML() { 22 | return [ 23 | { 24 | tag: 'div[data-type="column"]', 25 | }, 26 | ] 27 | }, 28 | }) 29 | 30 | export default Column 31 | -------------------------------------------------------------------------------- /app/extensions/MultiColumn/Columns.ts: -------------------------------------------------------------------------------- 1 | import { Node } from '@tiptap/core' 2 | import { Column } from './Column' 3 | import type { GeneralOptions } from '../../types' 4 | 5 | export enum ColumnLayout { 6 | SidebarLeft = 'sidebar-left', 7 | SidebarRight = 'sidebar-right', 8 | TwoColumn = 'two-column', 9 | } 10 | 11 | declare module '@tiptap/core' { 12 | interface Commands { 13 | columns: { 14 | setColumns: () => ReturnType 15 | setLayout: (layout: ColumnLayout) => ReturnType 16 | } 17 | } 18 | } 19 | export interface ColumnsOptions extends GeneralOptions { 20 | columnOptions: any 21 | layout: ColumnLayout 22 | } 23 | 24 | export const Columns = Node.create({ 25 | name: 'columns', 26 | group: 'columns', 27 | content: 'column+', 28 | defining: true, 29 | isolating: true, 30 | addOptions() { 31 | return { 32 | ...this.parent?.(), 33 | layout: ColumnLayout.TwoColumn, 34 | } 35 | }, 36 | 37 | addAttributes() { 38 | return { 39 | layout: { 40 | default: ColumnLayout.TwoColumn, 41 | }, 42 | } 43 | }, 44 | addCommands() { 45 | return { 46 | setColumns: 47 | () => 48 | ({ commands }) => { 49 | commands.insertContent( 50 | `` 51 | ) 52 | return true 53 | }, 54 | 55 | setLayout: 56 | (layout: ColumnLayout) => 57 | ({ commands }) => 58 | commands.updateAttributes('columns', { layout }), 59 | } 60 | }, 61 | renderHTML({ HTMLAttributes }) { 62 | return ['div', { 'data-type': 'columns', class: `layout-${HTMLAttributes.layout}` }, 0] 63 | }, 64 | parseHTML() { 65 | return [ 66 | { 67 | tag: 'div[data-type="columns"]', 68 | }, 69 | ] 70 | }, 71 | addExtensions() { 72 | return [Column.configure(this.options.columnOptions)] 73 | }, 74 | }) 75 | 76 | export default Columns 77 | -------------------------------------------------------------------------------- /app/extensions/MultiColumn/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Columns' 2 | export * from './Column' 3 | -------------------------------------------------------------------------------- /app/extensions/MultiColumn/menus/ColumnsMenu.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 42 | 57 | 58 | 59 | 66 | 73 | 80 | 81 | 82 | 83 | 84 | -------------------------------------------------------------------------------- /app/extensions/OrderedList/OrderedList.ts: -------------------------------------------------------------------------------- 1 | import { OrderedList as TiptapOrderedList, type OrderedListOptions as TiptapOrderedListOptions } from '@tiptap/extension-ordered-list' 2 | import type { GeneralOptions } from '../../types' 3 | import ActionButton from '../../components/ActionButton.vue' 4 | 5 | export interface OrderedListOptions extends TiptapOrderedListOptions, GeneralOptions {} 6 | 7 | export const OrderedList = TiptapOrderedList.extend({ 8 | addOptions() { 9 | return { 10 | ...this.parent?.(), 11 | button: ({ editor, t }) => ({ 12 | component: ActionButton, 13 | componentProps: { 14 | action: () => editor.commands.toggleOrderedList(), 15 | isActive: () => editor.isActive('orderedList') || false, 16 | disabled: !editor.can().toggleOrderedList(), 17 | icon: 'i-lucide-list-ordered', 18 | shortcuts: ['mod', 'shift', '7'], 19 | tooltip: t('editor.orderedlist.tooltip'), 20 | }, 21 | }), 22 | } 23 | }, 24 | }) 25 | -------------------------------------------------------------------------------- /app/extensions/OrderedList/index.ts: -------------------------------------------------------------------------------- 1 | export * from './OrderedList' 2 | -------------------------------------------------------------------------------- /app/extensions/Paper/Paper.ts: -------------------------------------------------------------------------------- 1 | import { mergeAttributes, Node, VueNodeViewRenderer } from '@tiptap/vue-3' 2 | import ActionButton from '../../components/ActionButton.vue' 3 | import PaperComponent from './components/Paper' 4 | 5 | export const Paper = Node.create({ 6 | name: 'paper', 7 | 8 | group: 'block', 9 | 10 | atom: true, 11 | 12 | addOptions() { 13 | return { 14 | ...this.parent?.(), 15 | button: ({ editor, t }) => ({ 16 | component: ActionButton, 17 | componentProps: { 18 | action: () => editor.commands.setPaper(), 19 | isActive: () => editor.isActive('paper') || false, 20 | disabled: !editor.can().setPaper(), 21 | icon: 'i-lucide-brush', 22 | tooltip: t('editor.paper.tooltip'), 23 | } 24 | }) 25 | } 26 | }, 27 | 28 | addAttributes() { 29 | return { 30 | lines: { 31 | default: [], 32 | }, 33 | } 34 | }, 35 | 36 | parseHTML() { 37 | return [ 38 | { 39 | tag: 'div[data-type="paper"]', 40 | }, 41 | ] 42 | }, 43 | 44 | renderHTML({ HTMLAttributes }) { 45 | return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'paper' })] 46 | }, 47 | 48 | addCommands() { 49 | return { 50 | setPaper: (lines) => ({ tr, commands }) => { 51 | return commands.insertContent({ 52 | type: this.name, 53 | attrs: { lines }, 54 | }) 55 | }, 56 | } 57 | }, 58 | 59 | addNodeView() { 60 | return VueNodeViewRenderer(PaperComponent) 61 | }, 62 | }) 63 | -------------------------------------------------------------------------------- /app/extensions/Paper/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Paper' 2 | -------------------------------------------------------------------------------- /app/extensions/Print/Print.ts: -------------------------------------------------------------------------------- 1 | import { Extension } from '@tiptap/core' 2 | import { printEditorContent } from '../../utils/print' 3 | import ActionButton from '../../components/ActionButton.vue' 4 | 5 | declare module '@tiptap/core' { 6 | interface Commands { 7 | print: { 8 | print: () => ReturnType 9 | } 10 | } 11 | } 12 | 13 | export const Print = Extension.create({ 14 | name: 'print', 15 | 16 | addOptions() { 17 | return { 18 | ...this.parent?.(), 19 | button: ({ editor, t }) => ({ 20 | component: ActionButton, 21 | componentProps: { 22 | action: () => editor.commands.print(), 23 | icon: 'i-lucide-printer', 24 | shortcuts: ['mod', 'P'], 25 | tooltip: t('editor.print.tooltip'), 26 | } 27 | }) 28 | } 29 | }, 30 | 31 | addCommands() { 32 | return { 33 | print: () => ({ view }) => printEditorContent(view) 34 | } 35 | }, 36 | 37 | addKeyboardShortcuts() { 38 | return { 39 | 'Mod-p': () => this.editor.commands.print() 40 | } 41 | } 42 | }) 43 | -------------------------------------------------------------------------------- /app/extensions/Print/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Print' 2 | -------------------------------------------------------------------------------- /app/extensions/SearchAndReplace/components/SearchAndReplacePopover.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /app/extensions/SearchAndReplace/index.ts: -------------------------------------------------------------------------------- 1 | export * from './SearchAndReplace' 2 | -------------------------------------------------------------------------------- /app/extensions/Selection/Selection.ts: -------------------------------------------------------------------------------- 1 | import { Extension } from '@tiptap/core' 2 | import { Plugin, PluginKey } from '@tiptap/pm/state' 3 | import { Decoration, DecorationSet } from '@tiptap/pm/view' 4 | 5 | export const Selection = Extension.create({ 6 | name: 'selection', 7 | addProseMirrorPlugins() { 8 | const { editor } = this 9 | return [ 10 | new Plugin({ 11 | key: new PluginKey('selection'), 12 | props: { 13 | decorations(state) { 14 | if (state.selection.empty) { 15 | return null 16 | } 17 | if (editor.isFocused === true) { 18 | return null 19 | } 20 | return DecorationSet.create(state.doc, [ 21 | Decoration.inline(state.selection.from, state.selection.to, { 22 | class: 'selection', 23 | }), 24 | ]) 25 | }, 26 | }, 27 | }), 28 | ] 29 | }, 30 | }) 31 | 32 | export default Selection 33 | -------------------------------------------------------------------------------- /app/extensions/Selection/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Selection' 2 | -------------------------------------------------------------------------------- /app/extensions/SlashCommand/index.ts: -------------------------------------------------------------------------------- 1 | export * from './SlashCommand' 2 | -------------------------------------------------------------------------------- /app/extensions/SlashCommand/types.ts: -------------------------------------------------------------------------------- 1 | import type { Editor, Range } from '@tiptap/core' 2 | 3 | export interface Group { 4 | name: string 5 | title: string 6 | commands: Command[] 7 | } 8 | 9 | export interface Command { 10 | name: string 11 | label: string 12 | description?: string 13 | aliases?: string[] 14 | iconName?: string 15 | iconUrl?: string 16 | action: ({ editor, range }: { editor: Editor, range: Range }) => void 17 | shouldBeHidden?: (editor: Editor) => boolean 18 | } 19 | 20 | export interface MenuListProps { 21 | editor: Editor 22 | items: Group[] 23 | command: (command: Command) => void 24 | } 25 | -------------------------------------------------------------------------------- /app/extensions/SpeechRecognition/index.ts: -------------------------------------------------------------------------------- 1 | export * from './SpeechRecognition' 2 | -------------------------------------------------------------------------------- /app/extensions/SpeechSynthesis/SpeechSynthesis.ts: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue' 2 | import { Node } from '@tiptap/core' 3 | import ActionButton from '../../components/ActionButton.vue' 4 | 5 | export interface SpeechSynthesisOptions { 6 | lang: string 7 | pitch: number 8 | voice?: string 9 | rate?: number 10 | } 11 | 12 | const synthesisStore = { 13 | utterance: null as SpeechSynthesisUtterance | null, 14 | isSpeaking: ref(false), 15 | } 16 | 17 | declare module '@tiptap/core' { 18 | interface Commands { 19 | speechSynthesis: { 20 | startSpeechSynthesis: () => ReturnType 21 | stopSpeechSynthesis: () => ReturnType 22 | isSpeechSynthesisStarted: () => boolean 23 | } 24 | } 25 | } 26 | 27 | export const SpeechSynthesis = Node.create({ 28 | name: 'speechSynthesis', 29 | 30 | addOptions() { 31 | return { 32 | lang: 'fr-FR', 33 | pitch: 1, 34 | rate: 1, 35 | voice: 'Google français', 36 | button: ({ editor }) => ({ 37 | component: ActionButton, 38 | componentProps: { 39 | action: () => { 40 | if (synthesisStore.isSpeaking.value) { 41 | editor.commands.stopSpeechSynthesis() 42 | } else { 43 | editor.commands.startSpeechSynthesis() 44 | } 45 | }, 46 | isActive: () => synthesisStore.isSpeaking.value, 47 | disabled: false, 48 | icon: () => 49 | synthesisStore.isSpeaking.value 50 | ? 'i-lucide-pause' 51 | : 'i-lucide-audio-lines', 52 | tooltip: 'editor.speechSynthesis.tooltip', 53 | update: () => synthesisStore.isSpeaking.value, 54 | }, 55 | }), 56 | } 57 | }, 58 | 59 | addCommands() { 60 | return { 61 | startSpeechSynthesis: 62 | () => 63 | ({ commands, editor }) => { 64 | const { from, to, empty } = editor.state.selection 65 | if (empty) return null 66 | 67 | const text = editor.state.doc.textBetween(from, to, ' ') 68 | if (!text.trim()) return null 69 | 70 | synthesisStore.utterance = new SpeechSynthesisUtterance() 71 | synthesisStore.utterance.text = text 72 | synthesisStore.utterance.lang = this.options.lang 73 | synthesisStore.utterance.pitch = this.options.pitch 74 | synthesisStore.utterance.rate = this.options.rate 75 | 76 | if (this.options.voice) { 77 | const voices = window.speechSynthesis.getVoices() 78 | const match = voices.find(v => v.voiceURI === this.options.voice) 79 | if (match) synthesisStore.utterance.voice = match 80 | } 81 | 82 | synthesisStore.utterance.onstart = () => { 83 | synthesisStore.isSpeaking.value = true 84 | } 85 | 86 | synthesisStore.utterance.onend = () => { 87 | synthesisStore.isSpeaking.value = false 88 | } 89 | 90 | synthesisStore.utterance.onerror = () => { 91 | synthesisStore.isSpeaking.value = false 92 | } 93 | 94 | window.speechSynthesis.speak(synthesisStore.utterance) 95 | 96 | return commands 97 | }, 98 | 99 | stopSpeechSynthesis: 100 | () => 101 | ({ commands }) => { 102 | window.speechSynthesis.cancel() 103 | synthesisStore.isSpeaking.value = false 104 | return commands 105 | }, 106 | 107 | isSpeechSynthesisStarted: () => () => synthesisStore.isSpeaking.value, 108 | } 109 | }, 110 | }) 111 | -------------------------------------------------------------------------------- /app/extensions/SpeechSynthesis/index.ts: -------------------------------------------------------------------------------- 1 | export * from './SpeechSynthesis' 2 | -------------------------------------------------------------------------------- /app/extensions/Strike/Strike.ts: -------------------------------------------------------------------------------- 1 | import { Strike as TiptapStrike, type StrikeOptions as TiptapStrikeOptions } from '@tiptap/extension-strike' 2 | import ActionButton from '../../components/ActionButton.vue' 3 | import type { GeneralOptions } from '../../types' 4 | 5 | export interface StrikeOptions extends TiptapStrikeOptions, GeneralOptions {} 6 | 7 | export const Strike = TiptapStrike.extend({ 8 | addOptions() { 9 | return { 10 | ...this.parent?.(), 11 | button: ({ editor, t }) => ({ 12 | component: ActionButton, 13 | componentProps: { 14 | action: () => editor.commands.toggleStrike(), 15 | isActive: () => editor.isActive('strike') || false, 16 | disabled: !editor.can().toggleStrike(), 17 | icon: 'i-lucide-strikethrough', 18 | shortcuts: ['shift', 'mod', 'X'], 19 | tooltip: t('editor.strike.tooltip'), 20 | }, 21 | }), 22 | } 23 | }, 24 | }) 25 | -------------------------------------------------------------------------------- /app/extensions/Strike/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Strike' 2 | -------------------------------------------------------------------------------- /app/extensions/Subscript/Subscript.ts: -------------------------------------------------------------------------------- 1 | import { Extension, type Extensions } from '@tiptap/core' 2 | import { Subscript as TiptapSubscript, type SubscriptExtensionOptions as TiptapSubscriptOptions } from '@tiptap/extension-subscript' 3 | import { Superscript as TiptapSuperscript, type SuperscriptExtensionOptions as TiptapSuperscriptOptions } from '@tiptap/extension-superscript' 4 | 5 | import ActionButton from '../../components/ActionButton.vue' 6 | 7 | import type { ButtonViewReturn, GeneralOptions } from '../../types' 8 | 9 | /** 10 | * Represents the interface for subscript and superscript options, extending GeneralOptions. 11 | */ 12 | export interface SubAndSuperScriptOptions extends GeneralOptions { 13 | /** 14 | * subscript options or false, indicating whether subscript is enabled 15 | * 16 | * @default true 17 | */ 18 | subscript: Partial | false 19 | /** 20 | * superscript options or false, indicating whether superscript is enabled 21 | * 22 | * @default true 23 | */ 24 | superscript: Partial | false 25 | } 26 | 27 | export const SubAndSuperScript = Extension.create({ 28 | name: 'subAndSuperScript', 29 | 30 | addOptions() { 31 | return { 32 | ...this.parent?.(), 33 | button: ({ editor, extension, t }) => { 34 | const subscript = extension.options.subscript 35 | const superscript = extension.options.superscript 36 | 37 | const subBtn: ButtonViewReturn = { 38 | component: ActionButton, 39 | componentProps: { 40 | action: () => editor.commands.toggleSubscript(), 41 | isActive: () => editor.isActive('subscript') || false, 42 | disabled: !editor.can().toggleSubscript(), 43 | icon: 'i-lucide-subscript', 44 | tooltip: t('editor.subscript.tooltip'), 45 | }, 46 | } 47 | 48 | const superBtn: ButtonViewReturn = { 49 | component: ActionButton, 50 | componentProps: { 51 | action: () => editor.commands.toggleSuperscript(), 52 | isActive: () => editor.isActive('superscript') || false, 53 | disabled: !editor.can().toggleSuperscript(), 54 | icon: 'i-lucide-superscript', 55 | tooltip: t('editor.superscript.tooltip'), 56 | }, 57 | } 58 | 59 | const items: ButtonViewReturn[] = [] 60 | 61 | if (subscript !== false) items.push(subBtn) 62 | if (superscript !== false) items.push(superBtn) 63 | 64 | return items 65 | }, 66 | } 67 | }, 68 | 69 | addExtensions() { 70 | const extensions: Extensions = [] 71 | 72 | if (this.options.subscript !== false) { 73 | extensions.push(TiptapSubscript.configure(this.options.subscript)) 74 | } 75 | 76 | if (this.options.superscript !== false) { 77 | extensions.push(TiptapSuperscript.configure(this.options.superscript)) 78 | } 79 | 80 | return extensions 81 | }, 82 | }) 83 | -------------------------------------------------------------------------------- /app/extensions/Subscript/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Subscript' 2 | -------------------------------------------------------------------------------- /app/extensions/Table/cell-background.ts: -------------------------------------------------------------------------------- 1 | import { type Command, Extension } from '@tiptap/core' 2 | import { Transaction } from '@tiptap/pm/state' 3 | import { CellSelection } from '@tiptap/pm/tables' 4 | 5 | export type TableCellBackgroundOptions = { 6 | HTMLAttributes: Record 7 | types?: any 8 | } 9 | 10 | declare module '@tiptap/core' { 11 | interface Commands { 12 | tableCellBackground: { 13 | setTableCellBackground: (color: string) => ReturnType 14 | unsetTableCellBackground: () => ReturnType 15 | } 16 | } 17 | } 18 | 19 | export const setCellBackgroundMarkup = (tr: Transaction, pos: number, backgroundColor: string): Transaction => { 20 | if (!tr.doc) { 21 | return tr 22 | } 23 | 24 | const node = tr.doc.nodeAt(pos) 25 | if (!node) { 26 | return tr 27 | } 28 | 29 | if (backgroundColor === node.attrs.backgroundColor) { 30 | return tr 31 | } 32 | 33 | const nodeAttrs = { 34 | ...node.attrs, 35 | backgroundColor, 36 | } 37 | 38 | return tr.setNodeMarkup(pos, node.type, nodeAttrs, node.marks) 39 | } 40 | 41 | export const updateCellBackground = ( 42 | tr: Transaction, 43 | options: TableCellBackgroundOptions, 44 | backgroundColor: string 45 | ): Transaction => { 46 | const { doc, selection } = tr 47 | 48 | if (!doc || !selection || !(selection instanceof CellSelection)) { 49 | return tr 50 | } 51 | 52 | selection.forEachCell((node, pos) => { 53 | tr = setCellBackgroundMarkup(tr, pos, backgroundColor) 54 | }) 55 | 56 | return tr 57 | } 58 | 59 | export const createCellBackgroundCommand = (backgroundColor: string, options: TableCellBackgroundOptions): Command => { 60 | return ({ tr, state, dispatch }) => { 61 | const { selection } = state 62 | tr = tr.setSelection(selection) 63 | tr = updateCellBackground(tr, options, backgroundColor) 64 | 65 | if (tr.docChanged) { 66 | dispatch?.(tr) 67 | return true 68 | } 69 | 70 | return false 71 | } 72 | } 73 | 74 | // @ts-ignore 75 | export const TableCellBackground = Extension.create({ 76 | name: 'tableCellBackground', 77 | addOptions() { 78 | return { 79 | types: ['tableCell'], 80 | HTMLAttributes: {}, 81 | } 82 | }, 83 | 84 | addGlobalAttributes() { 85 | return [ 86 | { 87 | types: this.options.types, 88 | attributes: { 89 | backgroundColor: { 90 | parseHTML: element => { 91 | return element.style.backgroundColor || '' 92 | }, 93 | renderHTML: attributes => { 94 | if (!attributes.backgroundColor || attributes.backgroundColor === '') { 95 | return {} 96 | } else { 97 | return { 98 | style: `background-color: ${attributes.backgroundColor}`, 99 | } 100 | } 101 | }, 102 | }, 103 | }, 104 | }, 105 | ] 106 | }, 107 | addCommands() { 108 | return { 109 | setTableCellBackground: (backgroundColor: string) => createCellBackgroundCommand(backgroundColor, this.options), 110 | unsetTableCellBackground: () => createCellBackgroundCommand('', this.options), 111 | } 112 | }, 113 | }) 114 | -------------------------------------------------------------------------------- /app/extensions/Table/cell.ts: -------------------------------------------------------------------------------- 1 | import { mergeAttributes, Node } from '@tiptap/core' 2 | import { Plugin } from '@tiptap/pm/state' 3 | import { Decoration, DecorationSet } from '@tiptap/pm/view' 4 | import { getCellsInColumn, isRowSelected, selectRow } from './utils' 5 | 6 | export interface TableCellOptions { 7 | HTMLAttributes: Record 8 | } 9 | 10 | export const TableCell = Node.create({ 11 | name: 'tableCell', 12 | content: 'block+', // TODO: Do not allow table in table 13 | tableRole: 'cell', 14 | isolating: true, 15 | addOptions() { 16 | return { 17 | HTMLAttributes: {}, 18 | } 19 | }, 20 | 21 | parseHTML() { 22 | return [{ tag: 'td' }] 23 | }, 24 | 25 | renderHTML({ HTMLAttributes }) { 26 | return ['td', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0] 27 | }, 28 | 29 | addAttributes() { 30 | return { 31 | colspan: { 32 | default: 1, 33 | parseHTML: element => { 34 | const colspan = element.getAttribute('colspan') 35 | const value = colspan ? parseInt(colspan, 10) : 1 36 | 37 | return value 38 | }, 39 | }, 40 | rowspan: { 41 | default: 1, 42 | parseHTML: element => { 43 | const rowspan = element.getAttribute('rowspan') 44 | const value = rowspan ? parseInt(rowspan, 10) : 1 45 | 46 | return value 47 | }, 48 | }, 49 | colwidth: { 50 | default: null, 51 | parseHTML: element => { 52 | const colwidth = element.getAttribute('colwidth') 53 | const value = colwidth ? [parseInt(colwidth, 10)] : null 54 | 55 | return value 56 | }, 57 | }, 58 | style: { 59 | default: null, 60 | }, 61 | } 62 | }, 63 | 64 | addProseMirrorPlugins() { 65 | const { isEditable } = this.editor 66 | 67 | return [ 68 | new Plugin({ 69 | props: { 70 | decorations: state => { 71 | if (!isEditable) { 72 | return DecorationSet.empty 73 | } 74 | 75 | const { doc, selection } = state 76 | const decorations: Decoration[] = [] 77 | const cells = getCellsInColumn(0)(selection) 78 | 79 | if (cells) { 80 | cells.forEach(({ pos }: { pos: number }, index: number) => { 81 | decorations.push( 82 | Decoration.widget(pos + 1, () => { 83 | const rowSelected = isRowSelected(index)(selection) 84 | let className = 'grip-row' 85 | 86 | if (rowSelected) { 87 | className += ' selected' 88 | } 89 | 90 | if (index === 0) { 91 | className += ' first' 92 | } 93 | 94 | if (index === cells.length - 1) { 95 | className += ' last' 96 | } 97 | 98 | const grip = document.createElement('a') 99 | 100 | grip.className = className 101 | grip.addEventListener('mousedown', event => { 102 | event.preventDefault() 103 | event.stopImmediatePropagation() 104 | 105 | this.editor.view.dispatch(selectRow(index)(this.editor.state.tr)) 106 | }) 107 | 108 | return grip 109 | }) 110 | ) 111 | }) 112 | } 113 | 114 | return DecorationSet.create(doc, decorations) 115 | }, 116 | }, 117 | }), 118 | ] 119 | }, 120 | }) 121 | -------------------------------------------------------------------------------- /app/extensions/Table/components/CreateTablePopover.vue: -------------------------------------------------------------------------------- 1 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 86 | 87 | 88 | 89 | 90 | 91 | {{ selectedTableGridSize.rows }} x {{ selectedTableGridSize.cols }} 92 | 93 | 94 | 95 | 96 | 97 | -------------------------------------------------------------------------------- /app/extensions/Table/components/TableActionButton.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 39 | 40 | 41 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /app/extensions/Table/header.ts: -------------------------------------------------------------------------------- 1 | import TiptapTableHeader from '@tiptap/extension-table-header' 2 | import { Plugin } from '@tiptap/pm/state' 3 | import { Decoration, DecorationSet } from '@tiptap/pm/view' 4 | 5 | import { getCellsInRow, isColumnSelected, selectColumn } from './utils' 6 | 7 | export type TableHeaderOptions = { 8 | HTMLAttributes: Record 9 | } 10 | export const TableHeader = TiptapTableHeader.extend({ 11 | addAttributes() { 12 | return { 13 | HTMLAttributes: {}, 14 | colspan: { 15 | default: 1, 16 | }, 17 | rowspan: { 18 | default: 1, 19 | }, 20 | colwidth: { 21 | default: null, 22 | parseHTML: element => { 23 | const colwidth = element.getAttribute('colwidth') 24 | const value = colwidth ? colwidth.split(',').map(item => parseInt(item, 10)) : null 25 | 26 | return value 27 | }, 28 | }, 29 | style: { 30 | default: null, 31 | }, 32 | } 33 | }, 34 | 35 | addProseMirrorPlugins() { 36 | const { isEditable } = this.editor 37 | 38 | return [ 39 | new Plugin({ 40 | props: { 41 | decorations: state => { 42 | if (!isEditable) { 43 | return DecorationSet.empty 44 | } 45 | 46 | const { doc, selection } = state 47 | const decorations: Decoration[] = [] 48 | const cells = getCellsInRow(0)(selection) 49 | if (cells) { 50 | cells.forEach(({ pos }: { pos: number }, index: number) => { 51 | decorations.push( 52 | Decoration.widget(pos + 1, () => { 53 | const colSelected = isColumnSelected(index)(selection) 54 | let className = 'grip-column' 55 | 56 | if (colSelected) { 57 | className += ' selected' 58 | } 59 | 60 | if (index === 0) { 61 | className += ' first' 62 | } 63 | 64 | if (index === cells.length - 1) { 65 | className += ' last' 66 | } 67 | 68 | const grip = document.createElement('a') 69 | 70 | grip.className = className 71 | grip.addEventListener('mousedown', event => { 72 | event.preventDefault() 73 | event.stopImmediatePropagation() 74 | 75 | this.editor.view.dispatch(selectColumn(index)(this.editor.state.tr)) 76 | }) 77 | 78 | return grip 79 | }) 80 | ) 81 | }) 82 | } 83 | 84 | return DecorationSet.create(doc, decorations) 85 | }, 86 | }, 87 | }), 88 | ] 89 | }, 90 | }) 91 | 92 | export default TableHeader 93 | -------------------------------------------------------------------------------- /app/extensions/Table/index.ts: -------------------------------------------------------------------------------- 1 | import { TableCellBackground } from './cell-background' 2 | import type { TableCellBackgroundOptions } from './cell-background' 3 | 4 | import { TableHeader } from './header' 5 | import type { TableHeaderOptions } from './header' 6 | 7 | import { TableRow } from './row' 8 | import type { TableRowOptions } from '@tiptap/extension-table-row' 9 | 10 | import { TableCell } from './cell' 11 | import type { TableCellOptions } from './cell' 12 | 13 | import { Table } from './table' 14 | import type { TableOptions } from './table' 15 | 16 | export { 17 | Table, 18 | type TableOptions, 19 | TableCell, 20 | type TableCellOptions, 21 | TableRow, 22 | type TableRowOptions, 23 | TableHeader, 24 | type TableHeaderOptions, 25 | TableCellBackground, 26 | type TableCellBackgroundOptions 27 | } 28 | -------------------------------------------------------------------------------- /app/extensions/Table/menus/TableBubbleMenu.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 50 | 56 | 62 | 63 | -------------------------------------------------------------------------------- /app/extensions/Table/menus/TableCell/index.vue: -------------------------------------------------------------------------------- 1 | 60 | 61 | 62 | 75 | 78 | 87 | 88 | 97 | 106 | 107 | 115 | 116 | 117 | 118 | -------------------------------------------------------------------------------- /app/extensions/Table/row.ts: -------------------------------------------------------------------------------- 1 | import TiptapTableRow from '@tiptap/extension-table-row' 2 | 3 | export const TableRow = TiptapTableRow.extend({ 4 | allowGapCursor: false, 5 | content: 'tableCell*', 6 | }) 7 | 8 | export default TableRow 9 | -------------------------------------------------------------------------------- /app/extensions/Table/table.ts: -------------------------------------------------------------------------------- 1 | import TiptapTable from '@tiptap/extension-table' 2 | import TableRow from './row' 3 | import TableHeader from './header' 4 | import { TableCell } from './cell' 5 | import { TableCellBackground } from './cell-background' 6 | 7 | import type { TableRowOptions } from '@tiptap/extension-table-row' 8 | import type { TableHeaderOptions } from './header' 9 | import type { TableCellOptions } from './cell' 10 | import type { TableCellBackgroundOptions } from './cell-background' 11 | import type { GeneralOptions } from '../../types' 12 | import TableActionButton from './components/TableActionButton.vue' 13 | 14 | export interface TableOptions extends GeneralOptions { 15 | HTMLAttributes: Record 16 | resizable: boolean 17 | handleWidth: number 18 | cellMinWidth: number 19 | lastColumnResizable: boolean 20 | allowTableNodeSelection: boolean 21 | /** options for table rows */ 22 | tableRow: Partial 23 | /** options for table headers */ 24 | tableHeader: Partial 25 | /** options for table cells */ 26 | tableCell: Partial 27 | /** options for table cell background */ 28 | tableCellBackground: Partial 29 | } 30 | export const Table = TiptapTable.extend({ 31 | addOptions() { 32 | return { 33 | ...this.parent?.(), 34 | HTMLAttributes: {}, 35 | resizable: true, 36 | lastColumnResizable: true, 37 | allowTableNodeSelection: false, 38 | button: ({ editor, t }) => ({ 39 | component: TableActionButton, 40 | componentProps: { 41 | disabled: editor.isActive('table') || false, 42 | icon: 'i-lucide-table', 43 | tooltip: t('editor.table.tooltip'), 44 | }, 45 | }), 46 | } 47 | }, 48 | addExtensions() { 49 | return [ 50 | TableRow.configure(this.options.tableRow), 51 | TableHeader.configure(this.options.tableHeader), 52 | TableCell.configure(this.options.tableCell), 53 | TableCellBackground.configure(this.options.tableCellBackground), 54 | ] 55 | }, 56 | }) 57 | 58 | export default Table 59 | -------------------------------------------------------------------------------- /app/extensions/TableOfContents/TableOfContents.ts: -------------------------------------------------------------------------------- 1 | import { mergeAttributes, Node, VueNodeViewRenderer } from '@tiptap/vue-3' 2 | import ActionButton from '../../components/ActionButton.vue' 3 | import TableOfContentsComponent from './components/TableOfContents' 4 | 5 | 6 | 7 | export const TableOfContents = Node.create({ 8 | name: 'tableOfContents', 9 | group: 'block', 10 | atom: true, 11 | addOptions() { 12 | return { 13 | ...this.parent?.(), 14 | button: ({ editor, t }) => ({ 15 | component: ActionButton, 16 | componentProps: { 17 | action: () => editor.commands.setTableOfContents(), 18 | isActive: () => editor.isActive('tableOfContents') || false, 19 | disabled: !editor.can().setTableOfContents(), 20 | icon: 'i-lucide-book-marked', 21 | tooltip: t('editor.tableOfContents.tooltip'), 22 | } 23 | }) 24 | } 25 | }, 26 | 27 | parseHTML() { 28 | return [ 29 | { 30 | tag: 'toc', 31 | }, 32 | ] 33 | }, 34 | 35 | renderHTML({ HTMLAttributes }) { 36 | return ['toc', mergeAttributes(HTMLAttributes)] 37 | }, 38 | 39 | addNodeView() { 40 | return VueNodeViewRenderer(TableOfContentsComponent) 41 | }, 42 | 43 | addCommands() { 44 | return { 45 | setTableOfContents: () => ({ tr, commands }) => { 46 | return commands.insertContent({ 47 | type: this.name, 48 | attrs: {}, 49 | }) 50 | }, 51 | } 52 | } 53 | }) 54 | -------------------------------------------------------------------------------- /app/extensions/TableOfContents/components/TableOfContents.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 10 | 14 | {{ heading.number }} - {{ heading.text }} 19 | 20 | 24 | {{ $t('editor.tableOfContents.empty') }} 25 | 26 | 27 | 28 | 29 | 30 | 31 | 81 | 82 | 89 | -------------------------------------------------------------------------------- /app/extensions/TableOfContents/index.ts: -------------------------------------------------------------------------------- 1 | export * from './TableOfContents' 2 | -------------------------------------------------------------------------------- /app/extensions/TaskList/TaskList.ts: -------------------------------------------------------------------------------- 1 | import { TaskItem, type TaskItemOptions } from '@tiptap/extension-task-item' 2 | import { TaskList as TiptapTaskList, type TaskListOptions as TiptapTaskListOptions } from '@tiptap/extension-task-list' 3 | import type { GeneralOptions } from '../../types' 4 | import ActionButton from '../../components/ActionButton.vue' 5 | 6 | /** 7 | * Represents the interface for task list options, extending TiptapTaskListOptions and GeneralOptions. 8 | */ 9 | export interface TaskListOptions extends TiptapTaskListOptions, GeneralOptions { 10 | /** options for task items */ 11 | taskItem: Partial 12 | } 13 | 14 | export const TaskList = TiptapTaskList.extend({ 15 | addOptions() { 16 | return { 17 | ...this.parent?.(), 18 | HTMLAttributes: { 19 | class: 'task-list', 20 | }, 21 | taskItem: { 22 | HTMLAttributes: { 23 | class: 'task-list-item', 24 | }, 25 | }, 26 | button: ({ editor, t }) => ({ 27 | component: ActionButton, 28 | componentProps: { 29 | action: () => editor.commands.toggleTaskList(), 30 | isActive: () => editor.isActive('taskList') || false, 31 | disabled: !editor.can().toggleTaskList(), 32 | icon: 'i-lucide-list-todo', 33 | shortcuts: ['shift', 'mod', '9'], 34 | tooltip: t('editor.tasklist.tooltip'), 35 | }, 36 | }), 37 | } 38 | }, 39 | 40 | addExtensions() { 41 | return [TaskItem.configure(this.options.taskItem)] 42 | }, 43 | }) 44 | -------------------------------------------------------------------------------- /app/extensions/TaskList/index.ts: -------------------------------------------------------------------------------- 1 | export * from './TaskList' 2 | -------------------------------------------------------------------------------- /app/extensions/TextAlign/TextAlign.ts: -------------------------------------------------------------------------------- 1 | import type { Editor, Extension } from '@tiptap/core' 2 | import TiptapTextAlign, { type TextAlignOptions as TiptapTextAlignOptions } from '@tiptap/extension-text-align' 3 | import TextAlignMenuButton from './components/TextAlignMenuButton.vue' 4 | import type { GeneralOptions } from '../../types' 5 | 6 | type Alignments = 'left' | 'center' | 'right' | 'justify' 7 | /** 8 | * Represents the interface for text align options, extending TiptapTextAlignOptions and GeneralOptions. 9 | */ 10 | export interface TextAlignOptions extends TiptapTextAlignOptions, GeneralOptions { 11 | /** 12 | * List of available alignment options 13 | * 14 | * @default ['left', 'center', 'right', 'justify'] 15 | */ 16 | alignments: Alignments[] 17 | } 18 | export const TextAlign = TiptapTextAlign.extend({ 19 | addOptions() { 20 | return { 21 | ...this.parent?.(), 22 | types: ['heading', 'paragraph', 'list_item', 'title'], 23 | button({ editor, extension, t }: { editor: Editor; extension: Extension; t: (...args: any[]) => string }) { 24 | const alignments = (extension.options?.alignments as Alignments[]) || [] 25 | const shortcutsMap = { 26 | left: ['mod', 'Shift', 'L'], 27 | center: ['mod', 'Shift', 'E'], 28 | right: ['mod', 'Shift', 'R'], 29 | justify: ['mod', 'Shift', 'J'], 30 | } 31 | const iconMap = { 32 | left: 'i-lucide-align-left', 33 | center: 'i-lucide-align-center', 34 | right: 'i-lucide-align-right', 35 | justify: 'i-lucide-align-justify', 36 | } 37 | const items = alignments.map(k => ({ 38 | title: t(`editor.textalign.${k}.tooltip`), 39 | icon: iconMap[k], 40 | shortcuts: shortcutsMap[k], 41 | isActive: () => editor.isActive({ textAlign: k }) || false, 42 | action: () => editor.commands.setTextAlign(k), 43 | disabled: !editor.can().setTextAlign(k), 44 | })) 45 | const disabled = items.filter(k => k.disabled).length === items.length 46 | return { 47 | component: TextAlignMenuButton, 48 | componentProps: { 49 | icon: 'i-lucide-align-justify', 50 | tooltip: t(`editor.textalign.tooltip`), 51 | disabled, 52 | items, 53 | }, 54 | } 55 | }, 56 | } 57 | }, 58 | }) 59 | -------------------------------------------------------------------------------- /app/extensions/TextAlign/components/TextAlignMenuButton.vue: -------------------------------------------------------------------------------- 1 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 81 | 82 | 83 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /app/extensions/TextAlign/index.ts: -------------------------------------------------------------------------------- 1 | export * from './TextAlign' 2 | -------------------------------------------------------------------------------- /app/extensions/TextBubble/TextBubble.ts: -------------------------------------------------------------------------------- 1 | import { Extension } from '@tiptap/core' 2 | import TextDropdown from './components/TextDropdown.vue' 3 | import type { GeneralOptions } from '../../types' 4 | 5 | export interface TextBubbleOptions extends GeneralOptions {} 6 | 7 | export const TextBubble = Extension.create({ 8 | name: 'text-bubble', 9 | addOptions() { 10 | return { 11 | ...this.parent?.(), 12 | toolbar: false, 13 | button: () => ({ 14 | component: TextDropdown, 15 | componentProps: {}, 16 | }), 17 | } 18 | }, 19 | }) 20 | 21 | export default TextBubble 22 | -------------------------------------------------------------------------------- /app/extensions/TextBubble/index.ts: -------------------------------------------------------------------------------- 1 | export * from './TextBubble' 2 | -------------------------------------------------------------------------------- /app/extensions/TrailingNode/TrailingNode.ts: -------------------------------------------------------------------------------- 1 | import { Extension } from '@tiptap/core' 2 | import { Plugin, PluginKey } from '@tiptap/pm/state' 3 | 4 | // @ts-ignore 5 | function nodeEqualsType({ types, node }) { 6 | return (Array.isArray(types) && types.includes(node.type)) || node.type === types 7 | } 8 | 9 | /** 10 | * Extension based on: 11 | * - https://github.com/ueberdosis/tiptap/blob/v1/packages/tiptap-extensions/src/extensions/TrailingNode.js 12 | * - https://github.com/remirror/remirror/blob/e0f1bec4a1e8073ce8f5500d62193e52321155b9/packages/prosemirror-trailing-node/src/trailing-node-plugin.ts 13 | */ 14 | 15 | export interface TrailingNodeOptions { 16 | node: string 17 | notAfter: string[] 18 | } 19 | 20 | export const TrailingNode = Extension.create({ 21 | name: 'trailingNode', 22 | 23 | addOptions() { 24 | return { 25 | node: 'paragraph', 26 | notAfter: ['paragraph'], 27 | } 28 | }, 29 | 30 | addProseMirrorPlugins() { 31 | const plugin = new PluginKey(this.name) 32 | const disabledNodes = Object.entries(this.editor.schema.nodes) 33 | .map(([, value]) => value) 34 | .filter(node => this.options.notAfter.includes(node.name)) 35 | 36 | return [ 37 | new Plugin({ 38 | key: plugin, 39 | appendTransaction: (_, __, state) => { 40 | const { doc, tr, schema } = state 41 | const shouldInsertNodeAtEnd = plugin.getState(state) 42 | const endPosition = doc.content.size 43 | const type = schema.nodes[this.options.node] 44 | 45 | if (!shouldInsertNodeAtEnd) { 46 | return 47 | } 48 | 49 | return tr.insert(endPosition, type.create()) 50 | }, 51 | state: { 52 | init: (_, state) => { 53 | const lastNode = state.tr.doc.lastChild 54 | 55 | return !nodeEqualsType({ node: lastNode, types: disabledNodes }) 56 | }, 57 | apply: (tr, value) => { 58 | if (!tr.docChanged) { 59 | return value 60 | } 61 | 62 | const lastNode = tr.doc.lastChild 63 | 64 | return !nodeEqualsType({ node: lastNode, types: disabledNodes }) 65 | }, 66 | }, 67 | }), 68 | ] 69 | }, 70 | }) 71 | -------------------------------------------------------------------------------- /app/extensions/TrailingNode/index.ts: -------------------------------------------------------------------------------- 1 | export * from './TrailingNode' 2 | -------------------------------------------------------------------------------- /app/extensions/UnderLine/Underline.ts: -------------------------------------------------------------------------------- 1 | import TiptapUnderline, { type UnderlineOptions as TiptapUnderlineOptions } from '@tiptap/extension-underline' 2 | import type { GeneralOptions } from '../../types' 3 | import ActionButton from '../../components/ActionButton.vue' 4 | 5 | export interface UnderlineOptions extends TiptapUnderlineOptions, GeneralOptions {} 6 | 7 | export const Underline = TiptapUnderline.extend({ 8 | addOptions() { 9 | return { 10 | ...this.parent?.(), 11 | button({ editor, t }) { 12 | return { 13 | component: ActionButton, 14 | componentProps: { 15 | action: () => editor.commands.toggleUnderline(), 16 | isActive: () => editor.isActive('underline') || false, 17 | disabled: !editor.can().toggleUnderline(), 18 | icon: 'i-lucide-underline', 19 | shortcuts: ['mod', 'U'], 20 | tooltip: t('editor.underline.tooltip'), 21 | }, 22 | } 23 | }, 24 | } 25 | }, 26 | }) 27 | -------------------------------------------------------------------------------- /app/extensions/UnderLine/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Underline' 2 | -------------------------------------------------------------------------------- /app/extensions/UniqueId/index.ts: -------------------------------------------------------------------------------- 1 | export * from './UniqueId' 2 | -------------------------------------------------------------------------------- /app/extensions/Video/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Video' 2 | -------------------------------------------------------------------------------- /app/extensions/VideoUpload/VideoUpload.ts: -------------------------------------------------------------------------------- 1 | import { Node, VueNodeViewRenderer } from '@tiptap/vue-3' 2 | import VideoUploaderView from './components/VideoUploader.vue' 3 | import ActionButton from '../../components/ActionButton.vue' 4 | 5 | export interface VideoOptions { 6 | upload?: (files: File[]) => void 7 | } 8 | 9 | declare module '@tiptap/core' { 10 | interface Commands { 11 | videoUpload: { 12 | setVideoUpload: () => ReturnType 13 | } 14 | } 15 | } 16 | 17 | export const VideoUpload = Node.create({ 18 | name: 'videoUpload', 19 | isolating: true, 20 | defining: true, 21 | group: 'block', 22 | draggable: true, 23 | selectable: true, 24 | inline: false, 25 | parseHTML() { 26 | return [ 27 | { 28 | tag: `div[data-type="${this.name}"]`, 29 | }, 30 | ] 31 | }, 32 | renderHTML() { 33 | return ['div', { 'data-type': this.name }] 34 | }, 35 | 36 | addCommands() { 37 | return { 38 | setVideoUpload: 39 | () => 40 | ({ commands }) => 41 | commands.insertContent(``), 42 | } 43 | }, 44 | addNodeView() { 45 | return VueNodeViewRenderer(VideoUploaderView) 46 | }, 47 | addOptions() { 48 | return { 49 | ...this.parent?.(), 50 | upload: undefined, 51 | button: ({ editor, t }) => { 52 | return { 53 | component: ActionButton, 54 | componentProps: { 55 | action: () => editor.commands.setVideoUpload(), 56 | isActive: () => editor.isActive('video') || false, 57 | disabled: !editor.can().setVideo({}), 58 | icon: 'i-lucide-video', 59 | tooltip: t('editor.video.tooltip'), 60 | }, 61 | } 62 | }, 63 | } 64 | }, 65 | }) 66 | 67 | export default VideoUpload 68 | -------------------------------------------------------------------------------- /app/extensions/VideoUpload/components/VideoUploader.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 35 | 36 | 37 | 40 | 41 | 42 | {{ t('editor.video.dialog.uploading') }}... 43 | 44 | 45 | 46 | 47 | {{ t('editor.video.dialog.title') }} 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /app/extensions/VideoUpload/index.ts: -------------------------------------------------------------------------------- 1 | export * from './VideoUpload' 2 | -------------------------------------------------------------------------------- /app/features/bubble/AiCompletion.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 42 | 45 | 48 | 51 | 52 | 53 | 54 | 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /app/features/bubble/BubbleMenu.vue: -------------------------------------------------------------------------------- 1 | 68 | 69 | 73 | 74 | 75 | 79 | 80 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | -------------------------------------------------------------------------------- /app/features/bubble/LinkBubbleMenu.vue: -------------------------------------------------------------------------------- 1 | 53 | 54 | 55 | 70 | 71 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /app/features/bubble/Menu.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 34 | 35 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 51 | 55 | 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /app/features/bubble/types.ts: -------------------------------------------------------------------------------- 1 | import type { ButtonViewParams, ButtonViewReturn, ExtensionNameKeys } from '../../types' 2 | 3 | /** Represents the size types for bubble images or videos */ 4 | export type BubbleImageOrVideoSizeType = 'size-small' | 'size-medium' | 'size-large' 5 | 6 | /** Represents the various types for bubble images */ 7 | type BubbleImageType = 8 | | `image-${BubbleImageOrVideoSizeType}` 9 | | `video-${BubbleImageOrVideoSizeType}` 10 | | 'image' 11 | | 'image-aspect-ratio' 12 | | 'remove' 13 | 14 | /** Represents the types for bubble videos */ 15 | type BubbleVideoType = 'video' | 'remove' 16 | 17 | /** Represents the overall types for bubbles */ 18 | type BubbleAllType = BubbleImageType | BubbleVideoType | ExtensionNameKeys | 'divider' | (string & {}) 19 | 20 | /** Represents the key types for node types */ 21 | export type NodeTypeKey = 'image' | 'text' | 'video' 22 | 23 | /** Represents the menu of bubble types for each node type */ 24 | export type BubbleTypeMenu = Partial> 25 | 26 | /** Represents the menu of overall bubble types for each node type */ 27 | export type NodeTypeMenu = Partial> 28 | 29 | /** 30 | * Represents the structure of a bubble menu item. 31 | */ 32 | export interface BubbleMenuItem extends ButtonViewReturn { 33 | /** The type of the bubble item */ 34 | type: BubbleAllType 35 | } 36 | 37 | /** 38 | * Represents a function to generate a bubble menu 39 | */ 40 | interface BubbleView { 41 | /** 42 | * Generates a bubble menu based on the provided options. 43 | * @param {ButtonViewParams} options - The options for generating the bubble menu. 44 | * @returns {BubbleTypeMenu} The generated bubble menu. 45 | */ 46 | (options: ButtonViewParams): BubbleTypeMenu 47 | } 48 | 49 | /** 50 | * Represents the options for configuring bubbles. 51 | * @interface BubbleOptions 52 | * @template T 53 | */ 54 | export interface BubbleOptions { 55 | /** The menu of bubble types for each node type. */ 56 | list: NodeTypeMenu 57 | /** The default list of bubble types. */ 58 | defaultBubbleList: typeof defaultBubbleList 59 | /** The function to generate a bubble menu. */ 60 | button: BubbleView 61 | } 62 | -------------------------------------------------------------------------------- /app/types/index.ts: -------------------------------------------------------------------------------- 1 | import type { Editor as CoreEditor, Extension, JSONContent } from '@tiptap/core' 2 | import type { Editor } from '@tiptap/vue-3' 3 | export type { Editor, JSONContent } from '@tiptap/core' 4 | 5 | /** 6 | * Represents the onChange event for EchoEditor. 7 | */ 8 | export type LeazyEditorOnChange = { 9 | /** Editor object */ 10 | editor: CoreEditor 11 | /** Output content, can be a string or JSON content */ 12 | output: string | JSONContent 13 | } 14 | 15 | /** 16 | * Represents the keys for different extensions. 17 | */ 18 | export type ExtensionNameKeys = 19 | | 'bold' 20 | | 'italic' 21 | | 'underline' 22 | | 'strike' 23 | | 'color' 24 | | 'highlight' 25 | | 'heading' 26 | | 'textAlign' 27 | | 'bulletList' 28 | | 'orderedList' 29 | | 'taskList' 30 | | 'indent' 31 | | 'link' 32 | | 'image' 33 | | 'video' 34 | | 'table' 35 | | 'blockquote' 36 | | 'horizontalRule' 37 | | 'code' 38 | | 'codeBlock' 39 | | 'clear' 40 | | 'history' 41 | | 'fullscreen' 42 | | 'export' 43 | | 'importWord' 44 | | 'import' 45 | 46 | /** 47 | * Represents the general options for Tiptap extensions. 48 | */ 49 | export interface GeneralOptions { 50 | /** Enabled divider */ 51 | divider: boolean 52 | /** Enabled spacer */ 53 | spacer: boolean 54 | /** Button view function */ 55 | button: ButtonView 56 | /** Show on Toolbar */ 57 | toolbar?: boolean 58 | } 59 | 60 | /** 61 | * Represents the props for the ButtonView component. 62 | */ 63 | export interface ButtonViewReturnComponentProps { 64 | /** Method triggered when action is performed */ 65 | action?: (value?: any) => void 66 | /** Whether it is in the active state */ 67 | isActive?: () => boolean 68 | /** Button icon */ 69 | icon?: string 70 | /** Text displayed on hover */ 71 | tooltip?: string 72 | [x: string]: any 73 | } 74 | 75 | /** 76 | * Represents the slots for the ButtonView component. 77 | */ 78 | export interface ButtonViewReturnComponentSlots { 79 | /** Dialog slot */ 80 | dialog: () => any 81 | [x: string]: () => any 82 | } 83 | 84 | /** 85 | * Represents the return value for the ButtonView component. 86 | */ 87 | export interface ButtonViewReturn { 88 | /** Component */ 89 | component: unknown 90 | /** Component props */ 91 | componentProps: ButtonViewReturnComponentProps 92 | /** Component slots */ 93 | componentSlots?: ButtonViewReturnComponentSlots 94 | } 95 | 96 | /** 97 | * Represents the parameters for the ButtonView function. 98 | */ 99 | export interface ButtonViewParams { 100 | /** Editor object */ 101 | editor: Editor 102 | /** Extension object */ 103 | extension: Extension 104 | /** Translation function */ 105 | t: (path: string) => string 106 | } 107 | 108 | /** 109 | * Represents the ButtonView function. 110 | */ 111 | export interface ButtonView { 112 | (options: ButtonViewParams): ButtonViewReturn | ButtonViewReturn[] 113 | } 114 | -------------------------------------------------------------------------------- /app/utils/get-render-container.ts: -------------------------------------------------------------------------------- 1 | import { Editor } from '@tiptap/vue-3' 2 | 3 | export const getRenderContainer = (editor: Editor, nodeType: string) => { 4 | const { 5 | view, 6 | state: { 7 | selection: { from }, 8 | }, 9 | } = editor 10 | 11 | const elements = document.querySelectorAll('.has-focus') 12 | const elementCount = elements.length 13 | const innermostNode = elements[elementCount - 1] 14 | const element = innermostNode 15 | 16 | if ( 17 | (element && element.getAttribute('data-type') && element.getAttribute('data-type') === nodeType) || 18 | (element && element.classList && element.classList.contains(nodeType)) 19 | ) { 20 | return element 21 | } 22 | 23 | const node = view.domAtPos(from).node as HTMLElement 24 | let container: any = node 25 | 26 | if (!container.tagName) { 27 | container = node.parentElement 28 | } 29 | 30 | while ( 31 | container && 32 | !(container.getAttribute('data-type') && container.getAttribute('data-type') === nodeType) && 33 | !container.classList.contains(nodeType) 34 | ) { 35 | container = container.parentElement 36 | } 37 | 38 | return container 39 | } 40 | 41 | export default getRenderContainer 42 | -------------------------------------------------------------------------------- /app/utils/image.ts: -------------------------------------------------------------------------------- 1 | import type { Editor, JSONContent } from '@tiptap/core' 2 | 3 | // https://github.com/ueberdosis/tiptap/blob/6cbc2d423391c950558721510c1b4c8614feb534/packages/extension-image/src/image.ts#L48-L58 4 | export type ImageNodeAttributes = { 5 | /** The URL at which this image can be served. Used as `src`. */ 6 | src: string 7 | /** Alt text for the image. */ 8 | alt?: string 9 | /** The `title` attribute when we render the image element. */ 10 | title?: string 11 | } 12 | 13 | /** 14 | * Insert one or more images into the editor. 15 | * If the editor is not given, or is destroyed, or the images array is empty, 16 | * 17 | * @param options.images The attributes of each image to insert 18 | * @param options.editor The Tiptap editor in which to insert 19 | * @param options.position The position at which to insert into the editor 20 | * content. If not given, uses the current editor caret/selection position. 21 | */ 22 | export function insertImages({ 23 | images, 24 | editor, 25 | position, 26 | }: { 27 | images: ImageNodeAttributes[] 28 | editor: Editor | null 29 | position?: number 30 | }): void { 31 | if (!editor || editor.isDestroyed || images.length === 0) { 32 | return 33 | } 34 | 35 | const imageContentToInsert: JSONContent[] = images 36 | .filter(imageAttrs => !!imageAttrs.src) 37 | .map(imageAttrs => ({ 38 | type: editor.schema.nodes.image.name, 39 | attrs: imageAttrs, 40 | })) 41 | 42 | editor 43 | .chain() 44 | .command(({ commands }) => { 45 | if (position == null) 46 | // Insert at the current caret/selection position 47 | return commands.insertContent(imageContentToInsert) 48 | else return commands.insertContentAt(position, imageContentToInsert) 49 | }) 50 | .focus() 51 | .run() 52 | } 53 | -------------------------------------------------------------------------------- /app/utils/indent.ts: -------------------------------------------------------------------------------- 1 | import type { Command, Editor } from '@tiptap/core' 2 | import { isList } from '@tiptap/core' 3 | import { TextSelection, AllSelection, Transaction } from '@tiptap/pm/state' 4 | 5 | export const enum IndentProps { 6 | max = 7, 7 | min = 0, 8 | 9 | more = 1, 10 | less = -1, 11 | } 12 | function updateIndentLevel(tr: Transaction, delta: number, types: string[], editor: Editor): Transaction { 13 | const { doc, selection } = tr 14 | 15 | if (!doc || !selection) return tr 16 | 17 | if (!(selection instanceof TextSelection || selection instanceof AllSelection)) { 18 | return tr 19 | } 20 | 21 | const { from, to } = selection 22 | 23 | doc.nodesBetween(from, to, (node, pos) => { 24 | const nodeType = node.type 25 | 26 | if (types.includes(nodeType.name)) { 27 | tr = setNodeIndentMarkup(tr, pos, delta) 28 | return false 29 | } else if (isList(node.type.name, editor.extensionManager.extensions)) { 30 | return false 31 | } 32 | return true 33 | }) 34 | 35 | return tr 36 | } 37 | 38 | export function setNodeIndentMarkup(tr: Transaction, pos: number, delta: number): Transaction { 39 | if (!tr.doc) return tr 40 | 41 | const node = tr.doc.nodeAt(pos) 42 | if (!node) return tr 43 | 44 | const minIndent = IndentProps.min 45 | const maxIndent = IndentProps.max 46 | 47 | const indent = clamp((node.attrs.indent || 0) + delta, minIndent, maxIndent) 48 | 49 | if (indent === node.attrs.indent) return tr 50 | 51 | const nodeAttrs = { 52 | ...node.attrs, 53 | indent, 54 | } 55 | 56 | return tr.setNodeMarkup(pos, node.type, nodeAttrs, node.marks) 57 | } 58 | 59 | export function createIndentCommand({ delta, types }: { delta: number; types: string[] }): Command { 60 | return ({ state, dispatch, editor }) => { 61 | const { selection } = state 62 | let { tr } = state 63 | tr = tr.setSelection(selection) 64 | tr = updateIndentLevel(tr, delta, types, editor) 65 | 66 | if (tr.docChanged) { 67 | dispatch && dispatch(tr) 68 | return true 69 | } 70 | 71 | return false 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /app/utils/index.ts: -------------------------------------------------------------------------------- 1 | import type { Editor } from '@tiptap/core' 2 | 3 | export const getCssUnitWithDefault = (value?: string | number, defaultUnit: string = 'px') => { 4 | if (!value) return value 5 | 6 | const stringValue = isNumber(value) ? String(value) : value 7 | 8 | const num = parseFloat(stringValue) 9 | const unitMatch = stringValue.match(/[a-zA-Z%]+$/) 10 | const unit = unitMatch ? unitMatch[0] : defaultUnit 11 | 12 | return isNaN(num) ? value : num + unit 13 | } 14 | 15 | export function clamp(val: number, min: number, max: number) { 16 | if (val < min) return min 17 | if (val > max) return max 18 | return val 19 | } 20 | 21 | export const isNumber = (value: unknown): value is number => typeof value === 'number' 22 | 23 | export const isString = (value: unknown): value is string => typeof value === 'string' 24 | 25 | export const isBoolean = (value: unknown): value is boolean => typeof value === 'boolean' 26 | 27 | export const isFunction = (value: unknown): value is Function => typeof value === 'function' 28 | 29 | /** 30 | * Checks if the editor has a specific extension method with the given name. 31 | * 32 | * @param {Editor} editor - An instance of the editor. 33 | * @param {string} name - The name of the extension method. 34 | * @returns {boolean} - Returns true if the specified extension method is present, otherwise returns false. 35 | */ 36 | export function hasExtension(editor: Editor, name: string): boolean { 37 | // Retrieve the extension manager of the editor, defaulting to an empty array if it doesn't exist 38 | const { extensions = [] } = editor.extensionManager ?? {} 39 | 40 | // Check if the extension method with the specified name is present in the extension manager 41 | const find = extensions.find(i => i.name === name) 42 | 43 | // Return false if the extension method with the specified name is not found, otherwise return true 44 | if (!find) return false 45 | return true 46 | } 47 | 48 | export { differenceBy, isEqual, throttle, truncate } from 'lodash-unified' 49 | 50 | const colors = [ 51 | '#958DF1', 52 | '#F98181', 53 | '#FBBC88', 54 | '#FAF594', 55 | '#70CFF8', 56 | '#94FADB', 57 | '#B9F18D', 58 | '#C3E2C2', 59 | '#EAECCC', 60 | '#AFC8AD', 61 | '#EEC759', 62 | '#9BB8CD', 63 | '#FF90BC', 64 | '#FFC0D9', 65 | '#DC8686', 66 | '#7ED7C1', 67 | '#F3EEEA', 68 | '#89B9AD', 69 | '#D0BFFF', 70 | '#FFF8C9', 71 | '#CBFFA9', 72 | '#9BABB8', 73 | '#E3F4F4', 74 | ] 75 | 76 | const names = [ 77 | 'Lea Thompson', 78 | 'Cyndi Lauper', 79 | 'Tom Cruise', 80 | 'Madonna', 81 | 'Jerry Hall', 82 | 'Joan Collins', 83 | 'Winona Ryder', 84 | 'Christina Applegate', 85 | 'Alyssa Milano', 86 | 'Molly Ringwald', 87 | 'Ally Sheedy', 88 | 'Debbie Harry', 89 | 'Olivia Newton-John', 90 | 'Elton John', 91 | 'Michael J. Fox', 92 | 'Axl Rose', 93 | 'Emilio Estevez', 94 | 'Ralph Macchio', 95 | 'Rob Lowe', 96 | 'Jennifer Grey', 97 | 'Mickey Rourke', 98 | 'John Cusack', 99 | 'Matthew Broderick', 100 | 'Justine Bateman', 101 | 'Lisa Bonet', 102 | ] 103 | export function getRandomUser() { 104 | return { 105 | name: names[Math.floor(Math.random() * names.length)], 106 | color: colors[Math.floor(Math.random() * colors.length)], 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /app/utils/is-mobile.ts: -------------------------------------------------------------------------------- 1 | interface HttpRequestHeadersInterfaceMock { 2 | [id: string]: string | string[] | undefined 3 | } 4 | 5 | interface HttpRequestInterfaceMock { 6 | headers: HttpRequestHeadersInterfaceMock 7 | [id: string]: any 8 | } 9 | 10 | export interface IsMobileOptions { 11 | ua?: string | HttpRequestInterfaceMock 12 | tablet?: boolean 13 | featureDetect?: boolean 14 | } 15 | 16 | const mobileRE = 17 | /(android|bb\d+|meego).+mobile|armv7l|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series[46]0|samsungbrowser.*mobile|symbian|treo|up\.(browser|link)|vodafone|wap|windows (ce|phone)|xda|xiino/i 18 | const notMobileRE = /CrOS/ 19 | const tabletRE = /android|ipad|playbook|silk/i 20 | 21 | /** 22 | * Determines if the current device is a mobile or tablet device. 23 | * @param opts - Options for the detection. 24 | * @returns `true` if the device is mobile or tablet, `false` otherwise. 25 | */ 26 | export function isMobile(opts: IsMobileOptions = {}): boolean { 27 | let ua = opts.ua || (typeof navigator !== 'undefined' && navigator.userAgent) 28 | 29 | if (ua && typeof ua === 'object' && ua.headers && typeof ua.headers['user-agent'] === 'string') { 30 | ua = ua.headers['user-agent'] 31 | } 32 | 33 | if (typeof ua !== 'string') { 34 | return false 35 | } 36 | 37 | if (mobileRE.test(ua) && !notMobileRE.test(ua)) { 38 | return true 39 | } 40 | 41 | if (opts.tablet && tabletRE.test(ua)) { 42 | return true 43 | } 44 | 45 | if ( 46 | opts.tablet && 47 | opts.featureDetect && 48 | navigator && 49 | navigator.maxTouchPoints > 1 && 50 | ua.includes('Macintosh') && 51 | ua.includes('Safari') 52 | ) { 53 | return true 54 | } 55 | 56 | return false 57 | } 58 | -------------------------------------------------------------------------------- /app/utils/line-height.ts: -------------------------------------------------------------------------------- 1 | import { TextSelection, AllSelection, EditorState, Transaction } from '@tiptap/pm/state' 2 | import { Node as ProsemirrorNode, NodeType } from '@tiptap/pm/model' 3 | import { LINE_HEIGHT_100, DEFAULT_LINE_HEIGHT } from '../constants' 4 | import type { Command } from '@tiptap/core' 5 | 6 | export const ALLOWED_NODE_TYPES = ['paragraph', 'heading', 'list_item', 'todo_item'] 7 | 8 | export function isLineHeightActive(state: EditorState, lineHeight: string): boolean { 9 | const { selection, doc } = state 10 | const { from, to } = selection 11 | 12 | let keepLooking = true 13 | let active = false 14 | 15 | doc.nodesBetween(from, to, node => { 16 | const nodeType = node.type 17 | const lineHeightValue = node.attrs.lineHeight || DEFAULT_LINE_HEIGHT 18 | 19 | if (ALLOWED_NODE_TYPES.includes(nodeType.name)) { 20 | if (keepLooking && lineHeight === lineHeightValue) { 21 | keepLooking = false 22 | active = true 23 | 24 | return false 25 | } 26 | return nodeType.name !== 'list_item' && nodeType.name !== 'todo_item' 27 | } 28 | return keepLooking 29 | }) 30 | 31 | return active 32 | } 33 | 34 | interface SetLineHeightTask { 35 | node: ProsemirrorNode 36 | nodeType: NodeType 37 | pos: number 38 | } 39 | 40 | export function setTextLineHeight(tr: Transaction, lineHeight: string | null): Transaction { 41 | const { selection, doc } = tr 42 | 43 | if (!selection || !doc) return tr 44 | 45 | if (!(selection instanceof TextSelection || selection instanceof AllSelection)) { 46 | return tr 47 | } 48 | 49 | const { from, to } = selection 50 | 51 | const tasks: Array = [] 52 | const lineHeightValue = lineHeight && lineHeight !== DEFAULT_LINE_HEIGHT ? lineHeight : null 53 | 54 | doc.nodesBetween(from, to, (node, pos) => { 55 | const nodeType = node.type 56 | if (ALLOWED_NODE_TYPES.includes(nodeType.name)) { 57 | const lineHeight = node.attrs.lineHeight || null 58 | if (lineHeight !== lineHeightValue) { 59 | tasks.push({ 60 | node, 61 | pos, 62 | nodeType, 63 | }) 64 | } 65 | return nodeType.name !== 'list_item' && nodeType.name !== 'todo_item' 66 | } 67 | return true 68 | }) 69 | 70 | if (!tasks.length) return tr 71 | 72 | tasks.forEach(task => { 73 | const { node, pos, nodeType } = task 74 | let { attrs } = node 75 | 76 | attrs = { 77 | ...attrs, 78 | lineHeight: lineHeightValue, 79 | } 80 | 81 | tr = tr.setNodeMarkup(pos, nodeType, attrs, node.marks) 82 | }) 83 | 84 | return tr 85 | } 86 | 87 | export function createLineHeightCommand(lineHeight: string): Command { 88 | return ({ state, dispatch }) => { 89 | const { selection } = state 90 | let { tr } = state 91 | tr = tr.setSelection(selection) 92 | 93 | tr = setTextLineHeight(tr, lineHeight) 94 | 95 | if (tr.docChanged) { 96 | dispatch && dispatch(tr) 97 | return true 98 | } 99 | 100 | return false 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /app/utils/plateform.ts: -------------------------------------------------------------------------------- 1 | // We'll cache the result of isMac() and isTouchDevice(), since they shouldn't 2 | // change during a session. That way repeated calls don't require any logic and 3 | // are rapid. 4 | let isMacResult: boolean | undefined 5 | let isTouchDeviceResult: boolean | undefined 6 | 7 | /** 8 | * Return true if the user is using a Mac (as opposed to Windows, etc.) device. 9 | */ 10 | export function isMac(): boolean { 11 | if (isMacResult === undefined) { 12 | isMacResult = navigator.platform.includes('Mac') 13 | } 14 | return isMacResult 15 | } 16 | 17 | /** 18 | * Return true if the user is using a Windows (as opposed to Mac, etc.) device. 19 | */ 20 | export function getShortcutKey(key: string): string { 21 | if (key.toLowerCase() === 'mod') { 22 | return isMac() ? '⌘' : 'Ctrl' 23 | } else if (key.toLowerCase() === 'alt') { 24 | return isMac() ? '⌥' : 'Alt' 25 | } else if (key.toLowerCase() === 'shift') { 26 | return isMac() ? '⇧' : 'Shift' 27 | } else { 28 | return key 29 | } 30 | } 31 | export function getShortcutKeys(keys: string[]): string { 32 | return keys.map(getShortcutKey).join(' ') 33 | } 34 | 35 | /** Return true if the user is using a touch-based device. */ 36 | export function isTouchDevice(): boolean { 37 | if (isTouchDeviceResult === undefined) { 38 | // This technique is taken from 39 | // https://hacks.mozilla.org/2013/04/detecting-touch-its-the-why-not-the-how/ 40 | // (and https://stackoverflow.com/a/4819886/4543977) 41 | isTouchDeviceResult 42 | = (window && 'ontouchstart' in window) 43 | || navigator.maxTouchPoints > 0 44 | // @ts-expect-error: msMaxTouchPoints is IE-specific, so needs to be ignored 45 | || navigator.msMaxTouchPoints > 0 46 | } 47 | 48 | return isTouchDeviceResult 49 | } 50 | -------------------------------------------------------------------------------- /app/utils/print.ts: -------------------------------------------------------------------------------- 1 | import type { EditorView } from '@tiptap/pm/view' 2 | 3 | function printHtml(dom: Element) { 4 | const style: string = Array.from(document.querySelectorAll('style, link')).reduce((str, style) => str + style.outerHTML, '') 5 | 6 | const content: string = style + dom.outerHTML 7 | 8 | const iframe: HTMLIFrameElement = document.createElement('iframe') 9 | iframe.id = 'tiptap-iframe' 10 | iframe.setAttribute('style', 'position: absolute; width: 0; height: 0; top: -10px; left: -10px;') 11 | document.body.appendChild(iframe) 12 | 13 | const frameWindow = iframe.contentWindow 14 | const doc = iframe.contentDocument || (iframe.contentWindow && iframe.contentWindow.document) 15 | 16 | if (doc) { 17 | doc.open() 18 | doc.write(content) 19 | doc.close() 20 | } 21 | 22 | if (frameWindow) { 23 | iframe.onload = function () { 24 | try { 25 | setTimeout(() => { 26 | frameWindow.focus() 27 | try { 28 | if (!frameWindow.document.execCommand('print', false)) { 29 | frameWindow.print() 30 | } 31 | } catch (e) { 32 | frameWindow.print() 33 | } 34 | frameWindow.close() 35 | }, 10) 36 | } catch (err) { 37 | console.error(err) 38 | } 39 | 40 | setTimeout(function () { 41 | document.body.removeChild(iframe) 42 | }, 100) 43 | } 44 | } 45 | } 46 | 47 | export function printEditorContent(view: EditorView) { 48 | const editorContent = view.dom.closest('.tiptap') 49 | if (editorContent) { 50 | printHtml(editorContent) 51 | return true 52 | } 53 | return false 54 | } 55 | -------------------------------------------------------------------------------- /i18n/localeDetector.ts: -------------------------------------------------------------------------------- 1 | export default defineI18nLocaleDetector((event, config) => { 2 | const query = tryQueryLocale(event, { lang: '' }) 3 | if (query) { 4 | return query.toString() 5 | } 6 | 7 | const cookie = tryCookieLocale(event, { lang: '', name: 'i18n_locale' }) 8 | if (cookie) { 9 | return cookie.toString() 10 | } 11 | 12 | const header = tryHeaderLocale(event, { lang: '' }) 13 | if (header) { 14 | return header.toString() 15 | } 16 | 17 | return config.defaultLocale 18 | }) 19 | -------------------------------------------------------------------------------- /nuxt.config.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config' 2 | 3 | // https://nuxt.com/docs/api/configuration/nuxt-config 4 | export default defineNuxtConfig({ 5 | future: { 6 | compatibilityVersion: 4, 7 | }, 8 | compatibilityDate: '2024-04-03', 9 | nitro: { 10 | experimental: { 11 | websocket: true, 12 | } 13 | }, 14 | runtimeConfig: { 15 | public: { 16 | WEBSOCKET_URL: process.env.NUXT_WEBSOCKET_URL, 17 | OPENAI_API_KEY: process.env.NUXT_OPENAI_API_KEY, 18 | CONVERT_APP_ID: process.env.NUXT_CONVERT_APP_ID, 19 | JWT_CONVERT_TOKEN: process.env.NUXT_JWT_CONVERT_TOKEN, 20 | UNSPLASH_API_KEY: process.env.NUXT_UNSPLASH_API_KEY, 21 | PIXABAY_API_KEY: process.env.NUXT_PIXABAY_API_KEY, 22 | PEXELS_API_KEY: process.env.NUXT_PEXELS_API_KEY, 23 | GIPHY_API_KEY: process.env.NUXT_GIPHY_API_KEY 24 | } 25 | }, 26 | css: ['~/assets/css/main.css'], 27 | modules: [ 28 | '@nuxt/ui', 29 | '@nuxtjs/i18n', 30 | '@nuxt/image', 31 | ], 32 | i18n: { 33 | legacy: false, 34 | locales: [ 35 | { 36 | code: 'fr', 37 | language: 'fr-FR', 38 | file: 'fr.json' 39 | }, 40 | { 41 | code: 'en', 42 | language: 'en-US', 43 | file: 'en.json' 44 | } 45 | ], 46 | lazy: true, 47 | defaultLocale: 'fr', 48 | experimental: { 49 | localeDetector: './localeDetector.ts', 50 | autoImportTranslationFunctions: true, 51 | switchLocalePathLinkSSR: true, 52 | typedOptionsAndMessages: 'default' 53 | } 54 | }, 55 | optimizeDeps: { 56 | include: ['highlight.js'] 57 | }, 58 | vite: { 59 | resolve: { 60 | alias: { 61 | 'highlight.js': 'highlight.js', 62 | 'evaluatex': 'evaluatex' 63 | } 64 | } 65 | }, 66 | devtools: { enabled: true } 67 | }) 68 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /server/api/yjs/[slug].ts: -------------------------------------------------------------------------------- 1 | import { createHandler } from 'y-crossws' 2 | 3 | export default defineWebSocketHandler(createHandler().hooks) 4 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../.nuxt/tsconfig.server.json" 3 | } 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.playground/.nuxt/tsconfig.json" 3 | } 4 | --------------------------------------------------------------------------------
24 | {{ $t('editor.tableOfContents.empty') }} 25 |