Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Issue #3324781: Add split text functionality to CKEditor 5 #76

Closed
wants to merge 11 commits into from
3 changes: 3 additions & 0 deletions css/split_paragraph.admin.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.ckeditor5-toolbar-button-splitParagraph {
background-image: url('../icons/split.svg');
}
7 changes: 7 additions & 0 deletions icons/split.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions js/build/split_paragraph.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

73 changes: 73 additions & 0 deletions js/ckeditor5_plugins/split_paragraph/src/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* @file
* Split paragraph plugin.
*/

import { Plugin } from 'ckeditor5/src/core';
import { ButtonView } from 'ckeditor5/src/ui';
import SplitParagraphCommand from './splitparagraphcommand';
import icon from '../../../../icons/split.svg';

class SplitParagraph extends Plugin {
init() {
// Only split text paragraphs.
if (this.editor.sourceElement.closest('.paragraph-type--text') == null) {
return;
}

// This will register the splitParagraph toolbar button.
this.editor.ui.componentFactory.add('splitParagraph', (locale) => {
const command = this.editor.commands.get('splitParagraph');
const buttonView = new ButtonView(locale);

// Create the toolbar button.
buttonView.set({
label: this.editor.t('Split Paragraph'),
icon,
tooltip: true,
});

buttonView.bind('isOn', 'isEnabled').to(command, 'value', 'isEnabled');

// Execute the command when the button is clicked.
this.listenTo(buttonView, 'execute', () => this.editor.execute('splitParagraph'));

return buttonView;
});

this.editor.commands.add(
'splitParagraph',
new SplitParagraphCommand(this.editor),
);
}

afterInit() {
// set value of the new paragraph
if (window._splitParagraph) {
if (typeof window._splitParagraph.data.second === 'string') {
const previousParagraph = this.editor.sourceElement.closest('.paragraph-type--text')?.previousElementSibling?.previousElementSibling;
if (previousParagraph && previousParagraph.querySelector(`[data-drupal-selector="${window._splitParagraph.selector}"]`)) {
// defer to wait until init is complete
setTimeout(() => {
this.editor.setData(window._splitParagraph.data.second);
window._splitParagraph.data.second = null;
}, 0);
}
}

if (typeof window._splitParagraph.data.first === 'string') {
if (this.editor.sourceElement.dataset.drupalSelector === window._splitParagraph.selector) {
// defer to wait until init is complete
setTimeout(() => {
this.editor.setData(window._splitParagraph.data.first);
window._splitParagraph.data.first = null;
}, 0);
}
}
}
}
}

export default {
SplitParagraph,
};
123 changes: 123 additions & 0 deletions js/ckeditor5_plugins/split_paragraph/src/splitparagraphcommand.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/**
* @file defines SplitParagraphCommand, which is executed when the splitParagraph
* toolbar button is pressed.
*/

import { Command } from 'ckeditor5/src/core';

export default class SplitParagraphCommand extends Command {
execute() {
const { model, sourceElement } = this.editor;
const [splitElement, splitText] = model.document.selection.getFirstPosition().path;
const elements = (new DOMParser()).parseFromString(this.editor.getData(), 'text/html').body.children;
const elementsBefore = [];
const elementsAfter = [];
const nodeArrayToHTML = (nodeArray) => nodeArray.reduce((data, element) => data + element.outerHTML, '');

let i = 0;
Array.from(elements).forEach((el) => {
if (i < splitElement) {
elementsBefore.push(el);
}

if (i === splitElement) {
const [nodeBefore, nodeAfter] = this.splitNode(el, splitText);

if (nodeBefore) {
elementsBefore.push(nodeBefore);
}

if (nodeAfter) {
elementsAfter.push(nodeAfter);
}
}

if (i > splitElement) {
elementsAfter.push(el);
}
i += 1;
});

// store the value of the paragraphs
const firstData = nodeArrayToHTML(elementsBefore);
const secondData = nodeArrayToHTML(elementsAfter);
window._splitParagraph = {
data: {
first: firstData,
second: secondData,
},
selector: sourceElement.dataset.drupalSelector,
};

// add new paragraph below
sourceElement.closest('.paragraph-type--text').nextElementSibling.querySelector('.paragraphs-features__add-in-between__button').click();
}

refresh() {
this.isEnabled = true;
}

static splitNode(node, splitAt) {
let characterCount = 0;

const nestedSplitter = (n) => {
if (n.nodeType === Node.TEXT_NODE) {
// split position within text node
if (n.data.length > splitAt - characterCount) {
const textBeforeSplit = n.data.substring(0, splitAt - characterCount);
const textAfterSplit = n.data.substring(splitAt - characterCount);

return [
textBeforeSplit ? document.createTextNode(textBeforeSplit) : null,
textAfterSplit ? document.createTextNode(textAfterSplit) : null,
];
}

characterCount += n.data.length;
return [n, null];
}

const childNodesBefore = [];
const childNodesAfter = [];
n.childNodes.forEach((childNode) => {
// split not yet reached
if (childNodesAfter.length === 0) {
const [childNodeBefore, childNodeAfter] = nestedSplitter(childNode);

if (childNodeBefore) {
childNodesBefore.push(childNodeBefore);
}

if (childNodeAfter) {
childNodesAfter.push(childNodeAfter);
}
} else {
childNodesAfter.push(childNode);
}
});

// node was not split
if (childNodesAfter.length === 0) {
return [n, null];
}

const nodeBefore = n.cloneNode();
const nodeAfter = n.cloneNode();

childNodesBefore.forEach((childNode) => {
nodeBefore.appendChild(childNode);
});

childNodesAfter.forEach((childNode) => {
nodeAfter.appendChild(childNode);
});

return [
nodeBefore.childNodes.length > 0 ? nodeBefore : null,
nodeAfter.childNodes.length > 0 ? nodeAfter : null,
];
};

return nestedSplitter(node);
}
}
17 changes: 16 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
{
"name": "drupal-ckeditor5",
"version": "1.0.0",
"description": "Drupal CKEditor 5 integration",
"author": "",
"license": "GPL-2.0-or-later",
"scripts": {
"watch": "webpack --mode development --watch",
"build": "webpack"
},
"dependencies": {
"ckeditor5": "~34.1.0"
},
"devDependencies": {
"eslint": "^8"
"eslint": "^8",
"raw-loader": "^4.0.2",
"webpack": "^5.51.1",
"webpack-cli": "^4.4.0"
}
}
14 changes: 14 additions & 0 deletions paragraphs_features.ckeditor5.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
paragraphs_features_split_paragraph:
ckeditor5:
plugins:
- split_paragraph.SplitParagraph
drupal:
label: Split paragraph
library: paragraphs_features/splitParagraph
admin_library: paragraphs_features/splitParagraph.admin
toolbar_items:
splitParagraph:
label: Split paragraph
elements:
- <p>
- <div>
zibellino marked this conversation as resolved.
Show resolved Hide resolved
1 change: 1 addition & 0 deletions paragraphs_features.info.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ configure: paragraphs_features.settings_form
dependencies:
- paragraphs:paragraphs
- drupal:field
- drupal:ckeditor5
11 changes: 11 additions & 0 deletions paragraphs_features.libraries.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,14 @@ scroll_to_element:
js/paragraphs-features.scroll-to-element.js: {}
dependencies:
- core/drupal

splitParagraph:
js:
js/build/split_paragraph.js: { preprocess: false, minified: true }
dependencies:
- core/ckeditor5

splitParagraph.admin:
css:
theme:
css/split_paragraph.admin.css: {}
zibellino marked this conversation as resolved.
Show resolved Hide resolved