diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..b04cd97 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v18.9.1 diff --git a/README.CKEditor5.md b/README.CKEditor5.md new file mode 100644 index 0000000..16a29ac --- /dev/null +++ b/README.CKEditor5.md @@ -0,0 +1,17 @@ +## CKEditor 5 plugin development + +Based on the [CKEditor 5 plugin development starter template](https://git.drupalcode.org/project/ckeditor5_dev/-/tree/1.0.x/ckeditor5_plugin_starter_template). + +Plugin source should be added to +`js/ckeditor5_plugins/{pluginNameDirectory}/src` and the build tools expect this +directory to include an `index.js` file that exports one or more CKEditor 5 +plugins. Note that requiring `index.js` to be inside +`{pluginNameDirectory}/src` is not a fixed requirement of CKEditor 5 or Drupal, +and can be changed in `webpack.config.js` as needed. + +In the module directory, run `npm install` to set up the necessary assets. The +initial run of `install` may take a few minutes, but subsequent builds will be +faster. + +After installing dependencies, plugins can be built with `npm run build` or `npm run +watch`. They will be built to `js/build/{pluginNameDirectory}.js`. co diff --git a/css/split_paragraph.admin.css b/css/split_paragraph.admin.css new file mode 100644 index 0000000..69ac0fd --- /dev/null +++ b/css/split_paragraph.admin.css @@ -0,0 +1,3 @@ +.ckeditor5-toolbar-button-splitParagraph { + background-image: url('../icons/split.svg'); +} diff --git a/icons/split.svg b/icons/split.svg new file mode 100644 index 0000000..4497d7a --- /dev/null +++ b/icons/split.svg @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/js/build/split_paragraph.js b/js/build/split_paragraph.js new file mode 100644 index 0000000..71c3fad --- /dev/null +++ b/js/build/split_paragraph.js @@ -0,0 +1 @@ +!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.CKEditor5=e():(t.CKEditor5=t.CKEditor5||{},t.CKEditor5.split_paragraph=e())}(self,(()=>(()=>{var t={"ckeditor5/src/core.js":(t,e,r)=>{t.exports=r("dll-reference CKEditor5.dll")("./src/core.js")},"ckeditor5/src/ui.js":(t,e,r)=>{t.exports=r("dll-reference CKEditor5.dll")("./src/ui.js")},"dll-reference CKEditor5.dll":t=>{"use strict";t.exports=CKEditor5.dll}},e={};function r(o){var a=e[o];if(void 0!==a)return a.exports;var s=e[o]={exports:{}};return t[o](s,s.exports,r),s.exports}r.d=(t,e)=>{for(var o in e)r.o(e,o)&&!r.o(t,o)&&Object.defineProperty(t,o,{enumerable:!0,get:e[o]})},r.o=(t,e)=>Object.prototype.hasOwnProperty.call(t,e);var o={};return(()=>{"use strict";r.d(o,{default:()=>i});var t=r("ckeditor5/src/core.js"),e=r("ckeditor5/src/ui.js");class a extends t.Command{execute(){const{model:t,sourceElement:e}=this.editor,r=Drupal.t("[Splitting in progress... ⌛]"),o=this.editor.getData();t.change((e=>{e.insertText(r,t.document.selection.getFirstPosition())}));const s=(new DOMParser).parseFromString(this.editor.getData(),"text/html").body,[i,n,d]=a.splitNode(s,r);if(!d||!i||!n)return void this.editor.setData(o);const l=e.closest(".paragraphs-subform").closest("tr"),p=l.querySelector("[data-paragraphs-split-text-type]").dataset.paragraphsSplitTextType,c=[...l.parentNode.children].filter((t=>t.querySelector(".paragraphs-actions"))).indexOf(l)+1;window._splitParagraph={data:{first:i.outerHTML,second:n.outerHTML},selector:e.dataset.drupalSelector},e.closest(".paragraphs-container").querySelector("input.paragraph-type-add-delta.modal").value=c,e.closest(".paragraphs-container").querySelector(`input[data-paragraph-type="${p}"].field-add-more-submit`).dispatchEvent(new Event("mousedown"))}refresh(){this.isEnabled=!0}static splitNode(t,e){const r=t=>{if(t.nodeType===Node.TEXT_NODE){const r=t.data.indexOf(e);if(r>=0){const o=t.data.substring(0,r),a=t.data.substring(r+e.length);return[o?document.createTextNode(o):null,a?document.createTextNode(a):null,!0]}return[t,null,!1]}const o=[],a=[];let s=!1;if(t.childNodes.forEach((t=>{if(s)a.push(t);else{const[e,i,n]=r(t);s=n,e&&o.push(e),i&&a.push(i)}})),!s)return[t,null,!1];const i=t.cloneNode(),n=t.cloneNode();return o.forEach((t=>{i.appendChild(t)})),a.forEach((t=>{n.appendChild(t)})),[i.childNodes.length>0?i:null,n.childNodes.length>0?n:null,s]};return r(t)}}class s extends t.Plugin{init(){null!=this.editor.sourceElement.closest(".paragraphs-container")?.querySelector("input.paragraph-type-add-delta.modal")&&(this.editor.ui.componentFactory.add("splitParagraph",(t=>{const r=this.editor.commands.get("splitParagraph"),o=new e.ButtonView(t);return o.set({label:this.editor.t("Simple Split Paragraph"),icon:'\r\n\r\n\r\n\x3c!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools --\x3e\r\n',tooltip:!0}),o.bind("isOn","isEnabled").to(r,"value","isEnabled"),this.listenTo(o,"execute",(()=>this.editor.execute("splitParagraph"))),o})),this.editor.commands.add("splitParagraph",new a(this.editor)))}afterInit(){if(window._splitParagraph){if("string"==typeof window._splitParagraph.data.second){const t=this.editor.sourceElement.closest(".paragraphs-subform").closest("tr"),e=t?.previousElementSibling?.previousElementSibling;e&&e.querySelector(`[data-drupal-selector="${window._splitParagraph.selector}"]`)&&setTimeout((()=>{this.editor.setData(window._splitParagraph.data.second),window._splitParagraph.data.second=null}),0)}"string"==typeof window._splitParagraph.data.first&&this.editor.sourceElement.dataset.drupalSelector===window._splitParagraph.selector&&setTimeout((()=>{this.editor.setData(window._splitParagraph.data.first),window._splitParagraph.data.first=null}),0)}}}const i={SplitParagraph:s}})(),o.default})())); \ No newline at end of file diff --git a/js/ckeditor5_plugins/split_paragraph/src/index.js b/js/ckeditor5_plugins/split_paragraph/src/index.js new file mode 100644 index 0000000..f60ba28 --- /dev/null +++ b/js/ckeditor5_plugins/split_paragraph/src/index.js @@ -0,0 +1,75 @@ +/** + * @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 paragraphs. + if (this.editor.sourceElement.closest('.paragraphs-container')?.querySelector('input.paragraph-type-add-delta.modal') == null) { + return; + } + + // Register splitParagraph toolbar button. + this.editor.ui.componentFactory.add('splitParagraph', (locale) => { + const command = this.editor.commands.get('splitParagraph'); + const buttonView = new ButtonView(locale); + + // Create toolbar button. + buttonView.set({ + label: this.editor.t('Simple Split Paragraph'), + icon, + tooltip: true, + }); + + buttonView.bind('isOn', 'isEnabled').to(command, 'value', 'isEnabled'); + + // Execute command when button is clicked. + this.listenTo(buttonView, 'execute', () => this.editor.execute('splitParagraph')); + + return buttonView; + }); + + // Add toolbar button. + 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 paragraph = this.editor.sourceElement.closest('.paragraphs-subform').closest('tr'); + const previousParagraph = paragraph?.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, +}; diff --git a/js/ckeditor5_plugins/split_paragraph/src/splitparagraphcommand.js b/js/ckeditor5_plugins/split_paragraph/src/splitparagraphcommand.js new file mode 100644 index 0000000..6d4b542 --- /dev/null +++ b/js/ckeditor5_plugins/split_paragraph/src/splitparagraphcommand.js @@ -0,0 +1,115 @@ +/** + * @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; + // @todo Use an html node with display:none instead + const splitMarker = Drupal.t('[Splitting in progress... ⌛]'); + const originalText = this.editor.getData(); + + model.change(writer => { + writer.insertText(splitMarker, model.document.selection.getFirstPosition()); + }); + + const rootElement = (new DOMParser()).parseFromString(this.editor.getData(), 'text/html').body; + const [elementBefore, elementAfter, markerFound] = SplitParagraphCommand.splitNode(rootElement, splitMarker); + + if (!markerFound || !elementBefore || !elementAfter) { + this.editor.setData(originalText); + return; + } + + // Get paragraph type and position. + const paragraph = sourceElement.closest('.paragraphs-subform').closest('tr'); + const paragraphType = paragraph.querySelector('[data-paragraphs-split-text-type]').dataset.paragraphsSplitTextType; + const paragraphDelta = [...paragraph.parentNode.children].filter(el => el.querySelector('.paragraphs-actions')).indexOf(paragraph) + 1; + + // Store the value of the paragraphs. + window._splitParagraph = { + data: { + first: elementBefore.outerHTML, + second: elementAfter.outerHTML, + }, + selector: sourceElement.dataset.drupalSelector, + }; + + // Add new paragragraph after current. + sourceElement.closest('.paragraphs-container').querySelector('input.paragraph-type-add-delta.modal').value = paragraphDelta; + sourceElement.closest('.paragraphs-container').querySelector(`input[data-paragraph-type="${paragraphType}"].field-add-more-submit`).dispatchEvent(new Event('mousedown')); + } + + refresh() { + this.isEnabled = true; + } + + static splitNode(node, splitMarker) { + const nestedSplitter = (n) => { + if (n.nodeType === Node.TEXT_NODE) { + // Split position within text node. + const markerPos = n.data.indexOf(splitMarker); + if (markerPos >= 0) { + const textBeforeSplit = n.data.substring(0, markerPos); + const textAfterSplit = n.data.substring(markerPos + splitMarker.length); + + return [ + textBeforeSplit ? document.createTextNode(textBeforeSplit) : null, + textAfterSplit ? document.createTextNode(textAfterSplit) : null, + true, + ]; + } + + return [n, null, false]; + } + + const childNodesBefore = []; + const childNodesAfter = []; + let found = false; + n.childNodes.forEach((childNode) => { + // Split not yet reached. + if (!found) { + const [childNodeBefore, childNodeAfter, markerFound] = nestedSplitter(childNode); + found = markerFound; + + if (childNodeBefore) { + childNodesBefore.push(childNodeBefore); + } + + if (childNodeAfter) { + childNodesAfter.push(childNodeAfter); + } + } else { + childNodesAfter.push(childNode); + } + }); + + // Node was not split. + if (!found) { + return [n, null, false]; + } + + 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, + found, + ]; + }; + + return nestedSplitter(node); + } +} diff --git a/package.json b/package.json index a7dbca4..b911cf6 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/paragraphs_features.ckeditor5.yml b/paragraphs_features.ckeditor5.yml new file mode 100644 index 0000000..6017403 --- /dev/null +++ b/paragraphs_features.ckeditor5.yml @@ -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: + -
+ -