From 922ecdf98f4f94b9d4c7bc2879d842c4621e48dc Mon Sep 17 00:00:00 2001 From: fantasticit Date: Sun, 18 Dec 2022 12:25:20 +0800 Subject: [PATCH] add scroll to cursor --- .../core/extensions/scroll-to-cursor.ts | 127 ++++++ .../src/tiptap/editor/collaboration/kit.ts | 2 + packages/client/src/tiptap/image-load.js | 374 ++++++++++++++++++ 3 files changed, 503 insertions(+) create mode 100644 packages/client/src/tiptap/core/extensions/scroll-to-cursor.ts create mode 100644 packages/client/src/tiptap/image-load.js diff --git a/packages/client/src/tiptap/core/extensions/scroll-to-cursor.ts b/packages/client/src/tiptap/core/extensions/scroll-to-cursor.ts new file mode 100644 index 00000000..9eaa7cb8 --- /dev/null +++ b/packages/client/src/tiptap/core/extensions/scroll-to-cursor.ts @@ -0,0 +1,127 @@ +import { Extension } from '@tiptap/core'; +import { Plugin } from 'prosemirror-state'; +import { ImagesLoaded } from 'tiptap/image-load'; + +/** + * Options for customizing Scroll2Cursor plugin + */ +export type Scroll2CursorOptions = { + /** + * The HTML element that wraps around the editor on which you would + * call `scrollTo` to scroll to the cursor. Default to `window`. + */ + scrollerElement?: HTMLElement; + /** + * Number of milliseconds to wait before starting scrolling. The main reason + * for the delay is that it helps prevent flickering when the user hold down + * the up/down key. Default to 50. + */ + delay?: number; + /** + * Used to override the default function in case there is another + * platform-specific implementation. + */ + computeScrollTop?: () => number; + /** + * Number of pixels from the bottom where cursor position should be + * considered too low. Default to 64. + */ + offsetBottom?: number; + /** + * Number of pixels from the top where cursor position should be considered + * too high. Default to 168. + */ + offsetTop?: number; + /** + * Number of pixels you want to scroll downward/upward when the cursor is + * too low/high the. Default to 96. + */ + scrollDistance?: number; + /** + * When debugMode is false or not set, the plugin will not print anything to + * the console. + */ + debugMode?: boolean; +}; + +export const Scroll2Cursor = Extension.create({ + name: 'scroll2Cursor', + + addOptions() { + return { + delay: 100, + offsetTop: 64, + offsetBottom: 64, + scrollDistance: 96, + }; + }, + + addProseMirrorPlugins() { + const { options, editor } = this; + let timeoutScroll: ReturnType; + const offsetBottom = options?.offsetBottom; + const offsetTop = options?.offsetTop; + const scrollDistance = options?.scrollDistance; + + function scrollTo(x: number, y: number) { + const scrollerElement = + options?.scrollerElement || + editor.view?.dom?.parentElement?.parentElement?.parentElement?.parentElement || + window; + scrollerElement.scrollTo(x, y); + } + + return [ + new Plugin({ + props: { + handleScrollToSelection(view) { + const scrollerElement = (options?.scrollerElement || + editor.view?.dom?.parentElement?.parentElement?.parentElement?.parentElement || + window) as HTMLElement; + const scrollerHeight = scrollerElement.getBoundingClientRect().height; + + ImagesLoaded(scrollerElement, function () { + timeoutScroll && clearTimeout(timeoutScroll); + + timeoutScroll = setTimeout(() => { + if (scrollerHeight <= offsetBottom + offsetTop + scrollDistance) { + options?.debugMode && console.info('The window height is too small for the scrolling configurations'); + return false; + } + + const top = + view.coordsAtPos(view.state.selection.$head.pos).top - + (scrollerElement?.getBoundingClientRect().top ?? 0); + + const scrollTop = options?.computeScrollTop + ? options.computeScrollTop() + : scrollerElement?.scrollTop ?? + (window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop) ?? + -1; + + if (scrollTop === -1) { + options?.debugMode && console.error('The plugin could not determine scrollTop'); + return; + } + + const offBottom = top + offsetBottom - scrollerHeight; + + if (offBottom > 0) { + scrollTo(0, scrollTop + offBottom + scrollDistance); + return; + } + + const offTop = top - offsetTop; + if (offTop < 0) { + scrollTo(0, scrollTop + offTop - scrollDistance); + } + }, options.delay); + }); + + return true; + }, + }, + }), + ]; + }, +}); diff --git a/packages/client/src/tiptap/editor/collaboration/kit.ts b/packages/client/src/tiptap/editor/collaboration/kit.ts index 71815334..c3f2e831 100644 --- a/packages/client/src/tiptap/editor/collaboration/kit.ts +++ b/packages/client/src/tiptap/editor/collaboration/kit.ts @@ -48,6 +48,7 @@ import { Paragraph } from 'tiptap/core/extensions/paragraph'; import { Paste } from 'tiptap/core/extensions/paste'; import { Placeholder } from 'tiptap/core/extensions/placeholder'; import { QuickInsert } from 'tiptap/core/extensions/quick-insert'; +import { Scroll2Cursor } from 'tiptap/core/extensions/scroll-to-cursor'; import { SearchNReplace } from 'tiptap/core/extensions/search'; import { Status } from 'tiptap/core/extensions/status'; import { Strike } from 'tiptap/core/extensions/strike'; @@ -186,4 +187,5 @@ export const CollaborationKit = [ Title, DocumentWithTitle, Dragable, + Scroll2Cursor, ]; diff --git a/packages/client/src/tiptap/image-load.js b/packages/client/src/tiptap/image-load.js new file mode 100644 index 00000000..b0e8d0d6 --- /dev/null +++ b/packages/client/src/tiptap/image-load.js @@ -0,0 +1,374 @@ +/*! + * imagesLoaded v5.0.0 + * JavaScript is all like "You images are done yet or what?" + * MIT License + */ +function EvEmitter() {} + +let proto = EvEmitter.prototype; + +proto.on = function (eventName, listener) { + if (!eventName || !listener) return this; + + // set events hash + let events = (this._events = this._events || {}); + // set listeners array + let listeners = (events[eventName] = events[eventName] || []); + // only add once + if (!listeners.includes(listener)) { + listeners.push(listener); + } + + return this; +}; + +proto.once = function (eventName, listener) { + if (!eventName || !listener) return this; + + // add event + this.on(eventName, listener); + // set once flag + // set onceEvents hash + let onceEvents = (this._onceEvents = this._onceEvents || {}); + // set onceListeners object + let onceListeners = (onceEvents[eventName] = onceEvents[eventName] || {}); + // set flag + onceListeners[listener] = true; + + return this; +}; + +proto.off = function (eventName, listener) { + let listeners = this._events && this._events[eventName]; + if (!listeners || !listeners.length) return this; + + let index = listeners.indexOf(listener); + if (index != -1) { + listeners.splice(index, 1); + } + + return this; +}; + +proto.emitEvent = function (eventName, args) { + let listeners = this._events && this._events[eventName]; + if (!listeners || !listeners.length) return this; + + // copy over to avoid interference if .off() in listener + listeners = listeners.slice(0); + args = args || []; + // once stuff + let onceListeners = this._onceEvents && this._onceEvents[eventName]; + + for (let listener of listeners) { + let isOnce = onceListeners && onceListeners[listener]; + if (isOnce) { + // remove listener + // remove before trigger to prevent recursion + this.off(eventName, listener); + // unset once flag + delete onceListeners[listener]; + } + // trigger listener + listener.apply(this, args); + } + + return this; +}; + +proto.allOff = function () { + delete this._events; + delete this._onceEvents; + return this; +}; + +let console = () => {}; + +// -------------------------- helpers -------------------------- // + +// turn element or nodeList into an array +function makeArray(obj) { + // use object if already an array + if (Array.isArray(obj)) return obj; + + let isArrayLike = typeof obj == 'object' && typeof obj.length == 'number'; + // convert nodeList to array + if (isArrayLike) return [...obj]; + + // array of single index + return [obj]; +} + +// -------------------------- imagesLoaded -------------------------- // + +/** + * @param elem + * @param options - if function, use as callback + * @param onAlways - callback function + * @returns {ImagesLoaded} + */ +export function ImagesLoaded(elem, options, onAlways = null) { + // coerce ImagesLoaded() without new, to be new ImagesLoaded() + if (!(this instanceof ImagesLoaded)) { + return new ImagesLoaded(elem, options, onAlways); + } + // use elem as selector string + let queryElem = elem; + if (typeof elem == 'string') { + queryElem = document.querySelectorAll(elem); + } + // bail if bad element + if (!queryElem) { + console.error(`Bad element for imagesLoaded ${queryElem || elem}`); + return; + } + + this.elements = makeArray(queryElem); + this.options = {}; + // shift arguments if no options set + if (typeof options == 'function') { + onAlways = options; + } else { + Object.assign(this.options, options); + } + + if (onAlways) this.on('always', onAlways); + + this.getImages(); + + // HACK check async to allow time to bind listeners + setTimeout(this.check.bind(this)); +} + +ImagesLoaded.prototype = Object.create(EvEmitter.prototype); + +ImagesLoaded.prototype.getImages = function () { + this.images = []; + + // filter & find items if we have an item selector + this.elements.forEach(this.addElementImages, this); +}; + +const elementNodeTypes = [1, 9, 11]; + +/** + * @param {Node} elem + */ +ImagesLoaded.prototype.addElementImages = function (elem) { + // filter siblings + if (elem.nodeName === 'IMG') { + this.addImage(elem); + } + // get background image on element + if (this.options.background === true) { + this.addElementBackgroundImages(elem); + } + + // find children + // no non-element nodes, #143 + let { nodeType } = elem; + if (!nodeType || !elementNodeTypes.includes(nodeType)) return; + + let childImgs = elem.querySelectorAll('img'); + // concat childElems to filterFound array + for (let img of childImgs) { + this.addImage(img); + } + + // get child background images + if (typeof this.options.background == 'string') { + let children = elem.querySelectorAll(this.options.background); + for (let child of children) { + this.addElementBackgroundImages(child); + } + } +}; + +const reURL = /url\((['"])?(.*?)\1\)/gi; + +ImagesLoaded.prototype.addElementBackgroundImages = function (elem) { + let style = getComputedStyle(elem); + // Firefox returns null if in a hidden iframe https://bugzil.la/548397 + if (!style) return; + + // get url inside url("...") + let matches = reURL.exec(style.backgroundImage); + while (matches !== null) { + let url = matches && matches[2]; + if (url) { + this.addBackground(url, elem); + } + matches = reURL.exec(style.backgroundImage); + } +}; + +/** + * @param {Image} img + */ +ImagesLoaded.prototype.addImage = function (img) { + let loadingImage = new LoadingImage(img); + this.images.push(loadingImage); +}; + +ImagesLoaded.prototype.addBackground = function (url, elem) { + let background = new Background(url, elem); + this.images.push(background); +}; + +ImagesLoaded.prototype.check = function () { + this.progressedCount = 0; + this.hasAnyBroken = false; + // complete if no images + if (!this.images.length) { + this.complete(); + return; + } + + /* eslint-disable-next-line func-style */ + let onProgress = (image, elem, message) => { + // HACK - Chrome triggers event before object properties have changed. #83 + setTimeout(() => { + this.progress(image, elem, message); + }); + }; + + this.images.forEach(function (loadingImage) { + loadingImage.once('progress', onProgress); + loadingImage.check(); + }); +}; + +ImagesLoaded.prototype.progress = function (image, elem, message) { + this.progressedCount++; + this.hasAnyBroken = this.hasAnyBroken || !image.isLoaded; + // progress event + this.emitEvent('progress', [this, image, elem]); + if (this.jqDeferred && this.jqDeferred.notify) { + this.jqDeferred.notify(this, image); + } + // check if completed + if (this.progressedCount === this.images.length) { + this.complete(); + } + + if (this.options.debug && console) { + console.log(`progress: ${message}`, image, elem); + } +}; + +ImagesLoaded.prototype.complete = function () { + let eventName = this.hasAnyBroken ? 'fail' : 'done'; + this.isComplete = true; + this.emitEvent(eventName, [this]); + this.emitEvent('always', [this]); + if (this.jqDeferred) { + let jqMethod = this.hasAnyBroken ? 'reject' : 'resolve'; + this.jqDeferred[jqMethod](this); + } +}; + +// -------------------------- -------------------------- // + +function LoadingImage(img) { + this.img = img; +} + +LoadingImage.prototype = Object.create(EvEmitter.prototype); + +LoadingImage.prototype.check = function () { + // If complete is true and browser supports natural sizes, + // try to check for image status manually. + let isComplete = this.getIsImageComplete(); + if (isComplete) { + // report based on naturalWidth + this.confirm(this.img.naturalWidth !== 0, 'naturalWidth'); + return; + } + + // If none of the checks above matched, simulate loading on detached element. + this.proxyImage = new Image(); + // add crossOrigin attribute. #204 + if (this.img.crossOrigin) { + this.proxyImage.crossOrigin = this.img.crossOrigin; + } + this.proxyImage.addEventListener('load', this); + this.proxyImage.addEventListener('error', this); + // bind to image as well for Firefox. #191 + this.img.addEventListener('load', this); + this.img.addEventListener('error', this); + this.proxyImage.src = this.img.currentSrc || this.img.src; +}; + +LoadingImage.prototype.getIsImageComplete = function () { + // check for non-zero, non-undefined naturalWidth + // fixes Safari+InfiniteScroll+Masonry bug infinite-scroll#671 + return this.img.complete && this.img.naturalWidth; +}; + +LoadingImage.prototype.confirm = function (isLoaded, message) { + this.isLoaded = isLoaded; + let { parentNode } = this.img; + // emit progress with parent or self + let elem = parentNode.nodeName === 'PICTURE' ? parentNode : this.img; + this.emitEvent('progress', [this, elem, message]); +}; + +// ----- events ----- // + +// trigger specified handler for event type +LoadingImage.prototype.handleEvent = function (event) { + let method = 'on' + event.type; + if (this[method]) { + this[method](event); + } +}; + +LoadingImage.prototype.onload = function () { + this.confirm(true, 'onload'); + this.unbindEvents(); +}; + +LoadingImage.prototype.onerror = function () { + this.confirm(false, 'onerror'); + this.unbindEvents(); +}; + +LoadingImage.prototype.unbindEvents = function () { + this.proxyImage.removeEventListener('load', this); + this.proxyImage.removeEventListener('error', this); + this.img.removeEventListener('load', this); + this.img.removeEventListener('error', this); +}; + +// -------------------------- Background -------------------------- // + +function Background(url, element) { + this.url = url; + this.element = element; + this.img = new Image(); +} + +// inherit LoadingImage prototype +Background.prototype = Object.create(LoadingImage.prototype); + +Background.prototype.check = function () { + this.img.addEventListener('load', this); + this.img.addEventListener('error', this); + this.img.src = this.url; + // check if image is already complete + let isComplete = this.getIsImageComplete(); + if (isComplete) { + this.confirm(this.img.naturalWidth !== 0, 'naturalWidth'); + this.unbindEvents(); + } +}; + +Background.prototype.unbindEvents = function () { + this.img.removeEventListener('load', this); + this.img.removeEventListener('error', this); +}; + +Background.prototype.confirm = function (isLoaded, message) { + this.isLoaded = isLoaded; + this.emitEvent('progress', [this, this.element, message]); +};