@ckeditor/ckeditor5-minimap
Version:
Content minimap feature for CKEditor 5.
179 lines (178 loc) • 7.85 kB
JavaScript
/**
* @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module minimap/minimap
*/
import { Plugin } from 'ckeditor5/src/core.js';
import { findClosestScrollableAncestor, global } from 'ckeditor5/src/utils.js';
import MinimapView from './minimapview.js';
import { cloneEditingViewDomRoot, getClientHeight, getDomElementRect, getPageStyles, getScrollable } from './utils.js';
// @if CK_DEBUG_MINIMAP // const RectDrawer = require( '@ckeditor/ckeditor5-utils/tests/_utils/rectdrawer' ).default;
import '../theme/minimap.css';
/**
* The content minimap feature.
*/
export default class Minimap extends Plugin {
/**
* @inheritDoc
*/
static get pluginName() {
return 'Minimap';
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* The reference to the view of the minimap.
*/
_minimapView;
/**
* The DOM element closest to the editable element of the editor as returned
* by {@link module:ui/editorui/editorui~EditorUI#getEditableElement}.
*/
_scrollableRootAncestor;
/**
* The DOM element closest to the editable element of the editor as returned
* by {@link module:ui/editorui/editorui~EditorUI#getEditableElement}.
*/
_editingRootElement;
/**
* @inheritDoc
*/
init() {
const editor = this.editor;
this._minimapView = null;
this._scrollableRootAncestor = null;
this.listenTo(editor.ui, 'ready', this._onUiReady.bind(this));
}
/**
* @inheritDoc
*/
destroy() {
super.destroy();
this._minimapView.destroy();
this._minimapView.element.remove();
}
/**
* Initializes the minimap view element and starts the layout synchronization
* on the editing view `render` event.
*/
_onUiReady() {
const editor = this.editor;
// TODO: This will not work with the multi-root editor.
const editingRootElement = this._editingRootElement = editor.ui.getEditableElement();
this._scrollableRootAncestor = findClosestScrollableAncestor(editingRootElement);
// DOM root element is not yet attached to the document.
if (!editingRootElement.ownerDocument.body.contains(editingRootElement)) {
editor.ui.once('update', this._onUiReady.bind(this));
return;
}
this._initializeMinimapView();
this.listenTo(editor.editing.view, 'render', () => {
if (editor.state !== 'ready') {
return;
}
this._syncMinimapToEditingRootScrollPosition();
});
this._syncMinimapToEditingRootScrollPosition();
}
/**
* Initializes the minimap view and attaches listeners that make it responsive to the environment (document)
* but also allow the minimap to control the document (scroll position).
*/
_initializeMinimapView() {
const editor = this.editor;
const locale = editor.locale;
const useSimplePreview = editor.config.get('minimap.useSimplePreview');
// TODO: Throw an error if there is no `minimap` in config.
const minimapContainerElement = editor.config.get('minimap.container');
const scrollableRootAncestor = this._scrollableRootAncestor;
// TODO: This should be dynamic, the root width could change as the viewport scales if not fixed unit.
const editingRootElementWidth = getDomElementRect(this._editingRootElement).width;
const minimapContainerWidth = getDomElementRect(minimapContainerElement).width;
const minimapScaleRatio = minimapContainerWidth / editingRootElementWidth;
const minimapView = this._minimapView = new MinimapView({
locale,
scaleRatio: minimapScaleRatio,
pageStyles: getPageStyles(),
extraClasses: editor.config.get('minimap.extraClasses'),
useSimplePreview,
domRootClone: cloneEditingViewDomRoot(editor)
});
minimapView.render();
// Scrollable ancestor scroll -> minimap position update.
minimapView.listenTo(global.document, 'scroll', (evt, data) => {
if (scrollableRootAncestor === global.document.body) {
if (data.target !== global.document) {
return;
}
}
else if (data.target !== scrollableRootAncestor) {
return;
}
this._syncMinimapToEditingRootScrollPosition();
}, { useCapture: true, usePassive: true });
// Viewport resize -> minimap position update.
minimapView.listenTo(global.window, 'resize', () => {
this._syncMinimapToEditingRootScrollPosition();
});
// Dragging the visible content area -> document (scrollable) position update.
minimapView.on('drag', (evt, movementY) => {
let movementYPercentage;
if (minimapView.scrollHeight === 0) {
movementYPercentage = 0;
}
else {
movementYPercentage = movementY / minimapView.scrollHeight;
}
const absoluteScrollProgress = movementYPercentage *
(scrollableRootAncestor.scrollHeight - getClientHeight(scrollableRootAncestor));
const scrollable = getScrollable(scrollableRootAncestor);
scrollable.scrollBy(0, Math.round(absoluteScrollProgress));
});
// Clicking the minimap -> center the document (scrollable) to the corresponding position.
minimapView.on('click', (evt, percentage) => {
const absoluteScrollProgress = percentage * scrollableRootAncestor.scrollHeight;
const scrollable = getScrollable(scrollableRootAncestor);
scrollable.scrollBy(0, Math.round(absoluteScrollProgress));
});
minimapContainerElement.appendChild(minimapView.element);
}
/**
* @private
*/
_syncMinimapToEditingRootScrollPosition() {
const editingRootElement = this._editingRootElement;
const minimapView = this._minimapView;
minimapView.setContentHeight(editingRootElement.offsetHeight);
const editingRootRect = getDomElementRect(editingRootElement);
const scrollableRootAncestorRect = getDomElementRect(this._scrollableRootAncestor);
let scrollProgress;
// @if CK_DEBUG_MINIMAP // RectDrawer.clear();
// @if CK_DEBUG_MINIMAP // RectDrawer.draw( scrollableRootAncestorRect, { outlineColor: 'red' }, 'scrollableRootAncestor' );
// @if CK_DEBUG_MINIMAP // RectDrawer.draw( editingRootRect, { outlineColor: 'green' }, 'editingRoot' );
// The root is completely visible in the scrollable ancestor.
if (scrollableRootAncestorRect.contains(editingRootRect)) {
scrollProgress = 0;
}
else {
if (editingRootRect.top > scrollableRootAncestorRect.top) {
scrollProgress = 0;
}
else {
scrollProgress = (editingRootRect.top - scrollableRootAncestorRect.top) /
(scrollableRootAncestorRect.height - editingRootRect.height);
scrollProgress = Math.max(0, Math.min(scrollProgress, 1));
}
}
// The intersection helps to change the tracker height when there is a lot of padding around the root.
// Note: It is **essential** that the height is set first because the progress depends on the correct tracker height.
minimapView.setPositionTrackerHeight(scrollableRootAncestorRect.getIntersection(editingRootRect).height);
minimapView.setScrollProgress(scrollProgress);
}
}