github.com/ngocphuongnb/tetua@v0.0.7-alpha/packages/editor/src/extensions/image.ts (about) 1 import { Editor } from '@tiptap/core'; 2 import Image from '@tiptap/extension-image'; 3 import { createNodeViewBlock } from '../utils'; 4 5 export type ImageUploadHandler = (file: File, callback: (url: string, err?: Error) => void) => void; 6 export interface ImageExtensionProps { 7 uploadHandler?: ImageUploadHandler; 8 disableTitle?: boolean; 9 } 10 11 const createImageUrlElm = (editor: Editor, getPos: boolean | (() => number)) => { 12 const imageUrlContainer = document.createElement('div'); 13 const imageUrlInput = document.createElement('input'); 14 const imageUrlApplyBtn = document.createElement('button'); 15 16 imageUrlContainer.className = 'mely-editor-img-url'; 17 imageUrlApplyBtn.innerText = 'Insert'; 18 imageUrlInput.setAttribute('type', 'text'); 19 imageUrlInput.setAttribute('placeholder', 'Enter image URL'); 20 imageUrlApplyBtn.addEventListener('click', (e: KeyboardEvent) => { 21 e.preventDefault(); 22 e.stopImmediatePropagation(); 23 24 if (!imageUrlInput.value) { 25 imageUrlInput.focus(); 26 return; 27 } 28 29 if (typeof getPos === 'function') { 30 editor.view.dispatch(editor.view.state.tr.setNodeMarkup(getPos(), undefined, { 31 src: imageUrlInput.value, 32 alt: '', 33 title: '', 34 })) 35 editor.commands.focus(); 36 } 37 }); 38 39 imageUrlContainer.append(imageUrlInput, imageUrlApplyBtn); 40 setTimeout(() => imageUrlInput.focus(), 0); 41 42 return imageUrlContainer; 43 } 44 45 const createOrTextElm = () => { 46 const orText = document.createElement('p'); 47 orText.innerText = 'or'; 48 return orText; 49 } 50 51 const createImageUploadElm = (dom: HTMLDivElement, uploadHandler: ImageUploadHandler, editor: Editor, getPos: boolean | (() => number)) => { 52 const uploadElm = document.createElement('div'); 53 const uploadInput = document.createElement('input'); 54 const uploadBtn = document.createElement('button'); 55 56 uploadElm.className = 'mely-editor-img-upload'; 57 uploadInput.setAttribute('type', 'file'); 58 uploadInput.setAttribute('accept', 'image/*'); 59 uploadInput.setAttribute('name', 'file'); 60 uploadInput.setAttribute('id', 'file'); 61 uploadInput.setAttribute('style', 'display: none;'); 62 uploadBtn.setAttribute('type', 'button'); 63 uploadBtn.setAttribute('class', 'mely-editor-img-upload-btn'); 64 uploadBtn.innerText = 'Select file'; 65 uploadBtn.addEventListener('click', (e) => { 66 e.preventDefault(); 67 uploadInput.click(); 68 }); 69 70 uploadInput.addEventListener('change', (e) => { 71 const target = e.target as HTMLInputElement; 72 const file = target.files[0]; 73 dom.classList.add('uploading'); 74 uploadHandler(file, (url, err) => { 75 dom.classList.remove('uploading'); 76 if (err) { 77 console.error(err); 78 alert('Upload failed'); 79 return; 80 } 81 82 if (typeof getPos === 'function') { 83 editor.view.dispatch(editor.view.state.tr.setNodeMarkup(getPos(), undefined, { 84 src: url, 85 alt: '', 86 title: '', 87 })) 88 editor.commands.focus(); 89 } 90 }); 91 }); 92 93 uploadElm.append(uploadInput, uploadBtn); 94 return uploadElm; 95 } 96 97 export const getImageExtension = (props: ImageExtensionProps = {}) => { 98 const uploadHandler = props.uploadHandler || (() => console.log('Upload handler not set')); 99 100 return Image.extend({ 101 onCreate() { 102 window.addEventListener('paste', (e: ClipboardEvent) => { 103 if (e.clipboardData.files && e.clipboardData.files.length > 0) { 104 e.preventDefault(); 105 e.stopImmediatePropagation(); 106 e.stopPropagation(); 107 const file = e.clipboardData.files[0]; 108 const selection = this.editor.view.state.tr.selection; 109 const pos = selection.$anchor.pos - 1; 110 111 if (!props.disableTitle) { 112 const position = selection.$from; 113 let pastedOnTitleField = false; 114 let titleNode = null; 115 116 position.doc.nodesBetween(selection.from, selection.to, (node) => { 117 titleNode = node; 118 pastedOnTitleField = node.type.name === 'heading' && node.attrs.level == 1; 119 }); 120 121 if (pastedOnTitleField) { 122 alert('Can\'t paste image on title field'); 123 if (titleNode) { 124 this.editor.view.dispatch(this.editor.view.state.tr.setNodeMarkup( 125 pos, 126 this.editor.schema.nodes.paragraph 127 )); 128 } 129 return; 130 } 131 } 132 133 uploadHandler(file, (url, err) => { 134 if (err) { 135 console.error(err); 136 alert('Upload failed'); 137 this.editor.view.dispatch(this.editor.view.state.tr.setNodeMarkup( 138 pos, 139 this.editor.schema.nodes.paragraph 140 )); 141 return; 142 } 143 144 this.editor.view.dispatch(this.editor.view.state.tr.setNodeMarkup(pos, undefined, { 145 src: url, 146 alt: '', 147 title: '', 148 })); 149 }); 150 } 151 }); 152 }, 153 addNodeView() { 154 return ({ 155 editor, 156 node: _node, 157 getPos, 158 HTMLAttributes: attrs, 159 decorations: _decorations, 160 extension: _extension 161 }) => { 162 163 if (!props.disableTitle && typeof getPos === 'function') { 164 let pastedOnTitleField = false 165 const selection = editor.view.state.selection; 166 const position = selection.$from; 167 const pos = getPos() 168 position.doc.nodesBetween(pos, pos, (node) => { 169 pastedOnTitleField = node.type.name === 'heading' && node.attrs.level == 1; 170 }); 171 172 if (pastedOnTitleField) { 173 alert('Can\'t insert image on title field'); 174 return; 175 } 176 } 177 178 const contentDomElm = document.createElement('img'); 179 contentDomElm.setAttribute('src', attrs.src); 180 contentDomElm.setAttribute('alt', attrs.alt || ''); 181 contentDomElm.setAttribute('title', attrs.title || ''); 182 183 const { dom, view } = createNodeViewBlock(contentDomElm, []); 184 dom.classList.add('block-img'); 185 view.append( 186 createImageUrlElm(editor, getPos), 187 createOrTextElm(), 188 createImageUploadElm(dom, uploadHandler, editor, getPos) 189 ); 190 191 return { 192 dom, 193 contentDOM: contentDomElm, 194 stopEvent: () => !attrs.src, 195 ignoreMutation: _mutation => !attrs.src, 196 } 197 } 198 }, 199 }).configure({ inline: true }); 200 }