-
Notifications
You must be signed in to change notification settings - Fork 3.6k
/
legacytodolistconverters.ts
357 lines (294 loc) · 12.1 KB
/
legacytodolistconverters.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
/**
* @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 list/legacytodolist/legacytodolistconverters
*/
/* global document */
import type {
DowncastAttributeEvent,
DowncastInsertEvent,
DowncastWriter,
Element,
MapperModelToViewPositionEvent,
Model,
UpcastElementEvent,
EditingView,
ViewElement
} from 'ckeditor5/src/engine.js';
import { createElement, type GetCallback } from 'ckeditor5/src/utils.js';
import { generateLiInUl, injectViewList, positionAfterUiElements, findNestedList } from '../legacylist/legacyutils.js';
/**
* A model-to-view converter for the `listItem` model element insertion.
*
* It converts the `listItem` model element to an unordered list with a {@link module:engine/view/uielement~UIElement checkbox element}
* at the beginning of each list item. It also merges the list with surrounding lists (if available).
*
* It is used by {@link module:engine/controller/editingcontroller~EditingController}.
*
* @see module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:insert
* @param model Model instance.
* @param onCheckboxChecked Callback function.
* @returns Returns a conversion callback.
*/
export function modelViewInsertion(
model: Model,
onCheckboxChecked: ( element: Element ) => void
): GetCallback<DowncastInsertEvent<Element>> {
return ( evt, data, conversionApi ) => {
const consumable = conversionApi.consumable;
if ( !consumable.test( data.item, 'insert' ) ||
!consumable.test( data.item, 'attribute:listType' ) ||
!consumable.test( data.item, 'attribute:listIndent' )
) {
return;
}
if ( data.item.getAttribute( 'listType' ) != 'todo' ) {
return;
}
const modelItem = data.item;
consumable.consume( modelItem, 'insert' );
consumable.consume( modelItem, 'attribute:listType' );
consumable.consume( modelItem, 'attribute:listIndent' );
consumable.consume( modelItem, 'attribute:todoListChecked' );
const viewWriter = conversionApi.writer;
const viewItem = generateLiInUl( modelItem, conversionApi );
const isChecked = !!modelItem.getAttribute( 'todoListChecked' );
const checkmarkElement = createCheckmarkElement( modelItem, viewWriter, isChecked, onCheckboxChecked );
const span = viewWriter.createContainerElement( 'span', {
class: 'todo-list__label__description'
} );
viewWriter.addClass( 'todo-list', viewItem.parent as any );
viewWriter.insert( viewWriter.createPositionAt( viewItem, 0 ), checkmarkElement );
viewWriter.insert( viewWriter.createPositionAfter( checkmarkElement ), span );
injectViewList( modelItem, viewItem, conversionApi, model );
};
}
/**
* A model-to-view converter for the `listItem` model element insertion.
*
* It is used by {@link module:engine/controller/datacontroller~DataController}.
*
* @see module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:insert
* @param model Model instance.
* @returns Returns a conversion callback.
*/
export function dataModelViewInsertion( model: Model ): GetCallback<DowncastInsertEvent<Element>> {
return ( evt, data, conversionApi ) => {
const consumable = conversionApi.consumable;
if ( !consumable.test( data.item, 'insert' ) ||
!consumable.test( data.item, 'attribute:listType' ) ||
!consumable.test( data.item, 'attribute:listIndent' )
) {
return;
}
if ( data.item.getAttribute( 'listType' ) != 'todo' ) {
return;
}
const modelItem = data.item;
consumable.consume( modelItem, 'insert' );
consumable.consume( modelItem, 'attribute:listType' );
consumable.consume( modelItem, 'attribute:listIndent' );
consumable.consume( modelItem, 'attribute:todoListChecked' );
const viewWriter = conversionApi.writer;
const viewItem = generateLiInUl( modelItem, conversionApi );
viewWriter.addClass( 'todo-list', viewItem.parent as any );
const label = viewWriter.createContainerElement( 'label', {
class: 'todo-list__label'
} );
const checkbox = viewWriter.createEmptyElement( 'input', {
type: 'checkbox',
disabled: 'disabled'
} );
const span = viewWriter.createContainerElement( 'span', {
class: 'todo-list__label__description'
} );
if ( modelItem.getAttribute( 'todoListChecked' ) ) {
viewWriter.setAttribute( 'checked', 'checked', checkbox );
}
viewWriter.insert( viewWriter.createPositionAt( viewItem, 0 ), label );
viewWriter.insert( viewWriter.createPositionAt( label, 0 ), checkbox );
viewWriter.insert( viewWriter.createPositionAfter( checkbox ), span );
injectViewList( modelItem, viewItem, conversionApi, model );
};
}
/**
* A view-to-model converter for the checkbox element inside a view list item.
*
* It changes the `listType` of the model `listItem` to a `todo` value.
* When a view checkbox element is marked as checked, an additional `todoListChecked="true"` attribute is added to the model item.
*
* It is used by {@link module:engine/controller/datacontroller~DataController}.
*
* @see module:engine/conversion/upcastdispatcher~UpcastDispatcher#event:element
*/
export const dataViewModelCheckmarkInsertion: GetCallback<UpcastElementEvent> = ( evt, data, conversionApi ) => {
const modelCursor = data.modelCursor;
const modelItem = modelCursor.parent;
const viewItem = data.viewItem;
if ( viewItem.getAttribute( 'type' ) != 'checkbox' || modelItem.name != 'listItem' || !modelCursor.isAtStart ) {
return;
}
if ( !conversionApi.consumable.consume( viewItem, { name: true } ) ) {
return;
}
const writer = conversionApi.writer;
writer.setAttribute( 'listType', 'todo', modelItem );
if ( data.viewItem.hasAttribute( 'checked' ) ) {
writer.setAttribute( 'todoListChecked', true, modelItem );
}
data.modelRange = writer.createRange( modelCursor );
};
/**
* A model-to-view converter for the `listType` attribute change on the `listItem` model element.
*
* This change means that the `<li>` element parent changes to `<ul class="todo-list">` and a
* {@link module:engine/view/uielement~UIElement checkbox UI element} is added at the beginning
* of the list item element (or vice versa).
*
* This converter is preceded by {@link module:list/legacylist/legacyconverters~modelViewChangeType} and followed by
* {@link module:list/legacylist/legacyconverters~modelViewMergeAfterChangeType} to handle splitting and merging surrounding lists
* of the same type.
*
* It is used by {@link module:engine/controller/editingcontroller~EditingController}.
*
* @see module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:attribute
* @param onCheckedChange Callback fired after clicking the checkbox UI element.
* @param view Editing view controller.
* @returns Returns a conversion callback.
*/
export function modelViewChangeType(
onCheckedChange: ( element: Element ) => void,
view: EditingView
): GetCallback<DowncastAttributeEvent<Element>> {
return ( evt, data, conversionApi ) => {
if ( !conversionApi.consumable.consume( data.item, evt.name ) ) {
return;
}
const viewItem = conversionApi.mapper.toViewElement( data.item )!;
const viewWriter = conversionApi.writer;
const labelElement = findLabel( viewItem, view )!;
if ( data.attributeNewValue == 'todo' ) {
const isChecked = !!data.item.getAttribute( 'todoListChecked' );
const checkmarkElement = createCheckmarkElement( data.item, viewWriter, isChecked, onCheckedChange );
const span = viewWriter.createContainerElement( 'span', {
class: 'todo-list__label__description'
} );
const itemRange = viewWriter.createRangeIn( viewItem );
const nestedList = findNestedList( viewItem );
const descriptionStart = positionAfterUiElements( itemRange.start );
const descriptionEnd = nestedList ? viewWriter.createPositionBefore( nestedList ) : itemRange.end;
const descriptionRange = viewWriter.createRange( descriptionStart, descriptionEnd );
viewWriter.addClass( 'todo-list', viewItem.parent as any );
viewWriter.move( descriptionRange, viewWriter.createPositionAt( span, 0 ) );
viewWriter.insert( viewWriter.createPositionAt( viewItem, 0 ), checkmarkElement );
viewWriter.insert( viewWriter.createPositionAfter( checkmarkElement ), span );
} else if ( data.attributeOldValue == 'todo' ) {
const descriptionSpan = findDescription( viewItem, view )!;
viewWriter.removeClass( 'todo-list', viewItem.parent as any );
viewWriter.remove( labelElement );
viewWriter.move( viewWriter.createRangeIn( descriptionSpan ), viewWriter.createPositionBefore( descriptionSpan ) );
viewWriter.remove( descriptionSpan );
}
};
}
/**
* A model-to-view converter for the `todoListChecked` attribute change on the `listItem` model element.
*
* It marks the {@link module:engine/view/uielement~UIElement checkbox UI element} as checked.
*
* It is used by {@link module:engine/controller/editingcontroller~EditingController}.
*
* @see module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:attribute
* @param onCheckedChange Callback fired after clicking the checkbox UI element.
* @returns Returns a conversion callback.
*/
export function modelViewChangeChecked(
onCheckedChange: ( element: Element ) => void
): GetCallback<DowncastAttributeEvent<Element>> {
return ( evt, data, conversionApi ) => {
// Do not convert `todoListChecked` attribute when to-do list item has changed to other list item.
// This attribute will be removed by the model post fixer.
if ( data.item.getAttribute( 'listType' ) != 'todo' ) {
return;
}
if ( !conversionApi.consumable.consume( data.item, 'attribute:todoListChecked' ) ) {
return;
}
const { mapper, writer: viewWriter } = conversionApi;
const isChecked = !!data.item.getAttribute( 'todoListChecked' );
const viewItem = mapper.toViewElement( data.item )!;
// Because of m -> v position mapper we can be sure checkbox is always at the beginning.
const oldCheckmarkElement = viewItem.getChild( 0 )!;
const newCheckmarkElement = createCheckmarkElement( data.item, viewWriter, isChecked, onCheckedChange );
viewWriter.insert( viewWriter.createPositionAfter( oldCheckmarkElement ), newCheckmarkElement );
viewWriter.remove( oldCheckmarkElement );
};
}
/**
* A model-to-view position at zero offset mapper.
*
* This helper ensures that position inside todo-list in the view is mapped after the checkbox.
*
* It only handles the position at the beginning of a list item as other positions are properly mapped be the default mapper.
*/
export function mapModelToViewPosition( view: EditingView ): GetCallback<MapperModelToViewPositionEvent> {
return ( evt, data ) => {
const modelPosition = data.modelPosition;
const parent = modelPosition.parent;
if ( !parent.is( 'element', 'listItem' ) || parent.getAttribute( 'listType' ) != 'todo' ) {
return;
}
const viewLi = data.mapper.toViewElement( parent )!;
const descSpan = findDescription( viewLi, view );
if ( descSpan ) {
data.viewPosition = data.mapper.findPositionIn( descSpan, modelPosition.offset );
}
};
}
/**
* Creates a checkbox UI element.
*/
function createCheckmarkElement(
modelItem: Element,
viewWriter: DowncastWriter,
isChecked: boolean,
onChange: ( element: Element ) => void
) {
const uiElement = viewWriter.createUIElement(
'label',
{
class: 'todo-list__label',
contenteditable: false
},
function( domDocument ) {
const checkbox = createElement( document, 'input', { type: 'checkbox', tabindex: '-1' } );
if ( isChecked ) {
checkbox.setAttribute( 'checked', 'checked' );
}
checkbox.addEventListener( 'change', () => onChange( modelItem ) );
const domElement = this.toDomElement( domDocument );
domElement.appendChild( checkbox );
return domElement;
}
);
return uiElement;
}
// Helper method to find label element inside li.
function findLabel( viewItem: ViewElement, view: EditingView ) {
const range = view.createRangeIn( viewItem );
for ( const value of range ) {
if ( value.item.is( 'uiElement', 'label' ) ) {
return value.item;
}
}
}
function findDescription( viewItem: ViewElement, view: EditingView ) {
const range = view.createRangeIn( viewItem );
for ( const value of range ) {
if ( value.item.is( 'containerElement', 'span' ) && value.item.hasClass( 'todo-list__label__description' ) ) {
return value.item;
}
}
}