UNPKG

@ckeditor/ckeditor5-list

Version:

Ordered and unordered lists feature to CKEditor 5.

162 lines (161 loc) • 7.73 kB
/** * @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(); } }; }