UNPKG

21.2 kBJavaScriptView Raw
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/**
6 * @module heading/title
7 */
8import { Plugin } from 'ckeditor5/src/core.js';
9import { first } from 'ckeditor5/src/utils.js';
10import { DowncastWriter, enablePlaceholder, hidePlaceholder, needsPlaceholder, showPlaceholder } from 'ckeditor5/src/engine.js';
11// A list of element names that should be treated by the Title plugin as title-like.
12// This means that an element of a type from this list will be changed to a title element
13// when it is the first element in the root.
14const titleLikeElements = new Set(['paragraph', 'heading1', 'heading2', 'heading3', 'heading4', 'heading5', 'heading6']);
15/**
16 * The Title plugin.
17 *
18 * It splits the document into `Title` and `Body` sections.
19 */
20export default class Title extends Plugin {
21 constructor() {
22 super(...arguments);
23 /**
24 * A reference to an empty paragraph in the body
25 * created when there is no element in the body for the placeholder purposes.
26 */
27 this._bodyPlaceholder = new Map();
28 }
29 /**
30 * @inheritDoc
31 */
32 static get pluginName() {
33 return 'Title';
34 }
35 /**
36 * @inheritDoc
37 */
38 static get requires() {
39 return ['Paragraph'];
40 }
41 /**
42 * @inheritDoc
43 */
44 init() {
45 const editor = this.editor;
46 const model = editor.model;
47 // To use the schema for disabling some features when the selection is inside the title element
48 // it is needed to create the following structure:
49 //
50 // <title>
51 // <title-content>The title text</title-content>
52 // </title>
53 //
54 // See: https://github.com/ckeditor/ckeditor5/issues/2005.
55 model.schema.register('title', { isBlock: true, allowIn: '$root' });
56 model.schema.register('title-content', { isBlock: true, allowIn: 'title', allowAttributes: ['alignment'] });
57 model.schema.extend('$text', { allowIn: 'title-content' });
58 // Disallow all attributes in `title-content`.
59 model.schema.addAttributeCheck(context => {
60 if (context.endsWith('title-content $text')) {
61 return false;
62 }
63 });
64 // Because `title` is represented by two elements in the model
65 // but only one in the view, it is needed to adjust Mapper.
66 editor.editing.mapper.on('modelToViewPosition', mapModelPositionToView(editor.editing.view));
67 editor.data.mapper.on('modelToViewPosition', mapModelPositionToView(editor.editing.view));
68 // Conversion.
69 editor.conversion.for('downcast').elementToElement({ model: 'title-content', view: 'h1' });
70 editor.conversion.for('downcast').add(dispatcher => dispatcher.on('insert:title', (evt, data, conversionApi) => {
71 conversionApi.consumable.consume(data.item, evt.name);
72 }));
73 // Custom converter is used for data v -> m conversion to avoid calling post-fixer when setting data.
74 // See https://github.com/ckeditor/ckeditor5/issues/2036.
75 editor.data.upcastDispatcher.on('element:h1', dataViewModelH1Insertion, { priority: 'high' });
76 editor.data.upcastDispatcher.on('element:h2', dataViewModelH1Insertion, { priority: 'high' });
77 editor.data.upcastDispatcher.on('element:h3', dataViewModelH1Insertion, { priority: 'high' });
78 // Take care about correct `title` element structure.
79 model.document.registerPostFixer(writer => this._fixTitleContent(writer));
80 // Create and take care of correct position of a `title` element.
81 model.document.registerPostFixer(writer => this._fixTitleElement(writer));
82 // Create element for `Body` placeholder if it is missing.
83 model.document.registerPostFixer(writer => this._fixBodyElement(writer));
84 // Prevent from adding extra at the end of the document.
85 model.document.registerPostFixer(writer => this._fixExtraParagraph(writer));
86 // Attach `Title` and `Body` placeholders to the empty title and/or content.
87 this._attachPlaceholders();
88 // Attach Tab handling.
89 this._attachTabPressHandling();
90 }
91 /**
92 * Returns the title of the document. Note that because this plugin does not allow any formatting inside
93 * the title element, the output of this method will be a plain text, with no HTML tags.
94 *
95 * It is not recommended to use this method together with features that insert markers to the
96 * data output, like comments or track changes features. If such markers start in the title and end in the
97 * body, the result of this method might be incorrect.
98 *
99 * @param options Additional configuration passed to the conversion process.
100 * See {@link module:engine/controller/datacontroller~DataController#get `DataController#get`}.
101 * @returns The title of the document.
102 */
103 getTitle(options = {}) {
104 const rootName = options.rootName ? options.rootName : undefined;
105 const titleElement = this._getTitleElement(rootName);
106 const titleContentElement = titleElement.getChild(0);
107 return this.editor.data.stringify(titleContentElement, options);
108 }
109 /**
110 * Returns the body of the document.
111 *
112 * Note that it is not recommended to use this method together with features that insert markers to the
113 * data output, like comments or track changes features. If such markers start in the title and end in the
114 * body, the result of this method might be incorrect.
115 *
116 * @param options Additional configuration passed to the conversion process.
117 * See {@link module:engine/controller/datacontroller~DataController#get `DataController#get`}.
118 * @returns The body of the document.
119 */
120 getBody(options = {}) {
121 const editor = this.editor;
122 const data = editor.data;
123 const model = editor.model;
124 const rootName = options.rootName ? options.rootName : undefined;
125 const root = editor.model.document.getRoot(rootName);
126 const view = editor.editing.view;
127 const viewWriter = new DowncastWriter(view.document);
128 const rootRange = model.createRangeIn(root);
129 const viewDocumentFragment = viewWriter.createDocumentFragment();
130 // Find all markers that intersects with body.
131 const bodyStartPosition = model.createPositionAfter(root.getChild(0));
132 const bodyRange = model.createRange(bodyStartPosition, model.createPositionAt(root, 'end'));
133 const markers = new Map();
134 for (const marker of model.markers) {
135 const intersection = bodyRange.getIntersection(marker.getRange());
136 if (intersection) {
137 markers.set(marker.name, intersection);
138 }
139 }
140 // Convert the entire root to view.
141 data.mapper.clearBindings();
142 data.mapper.bindElements(root, viewDocumentFragment);
143 data.downcastDispatcher.convert(rootRange, markers, viewWriter, options);
144 // Remove title element from view.
145 viewWriter.remove(viewWriter.createRangeOn(viewDocumentFragment.getChild(0)));
146 // view -> data
147 return editor.data.processor.toData(viewDocumentFragment);
148 }
149 /**
150 * Returns the `title` element when it is in the document. Returns `undefined` otherwise.
151 */
152 _getTitleElement(rootName) {
153 const root = this.editor.model.document.getRoot(rootName);
154 for (const child of root.getChildren()) {
155 if (isTitle(child)) {
156 return child;
157 }
158 }
159 }
160 /**
161 * Model post-fixer callback that ensures that `title` has only one `title-content` child.
162 * All additional children should be moved after the `title` element and renamed to a paragraph.
163 */
164 _fixTitleContent(writer) {
165 let changed = false;
166 for (const rootName of this.editor.model.document.getRootNames()) {
167 const title = this._getTitleElement(rootName);
168 // If there is no title in the content it will be created by `_fixTitleElement` post-fixer.
169 // If the title has just one element, then it is correct. No fixing.
170 if (!title || title.maxOffset === 1) {
171 continue;
172 }
173 const titleChildren = Array.from(title.getChildren());
174 // Skip first child because it is an allowed element.
175 titleChildren.shift();
176 for (const titleChild of titleChildren) {
177 writer.move(writer.createRangeOn(titleChild), title, 'after');
178 writer.rename(titleChild, 'paragraph');
179 }
180 changed = true;
181 }
182 return changed;
183 }
184 /**
185 * Model post-fixer callback that creates a title element when it is missing,
186 * takes care of the correct position of it and removes additional title elements.
187 */
188 _fixTitleElement(writer) {
189 let changed = false;
190 const model = this.editor.model;
191 for (const modelRoot of this.editor.model.document.getRoots()) {
192 const titleElements = Array.from(modelRoot.getChildren()).filter(isTitle);
193 const firstTitleElement = titleElements[0];
194 const firstRootChild = modelRoot.getChild(0);
195 // When title element is at the beginning of the document then try to fix additional title elements (if there are any).
196 if (firstRootChild.is('element', 'title')) {
197 if (titleElements.length > 1) {
198 fixAdditionalTitleElements(titleElements, writer, model);
199 changed = true;
200 }
201 continue;
202 }
203 // When there is no title in the document and first element in the document cannot be changed
204 // to the title then create an empty title element at the beginning of the document.
205 if (!firstTitleElement && !titleLikeElements.has(firstRootChild.name)) {
206 const title = writer.createElement('title');
207 writer.insert(title, modelRoot);
208 writer.insertElement('title-content', title);
209 changed = true;
210 continue;
211 }
212 if (titleLikeElements.has(firstRootChild.name)) {
213 // Change the first element in the document to the title if it can be changed (is title-like).
214 changeElementToTitle(firstRootChild, writer, model);
215 }
216 else {
217 // Otherwise, move the first occurrence of the title element to the beginning of the document.
218 writer.move(writer.createRangeOn(firstTitleElement), modelRoot, 0);
219 }
220 fixAdditionalTitleElements(titleElements, writer, model);
221 changed = true;
222 }
223 return changed;
224 }
225 /**
226 * Model post-fixer callback that adds an empty paragraph at the end of the document
227 * when it is needed for the placeholder purposes.
228 */
229 _fixBodyElement(writer) {
230 let changed = false;
231 for (const rootName of this.editor.model.document.getRootNames()) {
232 const modelRoot = this.editor.model.document.getRoot(rootName);
233 if (modelRoot.childCount < 2) {
234 const placeholder = writer.createElement('paragraph');
235 writer.insert(placeholder, modelRoot, 1);
236 this._bodyPlaceholder.set(rootName, placeholder);
237 changed = true;
238 }
239 }
240 return changed;
241 }
242 /**
243 * Model post-fixer callback that removes a paragraph from the end of the document
244 * if it was created for the placeholder purposes and is not needed anymore.
245 */
246 _fixExtraParagraph(writer) {
247 let changed = false;
248 for (const rootName of this.editor.model.document.getRootNames()) {
249 const root = this.editor.model.document.getRoot(rootName);
250 const placeholder = this._bodyPlaceholder.get(rootName);
251 if (shouldRemoveLastParagraph(placeholder, root)) {
252 this._bodyPlaceholder.delete(rootName);
253 writer.remove(placeholder);
254 changed = true;
255 }
256 }
257 return changed;
258 }
259 /**
260 * Attaches the `Title` and `Body` placeholders to the title and/or content.
261 */
262 _attachPlaceholders() {
263 const editor = this.editor;
264 const t = editor.t;
265 const view = editor.editing.view;
266 const sourceElement = editor.sourceElement;
267 const titlePlaceholder = editor.config.get('title.placeholder') || t('Type your title');
268 const bodyPlaceholder = editor.config.get('placeholder') ||
269 sourceElement && sourceElement.tagName.toLowerCase() === 'textarea' && sourceElement.getAttribute('placeholder') ||
270 t('Type or paste your content here.');
271 // Attach placeholder to the view title element.
272 editor.editing.downcastDispatcher.on('insert:title-content', (evt, data, conversionApi) => {
273 const element = conversionApi.mapper.toViewElement(data.item);
274 element.placeholder = titlePlaceholder;
275 enablePlaceholder({
276 view,
277 element,
278 keepOnFocus: true
279 });
280 });
281 // Attach placeholder to first element after a title element and remove it if it's not needed anymore.
282 // First element after title can change, so we need to observe all changes keep placeholder in sync.
283 const bodyViewElements = new Map();
284 // This post-fixer runs after the model post-fixer, so we can assume that the second child in view root will always exist.
285 view.document.registerPostFixer(writer => {
286 let hasChanged = false;
287 for (const viewRoot of view.document.roots) {
288 // `viewRoot` can be empty despite the model post-fixers if the model root was detached.
289 if (viewRoot.isEmpty) {
290 continue;
291 }
292 // If `viewRoot` is not empty, then we can expect at least two elements in it.
293 const body = viewRoot.getChild(1);
294 const oldBody = bodyViewElements.get(viewRoot.rootName);
295 // If body element has changed we need to disable placeholder on the previous element and enable on the new one.
296 if (body !== oldBody) {
297 if (oldBody) {
298 hidePlaceholder(writer, oldBody);
299 writer.removeAttribute('data-placeholder', oldBody);
300 }
301 writer.setAttribute('data-placeholder', bodyPlaceholder, body);
302 bodyViewElements.set(viewRoot.rootName, body);
303 hasChanged = true;
304 }
305 // Then we need to display placeholder if it is needed.
306 // See: https://github.com/ckeditor/ckeditor5/issues/8689.
307 if (needsPlaceholder(body, true) && viewRoot.childCount === 2 && body.name === 'p') {
308 hasChanged = showPlaceholder(writer, body) ? true : hasChanged;
309 }
310 else {
311 // Or hide if it is not needed.
312 hasChanged = hidePlaceholder(writer, body) ? true : hasChanged;
313 }
314 }
315 return hasChanged;
316 });
317 }
318 /**
319 * Creates navigation between the title and body sections using <kbd>Tab</kbd> and <kbd>Shift</kbd>+<kbd>Tab</kbd> keys.
320 */
321 _attachTabPressHandling() {
322 const editor = this.editor;
323 const model = editor.model;
324 // Pressing <kbd>Tab</kbd> inside the title should move the caret to the body.
325 editor.keystrokes.set('TAB', (data, cancel) => {
326 model.change(writer => {
327 const selection = model.document.selection;
328 const selectedElements = Array.from(selection.getSelectedBlocks());
329 if (selectedElements.length === 1 && selectedElements[0].is('element', 'title-content')) {
330 const root = selection.getFirstPosition().root;
331 const firstBodyElement = root.getChild(1);
332 writer.setSelection(firstBodyElement, 0);
333 cancel();
334 }
335 });
336 });
337 // Pressing <kbd>Shift</kbd>+<kbd>Tab</kbd> at the beginning of the body should move the caret to the title.
338 editor.keystrokes.set('SHIFT + TAB', (data, cancel) => {
339 model.change(writer => {
340 const selection = model.document.selection;
341 if (!selection.isCollapsed) {
342 return;
343 }
344 const selectedElement = first(selection.getSelectedBlocks());
345 const selectionPosition = selection.getFirstPosition();
346 const root = editor.model.document.getRoot(selectionPosition.root.rootName);
347 const title = root.getChild(0);
348 const body = root.getChild(1);
349 if (selectedElement === body && selectionPosition.isAtStart) {
350 writer.setSelection(title.getChild(0), 0);
351 cancel();
352 }
353 });
354 });
355 }
356}
357/**
358 * A view-to-model converter for the h1 that appears at the beginning of the document (a title element).
359 *
360 * @see module:engine/conversion/upcastdispatcher~UpcastDispatcher#event:element
361 * @param evt An object containing information about the fired event.
362 * @param data An object containing conversion input, a placeholder for conversion output and possibly other values.
363 * @param conversionApi Conversion interface to be used by the callback.
364 */
365function dataViewModelH1Insertion(evt, data, conversionApi) {
366 const modelCursor = data.modelCursor;
367 const viewItem = data.viewItem;
368 if (!modelCursor.isAtStart || !modelCursor.parent.is('element', '$root')) {
369 return;
370 }
371 if (!conversionApi.consumable.consume(viewItem, { name: true })) {
372 return;
373 }
374 const modelWriter = conversionApi.writer;
375 const title = modelWriter.createElement('title');
376 const titleContent = modelWriter.createElement('title-content');
377 modelWriter.append(titleContent, title);
378 modelWriter.insert(title, modelCursor);
379 conversionApi.convertChildren(viewItem, titleContent);
380 conversionApi.updateConversionResult(title, data);
381}
382/**
383 * Maps position from the beginning of the model `title` element to the beginning of the view `h1` element.
384 *
385 * ```html
386 * <title>^<title-content>Foo</title-content></title> -> <h1>^Foo</h1>
387 * ```
388 */
389function mapModelPositionToView(editingView) {
390 return (evt, data) => {
391 const positionParent = data.modelPosition.parent;
392 if (!positionParent.is('element', 'title')) {
393 return;
394 }
395 const modelTitleElement = positionParent.parent;
396 const viewElement = data.mapper.toViewElement(modelTitleElement);
397 data.viewPosition = editingView.createPositionAt(viewElement, 0);
398 evt.stop();
399 };
400}
401/**
402 * @returns Returns true when given element is a title. Returns false otherwise.
403 */
404function isTitle(element) {
405 return element.is('element', 'title');
406}
407/**
408 * Changes the given element to the title element.
409 */
410function changeElementToTitle(element, writer, model) {
411 const title = writer.createElement('title');
412 writer.insert(title, element, 'before');
413 writer.insert(element, title, 0);
414 writer.rename(element, 'title-content');
415 model.schema.removeDisallowedAttributes([element], writer);
416}
417/**
418 * Loops over the list of title elements and fixes additional ones.
419 *
420 * @returns Returns true when there was any change. Returns false otherwise.
421 */
422function fixAdditionalTitleElements(titleElements, writer, model) {
423 let hasChanged = false;
424 for (const title of titleElements) {
425 if (title.index !== 0) {
426 fixTitleElement(title, writer, model);
427 hasChanged = true;
428 }
429 }
430 return hasChanged;
431}
432/**
433 * Changes given title element to a paragraph or removes it when it is empty.
434 */
435function fixTitleElement(title, writer, model) {
436 const child = title.getChild(0);
437 // Empty title should be removed.
438 // It is created as a result of pasting to the title element.
439 if (child.isEmpty) {
440 writer.remove(title);
441 return;
442 }
443 writer.move(writer.createRangeOn(child), title, 'before');
444 writer.rename(child, 'paragraph');
445 writer.remove(title);
446 model.schema.removeDisallowedAttributes([child], writer);
447}
448/**
449 * Returns true when the last paragraph in the document was created only for the placeholder
450 * purpose and it's not needed anymore. Returns false otherwise.
451 */
452function shouldRemoveLastParagraph(placeholder, root) {
453 if (!placeholder || !placeholder.is('element', 'paragraph') || placeholder.childCount) {
454 return false;
455 }
456 if (root.childCount <= 2 || root.getChild(root.childCount - 1) !== placeholder) {
457 return false;
458 }
459 return true;
460}