@ckeditor/ckeditor5-list
Version:
Ordered and unordered lists feature to CKEditor 5.
162 lines (161 loc) • 7.73 kB
JavaScript
/**
* @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
*/
import { Plugin } from 'ckeditor5/src/core';
import { getCode, parseKeystroke, getLocalizedArrowKeyCodeDirection } from 'ckeditor5/src/utils';
import ListCommand from '../list/listcommand';
import ListEditing from '../list/listediting';
import CheckTodoListCommand from './checktodolistcommand';
import { dataModelViewInsertion, dataViewModelCheckmarkInsertion, mapModelToViewPosition, modelViewChangeChecked, modelViewChangeType, modelViewInsertion } from './todolistconverters';
const ITEM_TOGGLE_KEYSTROKE = parseKeystroke('Ctrl+Enter');
/**
* The engine of the to-do list feature. It handles creating, editing and removing to-do lists and their items.
*
* It registers the entire functionality of the {@link module:list/list/listediting~ListEditing list editing plugin} and extends
* it with the commands:
*
* - `'todoList'`,
* - `'checkTodoList'`,
* - `'todoListCheck'` as an alias for `checkTodoList` command.
*/
export default class TodoListEditing extends Plugin {
/**
* @inheritDoc
*/
static get pluginName() {
return 'TodoListEditing';
}
/**
* @inheritDoc
*/
static get requires() {
return [ListEditing];
}
/**
* @inheritDoc
*/
init() {
const editor = this.editor;
const { editing, data, model } = editor;
// Extend schema.
model.schema.extend('listItem', {
allowAttributes: ['todoListChecked']
});
// Disallow todoListChecked attribute on other nodes than listItem with to-do listType.
model.schema.addAttributeCheck((context, attributeName) => {
const item = context.last;
if (attributeName == 'todoListChecked' && item.name == 'listItem' && item.getAttribute('listType') != 'todo') {
return false;
}
});
// Register `todoList` command.
editor.commands.add('todoList', new ListCommand(editor, 'todo'));
const checkTodoListCommand = new CheckTodoListCommand(editor);
// Register `checkTodoList` command and add `todoListCheck` command as an alias for backward compatibility.
editor.commands.add('checkTodoList', checkTodoListCommand);
editor.commands.add('todoListCheck', checkTodoListCommand);
// Define converters.
data.downcastDispatcher.on('insert:listItem', dataModelViewInsertion(model), { priority: 'high' });
data.upcastDispatcher.on('element:input', dataViewModelCheckmarkInsertion, { priority: 'high' });
editing.downcastDispatcher.on('insert:listItem', modelViewInsertion(model, listItem => this._handleCheckmarkChange(listItem)), { priority: 'high' });
editing.downcastDispatcher.on('attribute:listType:listItem', modelViewChangeType(listItem => this._handleCheckmarkChange(listItem), editing.view));
editing.downcastDispatcher.on('attribute:todoListChecked:listItem', modelViewChangeChecked(listItem => this._handleCheckmarkChange(listItem)));
editing.mapper.on('modelToViewPosition', mapModelToViewPosition(editing.view));
data.mapper.on('modelToViewPosition', mapModelToViewPosition(editing.view));
// Jump at the end of the previous node on left arrow key press, when selection is after the checkbox.
//
// <blockquote><p>Foo</p></blockquote>
// <ul><li><checkbox/>{}Bar</li></ul>
//
// press: `<-`
//
// <blockquote><p>Foo{}</p></blockquote>
// <ul><li><checkbox/>Bar</li></ul>
//
this.listenTo(editing.view.document, 'arrowKey', jumpOverCheckmarkOnSideArrowKeyPress(model, editor.locale), { context: 'li' });
// Toggle check state of selected to-do list items on keystroke.
this.listenTo(editing.view.document, 'keydown', (evt, data) => {
if (getCode(data) === ITEM_TOGGLE_KEYSTROKE) {
editor.execute('checkTodoList');
evt.stop();
}
}, { priority: 'high' });
// Remove `todoListChecked` attribute when a host element is no longer a to-do list item.
const listItemsToFix = new Set();
this.listenTo(model, 'applyOperation', (evt, args) => {
const operation = args[0];
if (operation.type == 'rename' && operation.oldName == 'listItem') {
const item = operation.position.nodeAfter;
if (item.hasAttribute('todoListChecked')) {
listItemsToFix.add(item);
}
}
else if (operation.type == 'changeAttribute' && operation.key == 'listType' && operation.oldValue === 'todo') {
for (const item of operation.range.getItems()) {
if (item.hasAttribute('todoListChecked') && item.getAttribute('listType') !== 'todo') {
listItemsToFix.add(item);
}
}
}
});
model.document.registerPostFixer(writer => {
let hasChanged = false;
for (const listItem of listItemsToFix) {
writer.removeAttribute('todoListChecked', listItem);
hasChanged = true;
}
listItemsToFix.clear();
return hasChanged;
});
}
/**
* Handles the checkbox element change, moves the selection to the corresponding model item to make it possible
* to toggle the `todoListChecked` attribute using the command, and restores the selection position.
*
* Some say it's a hack :) Moving the selection only for executing the command on a certain node and restoring it after,
* is not a clear solution. We need to design an API for using commands beyond the selection range.
* See https://github.com/ckeditor/ckeditor5/issues/1954.
*/
_handleCheckmarkChange(listItem) {
const editor = this.editor;
const model = editor.model;
const previousSelectionRanges = Array.from(model.document.selection.getRanges());
model.change(writer => {
writer.setSelection(listItem, 'end');
editor.execute('checkTodoList');
writer.setSelection(previousSelectionRanges);
});
}
}
/**
* Handles the left/right (LTR/RTL content) arrow key and moves the selection at the end of the previous block element
* if the selection is just after the checkbox element. In other words, it jumps over the checkbox element when
* moving the selection to the left/right (LTR/RTL).
*
* @returns Callback for 'keydown' events.
*/
function jumpOverCheckmarkOnSideArrowKeyPress(model, locale) {
return (eventInfo, domEventData) => {
const direction = getLocalizedArrowKeyCodeDirection(domEventData.keyCode, locale.contentLanguageDirection);
if (direction != 'left') {
return;
}
const schema = model.schema;
const selection = model.document.selection;
if (!selection.isCollapsed) {
return;
}
const position = selection.getFirstPosition();
const parent = position.parent;
if (parent.name === 'listItem' && parent.getAttribute('listType') == 'todo' && position.isAtStart) {
const newRange = schema.getNearestSelectionRange(model.createPositionBefore(parent), 'backward');
if (newRange) {
model.change(writer => writer.setSelection(newRange));
}
domEventData.preventDefault();
domEventData.stopPropagation();
eventInfo.stop();
}
};
}