mirror of https://github.com/fantasticit/think.git
add scroll to cursor
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 { 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,
|
||||||
];
|
];
|
||||||
|
|
|
@ -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