github.com/ngocphuongnb/tetua@v0.0.7-alpha/packages/editor/src/extensions/quick-edit/quick-edit.ts (about) 1 import { 2 Editor, 3 posToDOMRect, 4 isTextSelection, 5 isNodeSelection, 6 } from '@tiptap/core' 7 import { EditorState, Plugin, PluginKey } from 'prosemirror-state' 8 import { Node as ProsemirrorNode } from 'prosemirror-model' 9 import { EditorView } from 'prosemirror-view' 10 import tippy, { Instance, Props } from 'tippy.js' 11 12 export interface QuickEditPluginProps { 13 pluginKey: PluginKey | string, 14 editor: Editor, 15 tippyOptions?: Partial<Props>, 16 shouldShow?: ((props: { 17 editor: Editor, 18 view: EditorView, 19 state: EditorState, 20 oldState?: EditorState, 21 from: number, 22 to: number, 23 }) => boolean) | null, 24 } 25 26 export type QuickEditViewProps = QuickEditPluginProps & { 27 view: EditorView, 28 } 29 30 export class QuickEditView { 31 public editor: Editor 32 33 public element: HTMLElement 34 35 public view: EditorView 36 37 public preventHide = false 38 39 public tippy: Instance | undefined 40 41 public tippyOptions?: Partial<Props> 42 43 public shouldShow: Exclude<QuickEditPluginProps['shouldShow'], null> = ({ 44 view, 45 state, 46 from, 47 to, 48 }) => { 49 const { doc, selection } = state 50 const { empty } = selection 51 52 // Sometime check for `empty` is not enough. 53 // Doubleclick an empty paragraph returns a node size of 2. 54 // So we check also for an empty text size. 55 const isEmptyTextBlock = !doc.textBetween(from, to).length 56 && isTextSelection(state.selection) 57 58 if ( 59 !view.hasFocus() 60 || empty 61 || isEmptyTextBlock 62 ) { 63 return false 64 } 65 66 return true 67 } 68 69 constructor({ 70 editor, 71 view, 72 tippyOptions = {}, 73 shouldShow, 74 }: QuickEditViewProps) { 75 this.view = view 76 this.editor = editor 77 this.element = document.createElement('div') 78 this.element.classList.add('mely-bubble'); 79 80 if (shouldShow) { 81 this.shouldShow = shouldShow 82 } 83 84 this.element.addEventListener('mousedown', this.mousedownHandler, { capture: true }) 85 this.view.dom.addEventListener('dragstart', this.dragstartHandler) 86 this.editor.on('focus', this.focusHandler) 87 this.editor.on('blur', this.blurHandler) 88 this.tippyOptions = tippyOptions 89 90 // Detaches menu content from its current parent 91 this.element.remove() 92 this.element.style.visibility = 'visible' 93 } 94 95 mousedownHandler = () => { 96 this.preventHide = true 97 } 98 99 dragstartHandler = () => { 100 this.hide() 101 } 102 103 focusHandler = () => { 104 // we use `setTimeout` to make sure `selection` is already updated 105 setTimeout(() => this.update(this.editor.view)) 106 } 107 108 blurHandler = ({ event }: { event: FocusEvent }) => { 109 if (this.preventHide) { 110 this.preventHide = false 111 112 return 113 } 114 115 if ( 116 event?.relatedTarget 117 && this.element.parentNode?.contains(event.relatedTarget as Node) 118 ) { 119 return 120 } 121 122 this.hide() 123 } 124 125 createTooltip() { 126 const { element: editorElement } = this.editor.options 127 const editorIsAttached = !!editorElement.parentElement 128 129 if (this.tippy || !editorIsAttached) { 130 return 131 } 132 133 this.tippy = tippy(editorElement, { 134 duration: 0, 135 getReferenceClientRect: null, 136 content: this.element, 137 interactive: true, 138 arrow: true, 139 trigger: 'manual', 140 hideOnClick: 'toggle', 141 theme: 'light', 142 placement: 'top', 143 // placement: 'auto', 144 ...this.tippyOptions, 145 }) 146 147 // maybe we have to hide tippy on its own blur event as well 148 if (this.tippy.popper.firstChild) { 149 (this.tippy.popper.firstChild as HTMLElement).addEventListener('blur', event => { 150 this.blurHandler({ event }) 151 }) 152 } 153 } 154 155 showTippy(view: EditorView, from: number, to: number) { 156 this.tippy?.setProps({ 157 getReferenceClientRect: () => { 158 if (isNodeSelection(view.state.selection)) { 159 const node = view.nodeDOM(from) as HTMLElement 160 161 if (node) { 162 return node.getBoundingClientRect() 163 } 164 } 165 166 return posToDOMRect(view, from, to) 167 }, 168 }) 169 170 this.show(); 171 } 172 173 showLinkSettingPopup(view: EditorView) { 174 const { state } = view; 175 const { selection } = state; 176 const { ranges } = selection; 177 const linkMarkType = state.schema.marks.link; 178 const position = selection.$from; 179 const from = Math.min(...ranges.map(range => range.$from.pos)); 180 const to = Math.max(...ranges.map(range => range.$to.pos)); 181 let hasLinkMark = false; 182 let selectionChildCount = 0; 183 let linkMark: ProsemirrorNode = null; 184 185 for (let i = 0; !hasLinkMark && i < ranges.length; i++) { 186 let { $from, $to } = ranges[i]; 187 hasLinkMark = state.doc.rangeHasMark($from.pos, $to.pos, linkMarkType); 188 } 189 190 position.doc.nodesBetween(selection.from, selection.to, (node, pos) => { 191 if (selection.from === pos) { 192 linkMark = node; 193 } 194 selectionChildCount++; 195 return true; 196 }); 197 198 if (!hasLinkMark || !linkMark || selectionChildCount > 2) { 199 return this.hide(); 200 } 201 202 this.element.innerHTML = ''; 203 this.element.classList.add('mely-bubble-inline'); 204 const linkInputElement = document.createElement('input'); 205 const linkApplyElement = document.createElement('button'); 206 const linkUnsetElement = document.createElement('button'); 207 const selectionTo = selection.to; 208 209 const setLink = () => { 210 this.editor.chain().setLink({ 211 href: linkInputElement.value, 212 }).run(); 213 this.editor.chain().focus(selectionTo).run(); 214 this.hide(); 215 } 216 217 const unsetLink = () => { 218 this.editor.chain().unsetLink().run(); 219 this.editor.chain().focus(selectionTo).run(); 220 this.hide(); 221 } 222 223 const onSubmmitLink = (e: KeyboardEvent) => { 224 if (e.key === 'Enter') { 225 e.preventDefault(); 226 e.stopImmediatePropagation(); 227 setLink(); 228 return false; 229 } 230 } 231 232 233 linkApplyElement.type = 'button'; 234 linkApplyElement.textContent = 'Apply'; 235 linkUnsetElement.type = 'button'; 236 linkUnsetElement.textContent = 'Unset'; 237 238 linkUnsetElement.addEventListener('click', unsetLink); 239 linkApplyElement.addEventListener('click', setLink); 240 linkInputElement.addEventListener('keyup', onSubmmitLink); 241 linkInputElement.addEventListener('keydown', onSubmmitLink); 242 linkInputElement.addEventListener('keypress', onSubmmitLink); 243 244 this.element.append(linkInputElement, linkApplyElement, linkUnsetElement); 245 linkInputElement.value = this.editor.getAttributes('link').href; 246 247 this.showTippy(view, from, to); 248 // setTimeout(() => linkInputElement.focus(), 0); 249 } 250 251 showImageSettingPopup(view: EditorView) { 252 const { state } = view; 253 const { selection } = state; 254 const { ranges } = selection; 255 const from = Math.min(...ranges.map(range => range.$from.pos)); 256 const to = Math.max(...ranges.map(range => range.$to.pos)); 257 258 if (!selection || !isNodeSelection(selection) || selection.empty || selection.node.type.name !== 'image' || !selection.node.attrs.src) { 259 return; 260 } 261 262 const setImageAttrs = () => { 263 this.editor.chain().focus().setImage({ 264 src: imgHref.value, 265 alt: imgInfoAlt.value, 266 title: imgInfoTitle.value, 267 }).run(); 268 } 269 270 const onSubmmitImageAttrs = (e: KeyboardEvent) => { 271 if (e.key === 'Enter') { 272 e.preventDefault(); 273 e.stopImmediatePropagation(); 274 setImageAttrs(); 275 return false; 276 } 277 } 278 279 const imageAttrs = selection.node.attrs || {}; 280 this.element.innerHTML = ''; 281 282 const imgInfo = document.createElement('div'); 283 imgInfo.className = 'mely-editor-bubble-content'; 284 285 const imgHref = document.createElement('input'); 286 imgHref.value = imageAttrs.src || ''; 287 288 const imgInfoTitle = document.createElement('input'); 289 imgInfoTitle.setAttribute('placeholder', 'Title'); 290 imgInfoTitle.value = imageAttrs.title || ''; 291 imgInfoTitle.addEventListener('keyup', onSubmmitImageAttrs); 292 imgInfoTitle.addEventListener('keydown', onSubmmitImageAttrs); 293 imgInfoTitle.addEventListener('keypress', onSubmmitImageAttrs); 294 295 const imgInfoAlt = document.createElement('input'); 296 imgInfoAlt.setAttribute('placeholder', 'Alt'); 297 imgInfoAlt.value = imageAttrs.alt || ''; 298 imgInfoAlt.addEventListener('keyup', onSubmmitImageAttrs); 299 imgInfoAlt.addEventListener('keydown', onSubmmitImageAttrs); 300 imgInfoAlt.addEventListener('keypress', onSubmmitImageAttrs); 301 302 const action = document.createElement('div'); 303 action.className = 'mely-editor-bubble-inline-actions'; 304 305 const imageApplyElement = document.createElement('button'); 306 const imageUnsetElement = document.createElement('button'); 307 imageApplyElement.type = 'button'; 308 imageApplyElement.textContent = 'Apply'; 309 imageUnsetElement.type = 'button'; 310 imageUnsetElement.textContent = 'Remove'; 311 312 imageApplyElement.addEventListener('click', setImageAttrs); 313 imageUnsetElement.addEventListener('click', () => { 314 315 this.editor.chain().focus().deleteRange({ from, to }).run(); 316 }); 317 318 action.append(imageApplyElement, imageUnsetElement); 319 320 imgInfo.append(imgHref, imgInfoTitle, imgInfoAlt, action); 321 322 this.element.append(imgInfo); 323 this.showTippy(view, from, to); 324 } 325 326 showIframeSettingPopup(view: EditorView) { 327 const { state } = view; 328 const { selection } = state; 329 const { ranges } = selection; 330 const from = Math.min(...ranges.map(range => range.$from.pos)); 331 const to = Math.max(...ranges.map(range => range.$to.pos)); 332 333 if (!selection || !isNodeSelection(selection) || selection.empty || selection.node.type.name !== 'iframe' || !selection.node.attrs.src) { 334 return; 335 } 336 337 const setIframeAttrs = () => { 338 this.editor.chain().focus().setIframe({ 339 src: iframeSrc.value, 340 width: parseInt(iframeWidth.value), 341 height: parseInt(iframeHeight.value), 342 }).run(); 343 } 344 345 const onSubmmitImageAttrs = (e: KeyboardEvent) => { 346 if (e.key === 'Enter') { 347 e.preventDefault(); 348 e.stopImmediatePropagation(); 349 setIframeAttrs(); 350 return false; 351 } 352 } 353 354 const iframeAttrs = selection.node.attrs || {}; 355 this.element.innerHTML = ''; 356 357 const iframeInfo = document.createElement('div'); 358 iframeInfo.className = 'mely-editor-bubble-content'; 359 360 const iframeSrc = document.createElement('input'); 361 iframeSrc.value = iframeAttrs.src || ''; 362 363 const iframeWidth = document.createElement('input'); 364 iframeWidth.setAttribute('placeholder', 'Width'); 365 iframeWidth.value = iframeAttrs.width || ''; 366 iframeWidth.type = 'number'; 367 iframeWidth.addEventListener('keyup', onSubmmitImageAttrs); 368 iframeWidth.addEventListener('keydown', onSubmmitImageAttrs); 369 iframeWidth.addEventListener('keypress', onSubmmitImageAttrs); 370 371 const iframeHeight = document.createElement('input'); 372 iframeHeight.setAttribute('placeholder', 'Height'); 373 iframeHeight.value = iframeAttrs.height || ''; 374 iframeHeight.type = 'number'; 375 iframeHeight.addEventListener('keyup', onSubmmitImageAttrs); 376 iframeHeight.addEventListener('keydown', onSubmmitImageAttrs); 377 iframeHeight.addEventListener('keypress', onSubmmitImageAttrs); 378 379 const action = document.createElement('div'); 380 action.className = 'mely-editor-bubble-inline-actions'; 381 382 const imageApplyElement = document.createElement('button'); 383 const imageUnsetElement = document.createElement('button'); 384 imageApplyElement.type = 'button'; 385 imageApplyElement.textContent = 'Apply'; 386 imageUnsetElement.type = 'button'; 387 imageUnsetElement.textContent = 'Remove'; 388 389 imageApplyElement.addEventListener('click', setIframeAttrs); 390 imageUnsetElement.addEventListener('click', () => { 391 this.editor.chain().focus().deleteRange({ from, to }).run(); 392 }); 393 394 action.append(imageApplyElement, imageUnsetElement); 395 iframeInfo.append(iframeSrc, iframeWidth, iframeHeight, action); 396 397 this.element.append(iframeInfo); 398 this.showTippy(view, from, to); 399 } 400 401 update(view: EditorView, oldState?: EditorState) { 402 const { state, composing } = view; 403 const { doc, selection } = state; 404 const { ranges } = selection; 405 const isSame = oldState && oldState.doc.eq(doc) && oldState.selection.eq(selection); 406 const from = Math.min(...ranges.map(range => range.$from.pos)); 407 const to = Math.max(...ranges.map(range => range.$to.pos)); 408 409 if (composing || isSame) { 410 return 411 } 412 413 this.createTooltip() 414 const shouldShow = this.shouldShow?.({ 415 editor: this.editor, 416 view, 417 state, 418 oldState, 419 from, 420 to, 421 }) 422 423 if (!shouldShow) { 424 return this.hide() 425 } 426 427 this.showLinkSettingPopup(view); 428 this.showImageSettingPopup(view); 429 this.showIframeSettingPopup(view); 430 } 431 432 show() { 433 this.tippy?.show() 434 } 435 436 hide() { 437 this.element.innerHTML = ''; 438 this.tippy?.hide() 439 } 440 441 destroy() { 442 this.tippy?.destroy() 443 this.element.removeEventListener('mousedown', this.mousedownHandler, { capture: true }) 444 this.view.dom.removeEventListener('dragstart', this.dragstartHandler) 445 this.editor.off('focus', this.focusHandler) 446 this.editor.off('blur', this.blurHandler) 447 } 448 } 449 450 export const QuickEditPlugin = (options: QuickEditPluginProps) => { 451 return new Plugin({ 452 key: typeof options.pluginKey === 'string' 453 ? new PluginKey(options.pluginKey) 454 : options.pluginKey, 455 view: view => new QuickEditView({ view, ...options }), 456 }) 457 }