@ckeditor/ckeditor5-heading
Version:
Headings feature for CKEditor 5.
915 lines (906 loc) • 33 kB
JavaScript
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
import { Command, Plugin } from "@ckeditor/ckeditor5-core";
import { Paragraph } from "@ckeditor/ckeditor5-paragraph";
import { Collection, first, logWarning, priorities } from "@ckeditor/ckeditor5-utils";
import { ButtonView, MenuBarMenuListItemButtonView, MenuBarMenuListItemView, MenuBarMenuListView, MenuBarMenuView, UIModel, addListToDropdown, createDropdown } from "@ckeditor/ckeditor5-ui";
import { IconHeading1, IconHeading2, IconHeading3, IconHeading4, IconHeading5, IconHeading6 } from "@ckeditor/ckeditor5-icons";
import { ViewDowncastWriter, enableViewPlaceholder, hideViewPlaceholder, needsViewPlaceholder, showViewPlaceholder } from "@ckeditor/ckeditor5-engine";
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module heading/headingcommand
*/
/**
* The heading command. It is used by the {@link module:heading/heading~Heading heading feature} to apply headings.
*/
var HeadingCommand = class extends Command {
/**
* Set of defined model's elements names that this command support.
* See {@link module:heading/headingconfig~HeadingOption}.
*/
modelElements;
/**
* Creates an instance of the command.
*
* @param editor Editor instance.
* @param modelElements Names of the element which this command can apply in the model.
*/
constructor(editor, modelElements) {
super(editor);
this.modelElements = modelElements;
}
/**
* @inheritDoc
*/
refresh() {
const block = first(this.editor.model.document.selection.getSelectedBlocks());
this.value = !!block && this.modelElements.includes(block.name) && block.name;
this.isEnabled = !!block && this.modelElements.some((heading) => checkCanBecomeHeading(block, heading, this.editor.model.schema));
}
/**
* Executes the command. Applies the heading to the selected blocks or, if the first selected
* block is a heading already, turns selected headings (of this level only) to paragraphs.
*
* @param options.value Name of the element which this command will apply in the model.
* @fires execute
*/
execute(options) {
const model = this.editor.model;
const document = model.document;
const modelElement = options.value;
model.change((writer) => {
const blocks = Array.from(document.selection.getSelectedBlocks()).filter((block) => {
return checkCanBecomeHeading(block, modelElement, model.schema);
});
for (const block of blocks) if (!block.is("element", modelElement)) writer.rename(block, modelElement);
});
}
};
/**
* Checks whether the given block can be replaced by a specific heading.
*
* @param block A block to be tested.
* @param heading Command element name in the model.
* @param schema The schema of the document.
*/
function checkCanBecomeHeading(block, heading, schema) {
return schema.checkChild(block.parent, heading) && !schema.isObject(block);
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module heading/headingediting
*/
const defaultModelElement = "paragraph";
/**
* The headings engine feature. It handles switching between block formats – headings and paragraph.
* This class represents the engine part of the heading feature. See also {@link module:heading/heading~Heading}.
* It introduces `heading1`-`headingN` commands which allow to convert paragraphs into headings.
*/
var HeadingEditing = class extends Plugin {
/**
* @inheritDoc
*/
static get pluginName() {
return "HeadingEditing";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* @inheritDoc
*/
constructor(editor) {
super(editor);
editor.config.define("heading", { options: [
{
model: "paragraph",
title: "Paragraph",
class: "ck-heading_paragraph"
},
{
model: "heading1",
view: "h2",
title: "Heading 1",
class: "ck-heading_heading1"
},
{
model: "heading2",
view: "h3",
title: "Heading 2",
class: "ck-heading_heading2"
},
{
model: "heading3",
view: "h4",
title: "Heading 3",
class: "ck-heading_heading3"
}
] });
}
/**
* @inheritDoc
*/
static get requires() {
return [Paragraph];
}
/**
* @inheritDoc
*/
init() {
const editor = this.editor;
const options = editor.config.get("heading.options");
const modelElements = [];
for (const option of options) {
if (option.model === "paragraph") continue;
editor.model.schema.register(option.model, { inheritAllFrom: "$block" });
editor.conversion.elementToElement(option);
modelElements.push(option.model);
}
this._addDefaultH1Conversion(editor);
editor.commands.add("heading", new HeadingCommand(editor, modelElements));
}
/**
* @inheritDoc
*/
afterInit() {
const editor = this.editor;
const enterCommand = editor.commands.get("enter");
const options = editor.config.get("heading.options");
if (enterCommand) this.listenTo(enterCommand, "afterExecute", (evt, data) => {
const positionParent = editor.model.document.selection.getFirstPosition().parent;
if (options.some((option) => positionParent.is("element", option.model)) && !positionParent.is("element", defaultModelElement) && positionParent.childCount === 0) data.writer.rename(positionParent, defaultModelElement);
});
}
/**
* Adds default conversion for `h1` -> `heading1` with a low priority.
*
* @param editor Editor instance on which to add the `h1` conversion.
*/
_addDefaultH1Conversion(editor) {
editor.conversion.for("upcast").elementToElement({
model: "heading1",
view: "h1",
converterPriority: priorities.low + 1
});
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* Returns heading options as defined in `config.heading.options` but processed to consider
* the editor localization, i.e. to display {@link module:heading/headingconfig~HeadingOption}
* in the correct language.
*
* Note: The reason behind this method is that there is no way to use {@link module:utils/locale~Locale#t}
* when the user configuration is defined because the editor does not exist yet.
*
* @internal
*/
function getLocalizedOptions(editor) {
const t = editor.t;
const localizedTitles = {
"Paragraph": t("Paragraph"),
"Heading 1": t("Heading 1"),
"Heading 2": t("Heading 2"),
"Heading 3": t("Heading 3"),
"Heading 4": t("Heading 4"),
"Heading 5": t("Heading 5"),
"Heading 6": t("Heading 6")
};
return editor.config.get("heading.options").map((option) => {
const title = localizedTitles[option.title];
if (title && title != option.title) option.title = title;
return option;
});
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module heading/headingui
*/
/**
* The headings UI feature. It introduces the `headings` dropdown.
*/
var HeadingUI = class extends Plugin {
/**
* @inheritDoc
*/
static get pluginName() {
return "HeadingUI";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* @inheritDoc
*/
init() {
const editor = this.editor;
const t = editor.t;
const options = getLocalizedOptions(editor);
const defaultTitle = t("Choose heading");
const accessibleLabel = t("Heading");
editor.ui.componentFactory.add("heading", (locale) => {
const titles = {};
const itemDefinitions = new Collection();
const headingCommand = editor.commands.get("heading");
const paragraphCommand = editor.commands.get("paragraph");
const commands = [headingCommand];
for (const option of options) {
const def = {
type: "button",
model: new UIModel({
label: option.title,
class: option.class,
role: "menuitemradio",
withText: true
})
};
if (option.model === "paragraph") {
def.model.bind("isOn").to(paragraphCommand, "value");
def.model.set("commandName", "paragraph");
commands.push(paragraphCommand);
} else {
def.model.bind("isOn").to(headingCommand, "value", (value) => value === option.model);
def.model.set({
commandName: "heading",
commandValue: option.model
});
}
itemDefinitions.add(def);
titles[option.model] = option.title;
}
const dropdownView = createDropdown(locale);
addListToDropdown(dropdownView, itemDefinitions, {
ariaLabel: accessibleLabel,
role: "menu"
});
dropdownView.buttonView.set({
ariaLabel: accessibleLabel,
ariaLabelledBy: void 0,
isOn: false,
withText: true,
tooltip: accessibleLabel
});
dropdownView.extendTemplate({ attributes: { class: ["ck-heading-dropdown"] } });
dropdownView.bind("isEnabled").toMany(commands, "isEnabled", (...areEnabled) => {
return areEnabled.some((isEnabled) => isEnabled);
});
dropdownView.buttonView.bind("label").to(headingCommand, "value", paragraphCommand, "value", (heading, paragraph) => {
const whichModel = paragraph ? "paragraph" : heading;
if (typeof whichModel === "boolean") return defaultTitle;
if (!titles[whichModel]) return defaultTitle;
return titles[whichModel];
});
dropdownView.buttonView.bind("ariaLabel").to(headingCommand, "value", paragraphCommand, "value", (heading, paragraph) => {
const whichModel = paragraph ? "paragraph" : heading;
if (typeof whichModel === "boolean") return accessibleLabel;
if (!titles[whichModel]) return accessibleLabel;
return `${titles[whichModel]}, ${accessibleLabel}`;
});
this.listenTo(dropdownView, "execute", (evt) => {
const { commandName, commandValue } = evt.source;
editor.execute(commandName, commandValue ? { value: commandValue } : void 0);
editor.editing.view.focus();
});
return dropdownView;
});
editor.ui.componentFactory.add("menuBar:heading", (locale) => {
const menuView = new MenuBarMenuView(locale);
const headingCommand = editor.commands.get("heading");
const paragraphCommand = editor.commands.get("paragraph");
const commands = [headingCommand];
const listView = new MenuBarMenuListView(locale);
menuView.set({ class: "ck-heading-dropdown" });
listView.set({
ariaLabel: t("Heading"),
role: "menu"
});
menuView.buttonView.set({ label: t("Heading") });
menuView.panelView.children.add(listView);
for (const option of options) {
const listItemView = new MenuBarMenuListItemView(locale, menuView);
const buttonView = new MenuBarMenuListItemButtonView(locale);
listItemView.children.add(buttonView);
listView.items.add(listItemView);
buttonView.set({
isToggleable: true,
label: option.title,
role: "menuitemradio",
class: option.class
});
buttonView.delegate("execute").to(menuView);
buttonView.on("execute", () => {
const commandName = option.model === "paragraph" ? "paragraph" : "heading";
editor.execute(commandName, { value: option.model });
editor.editing.view.focus();
});
if (option.model === "paragraph") {
buttonView.bind("isOn").to(paragraphCommand, "value");
commands.push(paragraphCommand);
} else buttonView.bind("isOn").to(headingCommand, "value", (value) => value === option.model);
}
menuView.bind("isEnabled").toMany(commands, "isEnabled", (...areEnabled) => {
return areEnabled.some((isEnabled) => isEnabled);
});
return menuView;
});
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module heading/heading
*/
/**
* The headings feature.
*
* For a detailed overview, check the {@glink features/headings Headings feature} guide
* and the {@glink api/heading package page}.
*
* This is a "glue" plugin which loads the {@link module:heading/headingediting~HeadingEditing heading editing feature}
* and {@link module:heading/headingui~HeadingUI heading UI feature}.
*
* @extends module:core/plugin~Plugin
*/
var Heading = class extends Plugin {
/**
* @inheritDoc
*/
static get requires() {
return [HeadingEditing, HeadingUI];
}
/**
* @inheritDoc
*/
static get pluginName() {
return "Heading";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module heading/headingbuttonsui
*/
const defaultIcons = /* #__PURE__ */ (() => ({
heading1: IconHeading1,
heading2: IconHeading2,
heading3: IconHeading3,
heading4: IconHeading4,
heading5: IconHeading5,
heading6: IconHeading6
}))();
/**
* The `HeadingButtonsUI` plugin defines a set of UI buttons that can be used instead of the
* standard drop down component.
*
* This feature is not enabled by default by the {@link module:heading/heading~Heading} plugin and needs to be
* installed manually to the editor configuration.
*
* Plugin introduces button UI elements, which names are same as `model` property from {@link module:heading/headingconfig~HeadingOption}.
*
* ```ts
* ClassicEditor
* .create( {
* plugins: [ ..., Heading, Paragraph, HeadingButtonsUI, ParagraphButtonUI ]
* heading: {
* options: [
* { model: 'paragraph', title: 'Paragraph', class: 'ck-heading_paragraph' },
* { model: 'heading1', view: 'h2', title: 'Heading 1', class: 'ck-heading_heading1' },
* { model: 'heading2', view: 'h3', title: 'Heading 2', class: 'ck-heading_heading2' },
* { model: 'heading3', view: 'h4', title: 'Heading 3', class: 'ck-heading_heading3' }
* ]
* },
* toolbar: [ 'paragraph', 'heading1', 'heading2', 'heading3' ]
* } )
* .then( ... )
* .catch( ... );
* ```
*
* NOTE: The `'paragraph'` button is defined in by the {@link module:paragraph/paragraphbuttonui~ParagraphButtonUI} plugin
* which needs to be loaded manually as well.
*
* It is possible to use custom icons by providing `icon` config option in {@link module:heading/headingconfig~HeadingOption}.
* For the default configuration standard icons are used.
*/
var HeadingButtonsUI = class extends Plugin {
/**
* @inheritDoc
*/
init() {
getLocalizedOptions(this.editor).filter((item) => item.model !== "paragraph").map((item) => this._createButton(item));
}
/**
* Creates single button view from provided configuration option.
*/
_createButton(option) {
const editor = this.editor;
editor.ui.componentFactory.add(option.model, (locale) => {
const view = new ButtonView(locale);
const command = editor.commands.get("heading");
view.label = option.title;
view.icon = option.icon || defaultIcons[option.model];
view.tooltip = true;
view.isToggleable = true;
view.bind("isEnabled").to(command);
view.bind("isOn").to(command, "value", (value) => value == option.model);
view.on("execute", () => {
editor.execute("heading", { value: option.model });
editor.editing.view.focus();
});
return view;
});
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module heading/title
*/
const titleLikeElements = /* @__PURE__ */ new Set([
"paragraph",
"heading1",
"heading2",
"heading3",
"heading4",
"heading5",
"heading6"
]);
/**
* The Title plugin.
*
* It splits the document into `Title` and `Body` sections.
*/
var Title = class extends Plugin {
/**
* A reference to an empty paragraph in the body
* created when there is no element in the body for the placeholder purposes.
*/
_bodyPlaceholder = /* @__PURE__ */ new Map();
/**
* @inheritDoc
*/
static get pluginName() {
return "Title";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* @inheritDoc
*/
static get requires() {
return [Paragraph];
}
/**
* @inheritDoc
*/
init() {
const editor = this.editor;
const model = editor.model;
model.schema.register("title", {
isBlock: true,
allowIn: "$root"
});
model.schema.register("title-content", {
isBlock: true,
allowIn: "title",
allowAttributes: ["alignment"]
});
model.schema.extend("$text", { allowIn: "title-content" });
model.schema.addAttributeCheck((context) => {
if (context.endsWith("title-content $text")) return false;
});
editor.editing.mapper.on("modelToViewPosition", mapModelPositionToView(editor.editing.view));
editor.data.mapper.on("modelToViewPosition", mapModelPositionToView(editor.editing.view));
editor.conversion.for("downcast").elementToElement({
model: "title-content",
view: "h1"
});
editor.conversion.for("downcast").add((dispatcher) => dispatcher.on("insert:title", (evt, data, conversionApi) => {
conversionApi.consumable.consume(data.item, evt.name);
}));
editor.data.upcastDispatcher.on("element:h1", dataViewModelH1Insertion, { priority: "high" });
editor.data.upcastDispatcher.on("element:h2", dataViewModelH1Insertion, { priority: "high" });
editor.data.upcastDispatcher.on("element:h3", dataViewModelH1Insertion, { priority: "high" });
model.document.registerPostFixer((writer) => this._fixTitleContent(writer));
model.document.registerPostFixer((writer) => this._fixTitleElement(writer));
model.document.registerPostFixer((writer) => this._fixBodyElement(writer));
model.document.registerPostFixer((writer) => this._fixExtraParagraph(writer));
this._attachPlaceholders();
this._attachTabPressHandling();
this._warnIfNoSupportedRoot();
}
/**
* Logs a single warning when none of the editor's roots can host the title structure. The Title feature
* only operates on roots whose `modelElement` is the default `$root`; roots configured with a custom
* `modelElement` are silently skipped at runtime. If no root supports the structure, the plugin is
* effectively a no-op and the integrator likely wants to know.
*/
_warnIfNoSupportedRoot() {
const model = this.editor.model;
for (const root of model.document.getRoots()) if (model.schema.checkChild(root, "title")) return;
/**
* The Title feature was loaded, but none of the editor's roots supports the `title` element. The feature
* only operates on roots whose `modelElement` is the default `$root`; roots configured with a custom
* `modelElement` (including `$inlineRoot`) are silently skipped, so `getTitle()` / `getBody()` fall back
* to the regular data getter and no title structure is ever inserted.
*
* To use the Title feature, ensure at least one root uses the default `$root` model element. Otherwise,
* remove the Title plugin from this editor's plugin list.
*
* @error title-no-supported-root
*/
logWarning("title-no-supported-root");
}
/**
* Returns the title of the document. Note that because this plugin does not allow any formatting inside
* the title element, the output of this method will be a plain text, with no HTML tags.
*
* It is not recommended to use this method together with features that insert markers to the
* data output, like comments or track changes features. If such markers start in the title and end in the
* body, the result of this method might be incorrect.
*
* @param options Additional configuration passed to the conversion process.
* See {@link module:engine/controller/datacontroller~DataController#get `DataController#get`}.
* @returns The title of the document.
*/
getTitle(options = {}) {
const rootName = options.rootName ? options.rootName : void 0;
const titleElement = this._getTitleElement(rootName);
if (!titleElement) return "";
const titleContentElement = titleElement.getChild(0);
return this.editor.data.stringify(titleContentElement, options);
}
/**
* Returns the body of the document.
*
* Note that it is not recommended to use this method together with features that insert markers to the
* data output, like comments or track changes features. If such markers start in the title and end in the
* body, the result of this method might be incorrect.
*
* @param options Additional configuration passed to the conversion process.
* See {@link module:engine/controller/datacontroller~DataController#get `DataController#get`}.
* @returns The body of the document.
*/
getBody(options = {}) {
const editor = this.editor;
const data = editor.data;
const model = editor.model;
const rootName = options.rootName ? options.rootName : void 0;
const root = editor.model.document.getRoot(rootName);
if (!model.schema.checkChild(root, "title")) return data.get({
...options,
rootName: root.rootName
});
const firstChild = root.getChild(0);
if (!firstChild || !firstChild.is("element", "title")) return "";
const view = editor.editing.view;
const viewWriter = new ViewDowncastWriter(view.document);
const rootRange = model.createRangeIn(root);
const viewDocumentFragment = viewWriter.createDocumentFragment();
const bodyStartPosition = model.createPositionAfter(firstChild);
const bodyRange = model.createRange(bodyStartPosition, model.createPositionAt(root, "end"));
const markers = /* @__PURE__ */ new Map();
for (const marker of model.markers) {
const intersection = bodyRange.getIntersection(marker.getRange());
if (intersection) markers.set(marker.name, intersection);
}
data.mapper.clearBindings();
data.mapper.bindElements(root, viewDocumentFragment);
data.downcastDispatcher.convert(rootRange, markers, viewWriter, options);
viewWriter.remove(viewWriter.createRangeOn(viewDocumentFragment.getChild(0)));
return editor.data.processor.toData(viewDocumentFragment);
}
/**
* Returns the `title` element when it is in the document. Returns `undefined` otherwise.
*/
_getTitleElement(rootName) {
const model = this.editor.model;
const root = model.document.getRoot(rootName);
if (!model.schema.checkChild(root, "title")) return;
for (const child of root.getChildren()) if (isTitle(child)) return child;
}
/**
* Model post-fixer callback that ensures that `title` has only one `title-content` child.
* All additional children should be moved after the `title` element and renamed to a paragraph.
*/
_fixTitleContent(writer) {
let changed = false;
for (const rootName of this.editor.model.document.getRootNames()) {
const title = this._getTitleElement(rootName);
if (!title || title.maxOffset === 1) continue;
const titleChildren = Array.from(title.getChildren());
titleChildren.shift();
for (const titleChild of titleChildren) {
writer.move(writer.createRangeOn(titleChild), title, "after");
writer.rename(titleChild, "paragraph");
}
changed = true;
}
return changed;
}
/**
* Model post-fixer callback that creates a title element when it is missing,
* takes care of the correct position of it and removes additional title elements.
*/
_fixTitleElement(writer) {
let changed = false;
const model = this.editor.model;
for (const modelRoot of this.editor.model.document.getRoots()) {
if (!model.schema.checkChild(modelRoot, "title")) continue;
const titleElements = Array.from(modelRoot.getChildren()).filter(isTitle);
const firstTitleElement = titleElements[0];
const firstRootChild = modelRoot.getChild(0);
if (firstRootChild.is("element", "title")) {
if (titleElements.length > 1) {
fixAdditionalTitleElements(titleElements, writer, model);
changed = true;
}
continue;
}
if (!firstTitleElement && !titleLikeElements.has(firstRootChild.name)) {
const title = writer.createElement("title");
writer.insert(title, modelRoot);
writer.insertElement("title-content", title);
changed = true;
continue;
}
if (titleLikeElements.has(firstRootChild.name)) changeElementToTitle(firstRootChild, writer, model);
else writer.move(writer.createRangeOn(firstTitleElement), modelRoot, 0);
fixAdditionalTitleElements(titleElements, writer, model);
changed = true;
}
return changed;
}
/**
* Model post-fixer callback that adds an empty paragraph at the end of the document
* when it is needed for the placeholder purposes.
*/
_fixBodyElement(writer) {
const schema = this.editor.model.schema;
let changed = false;
for (const rootName of this.editor.model.document.getRootNames()) {
const modelRoot = this.editor.model.document.getRoot(rootName);
if (modelRoot.childCount < 2 && schema.checkChild(modelRoot, "title")) {
const placeholder = writer.createElement("paragraph");
writer.insert(placeholder, modelRoot, 1);
this._bodyPlaceholder.set(rootName, placeholder);
changed = true;
}
}
return changed;
}
/**
* Model post-fixer callback that removes a paragraph from the end of the document
* if it was created for the placeholder purposes and is not needed anymore.
*/
_fixExtraParagraph(writer) {
let changed = false;
for (const rootName of this.editor.model.document.getRootNames()) {
const root = this.editor.model.document.getRoot(rootName);
const placeholder = this._bodyPlaceholder.get(rootName);
if (!placeholder) continue;
if (shouldRemoveLastParagraph(placeholder, root)) {
this._bodyPlaceholder.delete(rootName);
writer.remove(placeholder);
changed = true;
}
}
return changed;
}
/**
* Attaches the `Title` and `Body` placeholders to the title and/or content.
*/
_attachPlaceholders() {
const editor = this.editor;
const t = editor.t;
const view = editor.editing.view;
const sourceElement = editor.sourceElement;
const titlePlaceholder = editor.config.get("title.placeholder") || t("Type your title");
editor.editing.downcastDispatcher.on("insert:title-content", (evt, data, conversionApi) => {
const element = conversionApi.mapper.toViewElement(data.item);
element.placeholder = titlePlaceholder;
enableViewPlaceholder({
view,
element,
keepOnFocus: true
});
});
const bodyViewElements = /* @__PURE__ */ new Map();
view.document.registerPostFixer((writer) => {
let hasChanged = false;
for (const viewRoot of view.document.roots) {
if (viewRoot.isEmpty) continue;
const modelRoot = editor.editing.mapper.toModelElement(viewRoot);
if (!editor.model.schema.checkChild(modelRoot, "title")) continue;
const body = viewRoot.getChild(1);
const oldBody = bodyViewElements.get(viewRoot.rootName);
if (body !== oldBody) {
if (oldBody) {
hideViewPlaceholder(writer, oldBody);
writer.removeAttribute("data-placeholder", oldBody);
}
const bodyPlaceholder = editor.config.get("roots")[viewRoot.rootName]?.placeholder || isTextArea(sourceElement) && sourceElement.getAttribute("placeholder") || t("Type or paste your content here.");
writer.setAttribute("data-placeholder", bodyPlaceholder, body);
bodyViewElements.set(viewRoot.rootName, body);
hasChanged = true;
}
if (needsViewPlaceholder(body, true) && viewRoot.childCount === 2 && body.name === "p") hasChanged = showViewPlaceholder(writer, body) ? true : hasChanged;
else hasChanged = hideViewPlaceholder(writer, body) ? true : hasChanged;
}
return hasChanged;
});
}
/**
* Creates navigation between the title and body sections using <kbd>Tab</kbd> and <kbd>Shift</kbd>+<kbd>Tab</kbd> keys.
*/
_attachTabPressHandling() {
const editor = this.editor;
const model = editor.model;
editor.keystrokes.set("TAB", (data, cancel) => {
model.change((writer) => {
const selection = model.document.selection;
const selectedElements = Array.from(selection.getSelectedBlocks());
if (selectedElements.length === 1 && selectedElements[0].is("element", "title-content")) {
const firstBodyElement = selection.getFirstPosition().root.getChild(1);
writer.setSelection(firstBodyElement, 0);
cancel();
}
});
});
editor.keystrokes.set("SHIFT + TAB", (data, cancel) => {
model.change((writer) => {
const selection = model.document.selection;
if (!selection.isCollapsed) return;
const selectedElement = first(selection.getSelectedBlocks());
const selectionPosition = selection.getFirstPosition();
const root = editor.model.document.getRoot(selectionPosition.root.rootName);
if (!model.schema.checkChild(root, "title")) return;
const title = root.getChild(0);
if (selectedElement === root.getChild(1) && selectionPosition.isAtStart) {
writer.setSelection(title.getChild(0), 0);
cancel();
}
});
});
}
};
/**
* A view-to-model converter for the h1 that appears at the beginning of the document (a title element).
*
* Matches only the synthetic upcast parent named `$root` (the default generic root element). Title is not supported
* for roots whose `modelElement` is customized, so this converter intentionally does not fire on them.
*
* @see module:engine/conversion/upcastdispatcher~UpcastDispatcher#event:element
* @param evt An object containing information about the fired event.
* @param data An object containing conversion input, a placeholder for conversion output and possibly other values.
* @param conversionApi Conversion interface to be used by the callback.
*/
function dataViewModelH1Insertion(evt, data, conversionApi) {
const modelCursor = data.modelCursor;
const viewItem = data.viewItem;
if (!modelCursor.isAtStart || !modelCursor.parent.is("element", "$root")) return;
if (!conversionApi.consumable.consume(viewItem, { name: true })) return;
const modelWriter = conversionApi.writer;
const title = modelWriter.createElement("title");
const titleContent = modelWriter.createElement("title-content");
modelWriter.append(titleContent, title);
modelWriter.insert(title, modelCursor);
conversionApi.convertChildren(viewItem, titleContent);
conversionApi.updateConversionResult(title, data);
}
/**
* Maps position from the beginning of the model `title` element to the beginning of the view `h1` element.
*
* ```html
* <title>^<title-content>Foo</title-content></title> -> <h1>^Foo</h1>
* ```
*/
function mapModelPositionToView(editingView) {
return (evt, data) => {
const positionParent = data.modelPosition.parent;
if (!positionParent.is("element", "title")) return;
const modelTitleElement = positionParent.parent;
const viewElement = data.mapper.toViewElement(modelTitleElement);
data.viewPosition = editingView.createPositionAt(viewElement, 0);
evt.stop();
};
}
/**
* @returns Returns true when given element is a title. Returns false otherwise.
*/
function isTitle(element) {
return element.is("element", "title");
}
/**
* Changes the given element to the title element.
*/
function changeElementToTitle(element, writer, model) {
const title = writer.createElement("title");
writer.insert(title, element, "before");
writer.insert(element, title, 0);
writer.rename(element, "title-content");
model.schema.removeDisallowedAttributes([element], writer);
}
/**
* Loops over the list of title elements and fixes additional ones.
*
* @returns Returns true when there was any change. Returns false otherwise.
*/
function fixAdditionalTitleElements(titleElements, writer, model) {
let hasChanged = false;
for (const title of titleElements) if (title.index !== 0) {
fixTitleElement(title, writer, model);
hasChanged = true;
}
return hasChanged;
}
/**
* Changes given title element to a paragraph or removes it when it is empty.
*/
function fixTitleElement(title, writer, model) {
const child = title.getChild(0);
if (child.isEmpty) {
writer.remove(title);
return;
}
writer.move(writer.createRangeOn(child), title, "before");
writer.rename(child, "paragraph");
writer.remove(title);
model.schema.removeDisallowedAttributes([child], writer);
}
/**
* Returns true when the last paragraph in the document was created only for the placeholder
* purpose and it's not needed anymore. Returns false otherwise.
*/
function shouldRemoveLastParagraph(placeholder, root) {
if (!placeholder.is("element", "paragraph") || placeholder.childCount) return false;
if (root.childCount <= 2 || root.getChild(root.childCount - 1) !== placeholder) return false;
return true;
}
/**
* Returns true when given element is a DOM textarea.
*/
function isTextArea(sourceElement) {
return !!sourceElement && sourceElement.tagName.toLowerCase() === "textarea";
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
export { Heading, HeadingButtonsUI, HeadingCommand, HeadingEditing, HeadingUI, Title, getLocalizedOptions as _getLocalizedHeadingOptions };
//# sourceMappingURL=index.js.map