-
Notifications
You must be signed in to change notification settings - Fork 3.6k
/
clipboardpipeline.ts
524 lines (459 loc) · 20.4 KB
/
clipboardpipeline.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
/**
* @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
*/
/**
* @module clipboard/clipboardpipeline
*/
import { Plugin } from '@ckeditor/ckeditor5-core';
import { EventInfo } from '@ckeditor/ckeditor5-utils';
import type {
DataTransfer,
DocumentFragment,
DomEventData,
Range,
ViewDocumentFragment,
ViewRange,
Selection,
DocumentSelection
} from '@ckeditor/ckeditor5-engine';
import ClipboardObserver, {
type ClipboardEventData,
type ViewDocumentCopyEvent,
type ViewDocumentCutEvent,
type ViewDocumentClipboardInputEvent
} from './clipboardobserver.js';
import plainTextToHtml from './utils/plaintexttohtml.js';
import normalizeClipboardHtml from './utils/normalizeclipboarddata.js';
import viewToPlainText from './utils/viewtoplaintext.js';
import ClipboardMarkersUtils from './clipboardmarkersutils.js';
// Input pipeline events overview:
//
// ┌──────────────────────┐ ┌──────────────────────┐
// │ view.Document │ │ view.Document │
// │ paste │ │ drop │
// └───────────┬──────────┘ └───────────┬──────────┘
// │ │
// └────────────────┌────────────────┘
// │
// ┌─────────V────────┐
// │ view.Document │ Retrieves text/html or text/plain from data.dataTransfer
// │ clipboardInput │ and processes it to view.DocumentFragment.
// └─────────┬────────┘
// │
// ┌───────────V───────────┐
// │ ClipboardPipeline │ Converts view.DocumentFragment to model.DocumentFragment.
// │ inputTransformation │
// └───────────┬───────────┘
// │
// ┌──────────V──────────┐
// │ ClipboardPipeline │ Calls model.insertContent().
// │ contentInsertion │
// └─────────────────────┘
//
//
// Output pipeline events overview:
//
// ┌──────────────────────┐ ┌──────────────────────┐
// │ view.Document │ │ view.Document │ Retrieves the selected model.DocumentFragment
// │ copy │ │ cut │ and fires the `outputTransformation` event.
// └───────────┬──────────┘ └───────────┬──────────┘
// │ │
// └────────────────┌────────────────┘
// │
// ┌───────────V───────────┐
// │ ClipboardPipeline │ Processes model.DocumentFragment and converts it to
// │ outputTransformation │ view.DocumentFragment.
// └───────────┬───────────┘
// │
// ┌─────────V────────┐
// │ view.Document │ Processes view.DocumentFragment to text/html and text/plain
// │ clipboardOutput │ and stores the results in data.dataTransfer.
// └──────────────────┘
//
/**
* The clipboard pipeline feature. It is responsible for intercepting the `paste` and `drop` events and
* passing the pasted content through a series of events in order to insert it into the editor's content.
* It also handles the `cut` and `copy` events to fill the native clipboard with the serialized editor's data.
*
* # Input pipeline
*
* The behavior of the default handlers (all at a `low` priority):
*
* ## Event: `paste` or `drop`
*
* 1. Translates the event data.
* 2. Fires the {@link module:engine/view/document~Document#event:clipboardInput `view.Document#clipboardInput`} event.
*
* ## Event: `view.Document#clipboardInput`
*
* 1. If the `data.content` event field is already set (by some listener on a higher priority), it takes this content and fires the event
* from the last point.
* 2. Otherwise, it retrieves `text/html` or `text/plain` from `data.dataTransfer`.
* 3. Normalizes the raw data by applying simple filters on string data.
* 4. Processes the raw data to {@link module:engine/view/documentfragment~DocumentFragment `view.DocumentFragment`} with the
* {@link module:engine/controller/datacontroller~DataController#htmlProcessor `DataController#htmlProcessor`}.
* 5. Fires the {@link module:clipboard/clipboardpipeline~ClipboardPipeline#event:inputTransformation
* `ClipboardPipeline#inputTransformation`} event with the view document fragment in the `data.content` event field.
*
* ## Event: `ClipboardPipeline#inputTransformation`
*
* 1. Converts {@link module:engine/view/documentfragment~DocumentFragment `view.DocumentFragment`} from the `data.content` field to
* {@link module:engine/model/documentfragment~DocumentFragment `model.DocumentFragment`}.
* 2. Fires the {@link module:clipboard/clipboardpipeline~ClipboardPipeline#event:contentInsertion `ClipboardPipeline#contentInsertion`}
* event with the model document fragment in the `data.content` event field.
* **Note**: The `ClipboardPipeline#contentInsertion` event is fired within a model change block to allow other handlers
* to run in the same block without post-fixers called in between (i.e., the selection post-fixer).
*
* ## Event: `ClipboardPipeline#contentInsertion`
*
* 1. Calls {@link module:engine/model/model~Model#insertContent `model.insertContent()`} to insert `data.content`
* at the current selection position.
*
* # Output pipeline
*
* The behavior of the default handlers (all at a `low` priority):
*
* ## Event: `copy`, `cut` or `dragstart`
*
* 1. Retrieves the selected {@link module:engine/model/documentfragment~DocumentFragment `model.DocumentFragment`} by calling
* {@link module:engine/model/model~Model#getSelectedContent `model#getSelectedContent()`}.
* 2. Converts the model document fragment to {@link module:engine/view/documentfragment~DocumentFragment `view.DocumentFragment`}.
* 3. Fires the {@link module:engine/view/document~Document#event:clipboardOutput `view.Document#clipboardOutput`} event
* with the view document fragment in the `data.content` event field.
*
* ## Event: `view.Document#clipboardOutput`
*
* 1. Processes `data.content` to HTML and plain text with the
* {@link module:engine/controller/datacontroller~DataController#htmlProcessor `DataController#htmlProcessor`}.
* 2. Updates the `data.dataTransfer` data for `text/html` and `text/plain` with the processed data.
* 3. For the `cut` method, calls {@link module:engine/model/model~Model#deleteContent `model.deleteContent()`}
* on the current selection.
*
* Read more about the clipboard integration in the {@glink framework/deep-dive/clipboard clipboard deep-dive} guide.
*/
export default class ClipboardPipeline extends Plugin {
/**
* @inheritDoc
*/
public static get pluginName() {
return 'ClipboardPipeline' as const;
}
/**
* @inheritDoc
*/
public static get requires() {
return [ ClipboardMarkersUtils ] as const;
}
/**
* @inheritDoc
*/
public init(): void {
const editor = this.editor;
const view = editor.editing.view;
view.addObserver( ClipboardObserver );
this._setupPasteDrop();
this._setupCopyCut();
}
/**
* Fires Clipboard `'outputTransformation'` event for given parameters.
*
* @internal
*/
public _fireOutputTransformationEvent(
dataTransfer: DataTransfer,
selection: Selection | DocumentSelection,
method: 'copy' | 'cut' | 'dragstart'
): void {
const clipboardMarkersUtils: ClipboardMarkersUtils = this.editor.plugins.get( 'ClipboardMarkersUtils' );
this.editor.model.enqueueChange( { isUndoable: method === 'cut' }, () => {
const documentFragment = clipboardMarkersUtils._copySelectedFragmentWithMarkers( method, selection );
this.fire<ClipboardOutputTransformationEvent>( 'outputTransformation', {
dataTransfer,
content: documentFragment,
method
} );
} );
}
/**
* The clipboard paste pipeline.
*/
private _setupPasteDrop(): void {
const editor = this.editor;
const model = editor.model;
const view = editor.editing.view;
const viewDocument = view.document;
const clipboardMarkersUtils: ClipboardMarkersUtils = this.editor.plugins.get( 'ClipboardMarkersUtils' );
// Pasting is disabled when selection is in non-editable place.
// Dropping is disabled in drag and drop handler.
this.listenTo<ViewDocumentClipboardInputEvent>( viewDocument, 'clipboardInput', ( evt, data ) => {
if ( data.method == 'paste' && !editor.model.canEditAt( editor.model.document.selection ) ) {
evt.stop();
}
}, { priority: 'highest' } );
this.listenTo<ViewDocumentClipboardInputEvent>( viewDocument, 'clipboardInput', ( evt, data ) => {
const dataTransfer = data.dataTransfer;
let content: ViewDocumentFragment;
// Some feature could already inject content in the higher priority event handler (i.e., codeBlock).
if ( data.content ) {
content = data.content;
} else {
let contentData = '';
if ( dataTransfer.getData( 'text/html' ) ) {
contentData = normalizeClipboardHtml( dataTransfer.getData( 'text/html' ) );
} else if ( dataTransfer.getData( 'text/plain' ) ) {
contentData = plainTextToHtml( dataTransfer.getData( 'text/plain' ) );
}
content = this.editor.data.htmlProcessor.toView( contentData );
}
const eventInfo = new EventInfo( this, 'inputTransformation' );
this.fire<ClipboardInputTransformationEvent>( eventInfo, {
content,
dataTransfer,
targetRanges: data.targetRanges,
method: data.method as 'paste' | 'drop'
} );
// If CKEditor handled the input, do not bubble the original event any further.
// This helps external integrations recognize this fact and act accordingly.
// https://github.com/ckeditor/ckeditor5-upload/issues/92
if ( eventInfo.stop.called ) {
evt.stop();
}
view.scrollToTheSelection();
}, { priority: 'low' } );
this.listenTo<ClipboardInputTransformationEvent>( this, 'inputTransformation', ( evt, data ) => {
if ( data.content.isEmpty ) {
return;
}
const dataController = this.editor.data;
// Convert the pasted content into a model document fragment.
// The conversion is contextual, but in this case an "all allowed" context is needed
// and for that we use the $clipboardHolder item.
const modelFragment = dataController.toModel( data.content, '$clipboardHolder' );
if ( modelFragment.childCount == 0 ) {
return;
}
evt.stop();
// Fire content insertion event in a single change block to allow other handlers to run in the same block
// without post-fixers called in between (i.e., the selection post-fixer).
model.change( () => {
this.fire<ClipboardContentInsertionEvent>( 'contentInsertion', {
content: modelFragment,
method: data.method,
dataTransfer: data.dataTransfer,
targetRanges: data.targetRanges
} );
} );
}, { priority: 'low' } );
this.listenTo<ClipboardContentInsertionEvent>( this, 'contentInsertion', ( evt, data ) => {
data.resultRange = clipboardMarkersUtils._pasteFragmentWithMarkers( data.content );
}, { priority: 'low' } );
}
/**
* The clipboard copy/cut pipeline.
*/
private _setupCopyCut(): void {
const editor = this.editor;
const modelDocument = editor.model.document;
const view = editor.editing.view;
const viewDocument = view.document;
const onCopyCut = ( evt: EventInfo<'copy' | 'cut'>, data: DomEventData<ClipboardEvent> & ClipboardEventData ) => {
const dataTransfer = data.dataTransfer;
data.preventDefault();
this._fireOutputTransformationEvent( dataTransfer, modelDocument.selection, evt.name );
};
this.listenTo<ViewDocumentCopyEvent>( viewDocument, 'copy', onCopyCut, { priority: 'low' } );
this.listenTo<ViewDocumentCutEvent>( viewDocument, 'cut', ( evt, data ) => {
// Cutting is disabled when selection is in non-editable place.
// See: https://github.com/ckeditor/ckeditor5-clipboard/issues/26.
if ( !editor.model.canEditAt( editor.model.document.selection ) ) {
data.preventDefault();
} else {
onCopyCut( evt, data );
}
}, { priority: 'low' } );
this.listenTo<ClipboardOutputTransformationEvent>( this, 'outputTransformation', ( evt, data ) => {
const content = editor.data.toView( data.content );
viewDocument.fire<ViewDocumentClipboardOutputEvent>( 'clipboardOutput', {
dataTransfer: data.dataTransfer,
content,
method: data.method
} );
}, { priority: 'low' } );
this.listenTo<ViewDocumentClipboardOutputEvent>( viewDocument, 'clipboardOutput', ( evt, data ) => {
if ( !data.content.isEmpty ) {
data.dataTransfer.setData( 'text/html', this.editor.data.htmlProcessor.toData( data.content ) );
data.dataTransfer.setData( 'text/plain', viewToPlainText( data.content ) );
}
if ( data.method == 'cut' ) {
editor.model.deleteContent( modelDocument.selection );
}
}, { priority: 'low' } );
}
}
/**
* Fired with the `content`, `dataTransfer`, `method`, and `targetRanges` properties:
*
* * The `content` which comes from the clipboard (it was pasted or dropped) should be processed in order to be inserted into the editor.
* * The `dataTransfer` object is available in case the transformation functions need access to the raw clipboard data.
* * The `method` indicates the original DOM event (for example `'drop'` or `'paste'`).
* * The `targetRanges` property is an array of view ranges (it is available only for `'drop'`).
*
* It is a part of the {@glink framework/deep-dive/clipboard#input-pipeline clipboard input pipeline}.
*
* **Note**: You should not stop this event if you want to change the input data. You should modify the `content` property instead.
*
* @see module:clipboard/clipboardobserver~ClipboardObserver
* @see module:clipboard/clipboardpipeline~ClipboardPipeline
*
* @eventName ~ClipboardPipeline#inputTransformation
* @param data The event data.
*/
export type ClipboardInputTransformationEvent = {
name: 'inputTransformation';
args: [ data: ClipboardInputTransformationData ];
};
/**
* The data of 'inputTransformation' event.
*/
export interface ClipboardInputTransformationData {
/**
* The event data.
* The content to be inserted into the editor. It can be modified by event listeners. Read more about the clipboard pipelines in
* the {@glink framework/deep-dive/clipboard clipboard deep-dive} guide.
*/
content: ViewDocumentFragment;
/**
* The data transfer instance.
*/
dataTransfer: DataTransfer;
/**
* The target drop ranges.
*/
targetRanges: Array<ViewRange> | null;
/**
* Whether the event was triggered by a paste or a drop operation.
*/
method: 'paste' | 'drop';
}
/**
* Fired with the `content`, `dataTransfer`, `method`, and `targetRanges` properties:
*
* * The `content` which comes from the clipboard (was pasted or dropped) should be processed in order to be inserted into the editor.
* * The `dataTransfer` object is available in case the transformation functions need access to the raw clipboard data.
* * The `method` indicates the original DOM event (for example `'drop'` or `'paste'`).
* * The `targetRanges` property is an array of view ranges (it is available only for `'drop'`).
*
* Event handlers can modify the content according to the final insertion position.
*
* It is a part of the {@glink framework/deep-dive/clipboard#input-pipeline clipboard input pipeline}.
*
* **Note**: You should not stop this event if you want to change the input data. You should modify the `content` property instead.
*
* @see module:clipboard/clipboardobserver~ClipboardObserver
* @see module:clipboard/clipboardpipeline~ClipboardPipeline
* @see module:clipboard/clipboardpipeline~ClipboardPipeline#event:inputTransformation
*
* @eventName ~ClipboardPipeline#contentInsertion
* @param data The event data.
*/
export type ClipboardContentInsertionEvent = {
name: 'contentInsertion';
args: [ data: ClipboardContentInsertionData ];
};
/**
* The data of 'contentInsertion' event.
*/
export interface ClipboardContentInsertionData {
/**
* The content to be inserted into the editor.
* Read more about the clipboard pipelines in the {@glink framework/deep-dive/clipboard clipboard deep-dive} guide.
*/
content: DocumentFragment;
/**
* Whether the event was triggered by a paste or a drop operation.
*/
method: 'paste' | 'drop';
/**
* The data transfer instance.
*/
dataTransfer: DataTransfer;
/**
* The target drop ranges.
*/
targetRanges: Array<ViewRange> | null;
/**
* The result of the `model.insertContent()` call
* (inserted by the event handler at a low priority).
*/
resultRange?: Range;
}
/**
* Fired on {@link module:engine/view/document~Document#event:copy} and {@link module:engine/view/document~Document#event:cut}
* with a copy of the selected content. The content can be processed before it ends up in the clipboard.
*
* It is a part of the {@glink framework/deep-dive/clipboard#output-pipeline clipboard output pipeline}.
*
* @see module:clipboard/clipboardobserver~ClipboardObserver
* @see module:clipboard/clipboardpipeline~ClipboardPipeline
*
* @eventName module:engine/view/document~Document#clipboardOutput
* @param data The event data.
*/
export type ViewDocumentClipboardOutputEvent = {
name: 'clipboardOutput';
args: [ data: ViewDocumentClipboardOutputEventData ];
};
/**
* The value of the 'clipboardOutput' event.
*/
export interface ViewDocumentClipboardOutputEventData {
/**
* The data transfer instance.
*
* @readonly
*/
dataTransfer: DataTransfer;
/**
* Content to be put into the clipboard. It can be modified by the event listeners.
* Read more about the clipboard pipelines in the {@glink framework/deep-dive/clipboard clipboard deep-dive} guide.
*/
content: ViewDocumentFragment;
/**
* Whether the event was triggered by a copy or cut operation.
*/
method: 'copy' | 'cut' | 'dragstart';
}
/**
* Fired on {@link module:engine/view/document~Document#event:copy}, {@link module:engine/view/document~Document#event:cut}
* and {@link module:engine/view/document~Document#event:dragstart}. The content can be processed before it ends up in the clipboard.
*
* It is a part of the {@glink framework/deep-dive/clipboard#output-pipeline clipboard output pipeline}.
*
* @eventName ~ClipboardPipeline#outputTransformation
* @param data The event data.
*/
export type ClipboardOutputTransformationEvent = {
name: 'outputTransformation';
args: [ data: ClipboardOutputTransformationData ];
};
/**
* The value of the 'outputTransformation' event.
*/
export interface ClipboardOutputTransformationData {
/**
* The data transfer instance.
*
* @readonly
*/
dataTransfer: DataTransfer;
/**
* Content to be put into the clipboard. It can be modified by the event listeners.
* Read more about the clipboard pipelines in the {@glink framework/deep-dive/clipboard clipboard deep-dive} guide.
*/
content: DocumentFragment;
/**
* Whether the event was triggered by a copy or cut operation.
*/
method: 'copy' | 'cut' | 'dragstart';
}