add scroll to cursor

This commit is contained in:
fantasticit 2022-12-18 12:25:20 +08:00
parent edadc508e7
commit 922ecdf98f
3 changed files with 503 additions and 0 deletions

View File

@ -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<Scroll2CursorOptions>({
name: 'scroll2Cursor',
addOptions() {
return {
delay: 100,
offsetTop: 64,
offsetBottom: 64,
scrollDistance: 96,
};
},
addProseMirrorPlugins() {
const { options, editor } = this;
let timeoutScroll: ReturnType<typeof setTimeout>;
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;
},
},
}),
];
},
});

View File

@ -48,6 +48,7 @@ import { Paragraph } from 'tiptap/core/extensions/paragraph';
import { Paste } from 'tiptap/core/extensions/paste'; import { Paste } from 'tiptap/core/extensions/paste';
import { Placeholder } from 'tiptap/core/extensions/placeholder'; import { Placeholder } from 'tiptap/core/extensions/placeholder';
import { QuickInsert } from 'tiptap/core/extensions/quick-insert'; 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 { SearchNReplace } from 'tiptap/core/extensions/search';
import { Status } from 'tiptap/core/extensions/status'; import { Status } from 'tiptap/core/extensions/status';
import { Strike } from 'tiptap/core/extensions/strike'; import { Strike } from 'tiptap/core/extensions/strike';
@ -186,4 +187,5 @@ export const CollaborationKit = [
Title, Title,
DocumentWithTitle, DocumentWithTitle,
Dragable, Dragable,
Scroll2Cursor,
]; ];

View File

@ -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 <picture> or self <img>
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]);
};