@ckeditor/ckeditor5-minimap 41.3.1 → 41.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,514 @@
1
+ /**
2
+ * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
3
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
4
+ */
5
+ import { Plugin } from '@ckeditor/ckeditor5-core/dist/index.js';
6
+ import { toUnit, global, Rect, findClosestScrollableAncestor } from '@ckeditor/ckeditor5-utils/dist/index.js';
7
+ import { IframeView, View } from '@ckeditor/ckeditor5-ui/dist/index.js';
8
+ import { DomConverter, Renderer } from '@ckeditor/ckeditor5-engine/dist/index.js';
9
+
10
+ const toPx$1 = toUnit('px');
11
+ class MinimapIframeView extends IframeView {
12
+ /**
13
+ * @inheritDoc
14
+ */ render() {
15
+ return super.render().then(()=>{
16
+ this._prepareDocument();
17
+ });
18
+ }
19
+ /**
20
+ * Sets the new height of the iframe.
21
+ */ setHeight(newHeight) {
22
+ this.height = newHeight;
23
+ }
24
+ /**
25
+ * Sets the top offset of the iframe to move it around vertically.
26
+ */ setTopOffset(newOffset) {
27
+ this.top = newOffset;
28
+ }
29
+ /**
30
+ * Sets the internal structure of the `<iframe>` readying it to display the
31
+ * minimap element.
32
+ */ _prepareDocument() {
33
+ const iframeDocument = this.element.contentWindow.document;
34
+ const domRootClone = iframeDocument.adoptNode(this._options.domRootClone);
35
+ const boxStyles = this._options.useSimplePreview ? `
36
+ .ck.ck-editor__editable_inline img {
37
+ filter: contrast( 0 );
38
+ }
39
+
40
+ p, li, a, figcaption, span {
41
+ background: hsl(0, 0%, 80%) !important;
42
+ color: hsl(0, 0%, 80%) !important;
43
+ }
44
+
45
+ h1, h2, h3, h4 {
46
+ background: hsl(0, 0%, 60%) !important;
47
+ color: hsl(0, 0%, 60%) !important;
48
+ }
49
+ ` : '';
50
+ const pageStyles = this._options.pageStyles.map((definition)=>{
51
+ if (typeof definition === 'string') {
52
+ return `<style>${definition}</style>`;
53
+ } else {
54
+ return `<link rel="stylesheet" type="text/css" href="${definition.href}">`;
55
+ }
56
+ }).join('\n');
57
+ const html = `<!DOCTYPE html><html lang="en">
58
+ <head>
59
+ <meta charset="utf-8">
60
+ <meta name="viewport" content="width=device-width, initial-scale=1">
61
+ ${pageStyles}
62
+ <style>
63
+ html, body {
64
+ margin: 0 !important;
65
+ padding: 0 !important;
66
+ }
67
+
68
+ html {
69
+ overflow: hidden;
70
+ }
71
+
72
+ body {
73
+ transform: scale( ${this._options.scaleRatio} );
74
+ transform-origin: 0 0;
75
+ overflow: visible;
76
+ }
77
+
78
+ .ck.ck-editor__editable_inline {
79
+ margin: 0 !important;
80
+ border-color: transparent !important;
81
+ outline-color: transparent !important;
82
+ box-shadow: none !important;
83
+ }
84
+
85
+ .ck.ck-content {
86
+ background: white;
87
+ }
88
+
89
+ ${boxStyles}
90
+ </style>
91
+ </head>
92
+ <body class="${this._options.extraClasses || ''}"></body>
93
+ </html>`;
94
+ iframeDocument.open();
95
+ iframeDocument.write(html);
96
+ iframeDocument.close();
97
+ iframeDocument.body.appendChild(domRootClone);
98
+ }
99
+ /**
100
+ * Creates an instance of the internal minimap iframe.
101
+ */ constructor(locale, options){
102
+ super(locale);
103
+ const bind = this.bindTemplate;
104
+ this.set('top', 0);
105
+ this.set('height', 0);
106
+ this._options = options;
107
+ this.extendTemplate({
108
+ attributes: {
109
+ tabindex: -1,
110
+ 'aria-hidden': 'true',
111
+ class: [
112
+ 'ck-minimap__iframe'
113
+ ],
114
+ style: {
115
+ top: bind.to('top', (top)=>toPx$1(top)),
116
+ height: bind.to('height', (height)=>toPx$1(height))
117
+ }
118
+ }
119
+ });
120
+ }
121
+ }
122
+
123
+ const toPx = toUnit('px');
124
+ class MinimapPositionTrackerView extends View {
125
+ /**
126
+ * @inheritDoc
127
+ */ render() {
128
+ super.render();
129
+ this.listenTo(global.document, 'mousemove', (evt, data)=>{
130
+ if (!this._isDragging) {
131
+ return;
132
+ }
133
+ this.fire('drag', data.movementY);
134
+ }, {
135
+ useCapture: true
136
+ });
137
+ this.listenTo(global.document, 'mouseup', ()=>{
138
+ this._isDragging = false;
139
+ }, {
140
+ useCapture: true
141
+ });
142
+ }
143
+ /**
144
+ * Sets the new height of the tracker to visualize the subset of the content visible to the user.
145
+ */ setHeight(newHeight) {
146
+ this.height = newHeight;
147
+ }
148
+ /**
149
+ * Sets the top offset of the tracker to move it around vertically.
150
+ */ setTopOffset(newOffset) {
151
+ this.top = newOffset;
152
+ }
153
+ /**
154
+ * Sets the scroll progress (in %) to inform the user using a label when the tracker is being dragged.
155
+ */ setScrollProgress(newProgress) {
156
+ this.scrollProgress = newProgress;
157
+ }
158
+ constructor(locale){
159
+ super(locale);
160
+ const bind = this.bindTemplate;
161
+ this.set('height', 0);
162
+ this.set('top', 0);
163
+ this.set('scrollProgress', 0);
164
+ this.set('_isDragging', false);
165
+ this.setTemplate({
166
+ tag: 'div',
167
+ attributes: {
168
+ class: [
169
+ 'ck',
170
+ 'ck-minimap__position-tracker',
171
+ bind.if('_isDragging', 'ck-minimap__position-tracker_dragging')
172
+ ],
173
+ style: {
174
+ top: bind.to('top', (top)=>toPx(top)),
175
+ height: bind.to('height', (height)=>toPx(height))
176
+ },
177
+ 'data-progress': bind.to('scrollProgress')
178
+ },
179
+ on: {
180
+ mousedown: bind.to(()=>{
181
+ this._isDragging = true;
182
+ })
183
+ }
184
+ });
185
+ }
186
+ }
187
+
188
+ class MinimapView extends View {
189
+ /**
190
+ * @inheritDoc
191
+ */ destroy() {
192
+ this._minimapIframeView.destroy();
193
+ super.destroy();
194
+ }
195
+ /**
196
+ * Returns the DOM {@link module:utils/dom/rect~Rect} height of the minimap.
197
+ */ get height() {
198
+ return new Rect(this.element).height;
199
+ }
200
+ /**
201
+ * Returns the number of available space (pixels) the position tracker (visible subset of the content) can use to scroll vertically.
202
+ */ get scrollHeight() {
203
+ return Math.max(0, Math.min(this.height, this._minimapIframeView.height) - this._positionTrackerView.height);
204
+ }
205
+ /**
206
+ * @inheritDoc
207
+ */ render() {
208
+ super.render();
209
+ this._minimapIframeView.render();
210
+ this.element.appendChild(this._minimapIframeView.element);
211
+ }
212
+ /**
213
+ * Sets the new height of the minimap (in px) to respond to the changes in the original editing DOM root.
214
+ *
215
+ * **Note**:The provided value should be the `offsetHeight` of the original editing DOM root.
216
+ */ setContentHeight(newHeight) {
217
+ this._minimapIframeView.setHeight(newHeight * this._scaleRatio);
218
+ }
219
+ /**
220
+ * Sets the minimap scroll progress.
221
+ *
222
+ * The minimap scroll progress is linked to the original editing DOM root and its scrollable container (ancestor).
223
+ * Changing the progress will alter the vertical position of the minimap (and its position tracker) and give the user an accurate
224
+ * overview of the visible document.
225
+ *
226
+ * **Note**: The value should be between 0 and 1. 0 when the DOM root has not been scrolled, 1 when the
227
+ * scrolling has reached the end.
228
+ */ setScrollProgress(newScrollProgress) {
229
+ const iframeView = this._minimapIframeView;
230
+ const positionTrackerView = this._positionTrackerView;
231
+ // The scrolling should end when the bottom edge of the iframe touches the bottom edge of the minimap.
232
+ if (iframeView.height < this.height) {
233
+ iframeView.setTopOffset(0);
234
+ positionTrackerView.setTopOffset((iframeView.height - positionTrackerView.height) * newScrollProgress);
235
+ } else {
236
+ const totalOffset = iframeView.height - this.height;
237
+ iframeView.setTopOffset(-totalOffset * newScrollProgress);
238
+ positionTrackerView.setTopOffset((this.height - positionTrackerView.height) * newScrollProgress);
239
+ }
240
+ positionTrackerView.setScrollProgress(Math.round(newScrollProgress * 100));
241
+ }
242
+ /**
243
+ * Sets the new height of the tracker (in px) to visualize the subset of the content visible to the user.
244
+ */ setPositionTrackerHeight(trackerHeight) {
245
+ this._positionTrackerView.setHeight(trackerHeight * this._scaleRatio);
246
+ }
247
+ /**
248
+ * @param data DOM event data
249
+ */ _handleMinimapClick(data) {
250
+ const positionTrackerView = this._positionTrackerView;
251
+ if (data.target === positionTrackerView.element) {
252
+ return;
253
+ }
254
+ const trackerViewRect = new Rect(positionTrackerView.element);
255
+ const diff = data.clientY - trackerViewRect.top - trackerViewRect.height / 2;
256
+ const percentage = diff / this._minimapIframeView.height;
257
+ this.fire('click', percentage);
258
+ }
259
+ /**
260
+ * @param data DOM event data
261
+ */ _handleMinimapMouseWheel(data) {
262
+ this.fire('drag', data.deltaY * this._scaleRatio);
263
+ }
264
+ /**
265
+ * Creates an instance of the minimap view.
266
+ */ constructor({ locale, scaleRatio, pageStyles, extraClasses, useSimplePreview, domRootClone }){
267
+ super(locale);
268
+ const bind = this.bindTemplate;
269
+ this._positionTrackerView = new MinimapPositionTrackerView(locale);
270
+ this._positionTrackerView.delegate('drag').to(this);
271
+ this._scaleRatio = scaleRatio;
272
+ this._minimapIframeView = new MinimapIframeView(locale, {
273
+ useSimplePreview,
274
+ pageStyles,
275
+ extraClasses,
276
+ scaleRatio,
277
+ domRootClone
278
+ });
279
+ this.setTemplate({
280
+ tag: 'div',
281
+ attributes: {
282
+ class: [
283
+ 'ck',
284
+ 'ck-minimap'
285
+ ]
286
+ },
287
+ children: [
288
+ this._positionTrackerView
289
+ ],
290
+ on: {
291
+ click: bind.to(this._handleMinimapClick.bind(this)),
292
+ wheel: bind.to(this._handleMinimapMouseWheel.bind(this))
293
+ }
294
+ });
295
+ }
296
+ }
297
+
298
+ /**
299
+ * Clones the editing view DOM root by using a dedicated pair of {@link module:engine/view/renderer~Renderer} and
300
+ * {@link module:engine/view/domconverter~DomConverter}. The DOM root clone updates incrementally to stay in sync with the
301
+ * source root.
302
+ *
303
+ * @internal
304
+ * @param editor The editor instance the original editing root belongs to.
305
+ * @param rootName The name of the root to clone.
306
+ * @returns The editing root DOM clone element.
307
+ */ function cloneEditingViewDomRoot(editor, rootName) {
308
+ const viewDocument = editor.editing.view.document;
309
+ const viewRoot = viewDocument.getRoot(rootName);
310
+ const domConverter = new DomConverter(viewDocument);
311
+ const renderer = new Renderer(domConverter, viewDocument.selection);
312
+ const domRootClone = editor.editing.view.getDomRoot().cloneNode();
313
+ domConverter.bindElements(domRootClone, viewRoot);
314
+ renderer.markToSync('children', viewRoot);
315
+ renderer.markToSync('attributes', viewRoot);
316
+ viewRoot.on('change:children', (evt, node)=>renderer.markToSync('children', node));
317
+ viewRoot.on('change:attributes', (evt, node)=>renderer.markToSync('attributes', node));
318
+ viewRoot.on('change:text', (evt, node)=>renderer.markToSync('text', node));
319
+ renderer.render();
320
+ editor.editing.view.on('render', ()=>renderer.render());
321
+ // TODO: Cleanup after destruction.
322
+ editor.on('destroy', ()=>{
323
+ domConverter.unbindDomElement(domRootClone);
324
+ });
325
+ return domRootClone;
326
+ }
327
+ /**
328
+ * Harvests all web page styles, for instance, to allow re-using them in an `<iframe>` preserving the look of the content.
329
+ *
330
+ * The returned data format is as follows:
331
+ *
332
+ * ```ts
333
+ * [
334
+ * 'p { color: red; ... } h2 { font-size: 2em; ... } ...',
335
+ * '.spacing { padding: 1em; ... }; ...',
336
+ * '...',
337
+ * { href: 'http://link.to.external.stylesheet' },
338
+ * { href: '...' }
339
+ * ]
340
+ * ```
341
+ *
342
+ * **Note**: For stylesheets with `href` different than window origin, an object is returned because
343
+ * accessing rules of these styles may cause CORS errors (depending on the configuration of the web page).
344
+ *
345
+ * @internal
346
+ */ function getPageStyles() {
347
+ return Array.from(global.document.styleSheets).map((styleSheet)=>{
348
+ // CORS
349
+ if (styleSheet.href && !styleSheet.href.startsWith(global.window.location.origin)) {
350
+ return {
351
+ href: styleSheet.href
352
+ };
353
+ }
354
+ return Array.from(styleSheet.cssRules).filter((rule)=>!(rule instanceof CSSMediaRule)).map((rule)=>rule.cssText).join(' \n');
355
+ });
356
+ }
357
+ /**
358
+ * Gets dimensions rectangle according to passed DOM element. Returns whole window's size for `body` element.
359
+ *
360
+ * @internal
361
+ */ function getDomElementRect(domElement) {
362
+ return new Rect(domElement === global.document.body ? global.window : domElement);
363
+ }
364
+ /**
365
+ * Gets client height according to passed DOM element. Returns window's height for `body` element.
366
+ *
367
+ * @internal
368
+ */ function getClientHeight(domElement) {
369
+ return domElement === global.document.body ? global.window.innerHeight : domElement.clientHeight;
370
+ }
371
+ /**
372
+ * Returns the DOM element itself if it's not a `body` element, whole window otherwise.
373
+ *
374
+ * @internal
375
+ */ function getScrollable(domElement) {
376
+ return domElement === global.document.body ? global.window : domElement;
377
+ }
378
+
379
+ class Minimap extends Plugin {
380
+ /**
381
+ * @inheritDoc
382
+ */ static get pluginName() {
383
+ return 'Minimap';
384
+ }
385
+ /**
386
+ * @inheritDoc
387
+ */ init() {
388
+ const editor = this.editor;
389
+ this._minimapView = null;
390
+ this._scrollableRootAncestor = null;
391
+ this.listenTo(editor.ui, 'ready', this._onUiReady.bind(this));
392
+ }
393
+ /**
394
+ * @inheritDoc
395
+ */ destroy() {
396
+ super.destroy();
397
+ this._minimapView.destroy();
398
+ this._minimapView.element.remove();
399
+ }
400
+ /**
401
+ * Initializes the minimap view element and starts the layout synchronization
402
+ * on the editing view `render` event.
403
+ */ _onUiReady() {
404
+ const editor = this.editor;
405
+ // TODO: This will not work with the multi-root editor.
406
+ const editingRootElement = this._editingRootElement = editor.ui.getEditableElement();
407
+ this._scrollableRootAncestor = findClosestScrollableAncestor(editingRootElement);
408
+ // DOM root element is not yet attached to the document.
409
+ if (!editingRootElement.ownerDocument.body.contains(editingRootElement)) {
410
+ editor.ui.once('update', this._onUiReady.bind(this));
411
+ return;
412
+ }
413
+ this._initializeMinimapView();
414
+ this.listenTo(editor.editing.view, 'render', ()=>{
415
+ if (editor.state !== 'ready') {
416
+ return;
417
+ }
418
+ this._syncMinimapToEditingRootScrollPosition();
419
+ });
420
+ this._syncMinimapToEditingRootScrollPosition();
421
+ }
422
+ /**
423
+ * Initializes the minimap view and attaches listeners that make it responsive to the environment (document)
424
+ * but also allow the minimap to control the document (scroll position).
425
+ */ _initializeMinimapView() {
426
+ const editor = this.editor;
427
+ const locale = editor.locale;
428
+ const useSimplePreview = editor.config.get('minimap.useSimplePreview');
429
+ // TODO: Throw an error if there is no `minimap` in config.
430
+ const minimapContainerElement = editor.config.get('minimap.container');
431
+ const scrollableRootAncestor = this._scrollableRootAncestor;
432
+ // TODO: This should be dynamic, the root width could change as the viewport scales if not fixed unit.
433
+ const editingRootElementWidth = getDomElementRect(this._editingRootElement).width;
434
+ const minimapContainerWidth = getDomElementRect(minimapContainerElement).width;
435
+ const minimapScaleRatio = minimapContainerWidth / editingRootElementWidth;
436
+ const minimapView = this._minimapView = new MinimapView({
437
+ locale,
438
+ scaleRatio: minimapScaleRatio,
439
+ pageStyles: getPageStyles(),
440
+ extraClasses: editor.config.get('minimap.extraClasses'),
441
+ useSimplePreview,
442
+ domRootClone: cloneEditingViewDomRoot(editor)
443
+ });
444
+ minimapView.render();
445
+ // Scrollable ancestor scroll -> minimap position update.
446
+ minimapView.listenTo(global.document, 'scroll', (evt, data)=>{
447
+ if (scrollableRootAncestor === global.document.body) {
448
+ if (data.target !== global.document) {
449
+ return;
450
+ }
451
+ } else if (data.target !== scrollableRootAncestor) {
452
+ return;
453
+ }
454
+ this._syncMinimapToEditingRootScrollPosition();
455
+ }, {
456
+ useCapture: true,
457
+ usePassive: true
458
+ });
459
+ // Viewport resize -> minimap position update.
460
+ minimapView.listenTo(global.window, 'resize', ()=>{
461
+ this._syncMinimapToEditingRootScrollPosition();
462
+ });
463
+ // Dragging the visible content area -> document (scrollable) position update.
464
+ minimapView.on('drag', (evt, movementY)=>{
465
+ let movementYPercentage;
466
+ if (minimapView.scrollHeight === 0) {
467
+ movementYPercentage = 0;
468
+ } else {
469
+ movementYPercentage = movementY / minimapView.scrollHeight;
470
+ }
471
+ const absoluteScrollProgress = movementYPercentage * (scrollableRootAncestor.scrollHeight - getClientHeight(scrollableRootAncestor));
472
+ const scrollable = getScrollable(scrollableRootAncestor);
473
+ scrollable.scrollBy(0, Math.round(absoluteScrollProgress));
474
+ });
475
+ // Clicking the minimap -> center the document (scrollable) to the corresponding position.
476
+ minimapView.on('click', (evt, percentage)=>{
477
+ const absoluteScrollProgress = percentage * scrollableRootAncestor.scrollHeight;
478
+ const scrollable = getScrollable(scrollableRootAncestor);
479
+ scrollable.scrollBy(0, Math.round(absoluteScrollProgress));
480
+ });
481
+ minimapContainerElement.appendChild(minimapView.element);
482
+ }
483
+ /**
484
+ * @private
485
+ */ _syncMinimapToEditingRootScrollPosition() {
486
+ const editingRootElement = this._editingRootElement;
487
+ const minimapView = this._minimapView;
488
+ minimapView.setContentHeight(editingRootElement.offsetHeight);
489
+ const editingRootRect = getDomElementRect(editingRootElement);
490
+ const scrollableRootAncestorRect = getDomElementRect(this._scrollableRootAncestor);
491
+ let scrollProgress;
492
+ // @if CK_DEBUG_MINIMAP // RectDrawer.clear();
493
+ // @if CK_DEBUG_MINIMAP // RectDrawer.draw( scrollableRootAncestorRect, { outlineColor: 'red' }, 'scrollableRootAncestor' );
494
+ // @if CK_DEBUG_MINIMAP // RectDrawer.draw( editingRootRect, { outlineColor: 'green' }, 'editingRoot' );
495
+ // The root is completely visible in the scrollable ancestor.
496
+ if (scrollableRootAncestorRect.contains(editingRootRect)) {
497
+ scrollProgress = 0;
498
+ } else {
499
+ if (editingRootRect.top > scrollableRootAncestorRect.top) {
500
+ scrollProgress = 0;
501
+ } else {
502
+ scrollProgress = (editingRootRect.top - scrollableRootAncestorRect.top) / (scrollableRootAncestorRect.height - editingRootRect.height);
503
+ scrollProgress = Math.max(0, Math.min(scrollProgress, 1));
504
+ }
505
+ }
506
+ // The intersection helps to change the tracker height when there is a lot of padding around the root.
507
+ // Note: It is **essential** that the height is set first because the progress depends on the correct tracker height.
508
+ minimapView.setPositionTrackerHeight(scrollableRootAncestorRect.getIntersection(editingRootRect).height);
509
+ minimapView.setScrollProgress(scrollProgress);
510
+ }
511
+ }
512
+
513
+ export { Minimap };
514
+ //# sourceMappingURL=index.js.map