mirror of https://github.com/fantasticit/think.git
add scroll to cursor
This commit is contained in:
parent
edadc508e7
commit
922ecdf98f
|
@ -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;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
|
@ -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,
|
||||
];
|
||||
|
|
|
@ -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]);
|
||||
};
|
Loading…
Reference in New Issue