From 3b951ebf2fc8d178d740048e90ef895ffce971ad Mon Sep 17 00:00:00 2001 From: Dariusz Szut Date: Tue, 14 Nov 2023 14:27:43 +0100 Subject: [PATCH] IBX-6889: Added custom attributes to links --- .../custom-attributes-editing.js | 8 + .../public/js/CKEditor/link/link-command.js | 10 + .../public/js/CKEditor/link/link-editing.js | 110 ++++++--- .../public/js/CKEditor/link/link-ui.js | 26 +- .../js/CKEditor/link/ui/link-form-view.js | 232 ++++++++++++++++-- .../Resources/public/scss/_balloon-form.scss | 13 +- 6 files changed, 346 insertions(+), 53 deletions(-) diff --git a/src/bundle/Resources/public/js/CKEditor/custom-attributes/custom-attributes-editing.js b/src/bundle/Resources/public/js/CKEditor/custom-attributes/custom-attributes-editing.js index 3bb3fd6e..cd028206 100644 --- a/src/bundle/Resources/public/js/CKEditor/custom-attributes/custom-attributes-editing.js +++ b/src/bundle/Resources/public/js/CKEditor/custom-attributes/custom-attributes-editing.js @@ -52,12 +52,20 @@ class IbexaCustomAttributesEditing extends Plugin { const elementsWithCustomClasses = Object.keys(customClassesConfig); elementsWithCustomAttributes.forEach((element) => { + if (element === 'link') { + return; + } + const customAttributes = Object.keys(customAttributesConfig[element]); this.extendSchema(model.schema, element, { allowAttributes: customAttributes }); }); elementsWithCustomClasses.forEach((element) => { + if (element === 'link') { + return; + } + this.extendSchema(model.schema, element, { allowAttributes: 'custom-classes' }); }); diff --git a/src/bundle/Resources/public/js/CKEditor/link/link-command.js b/src/bundle/Resources/public/js/CKEditor/link/link-command.js index d2ab8cf5..b0feab5f 100644 --- a/src/bundle/Resources/public/js/CKEditor/link/link-command.js +++ b/src/bundle/Resources/public/js/CKEditor/link/link-command.js @@ -28,6 +28,16 @@ class IbexaLinkCommand extends Command { writer.setAttribute('ibexaLinkHref', linkData.href, element); writer.setAttribute('ibexaLinkTitle', linkData.title, element); writer.setAttribute('ibexaLinkTarget', linkData.target, element); + + if (!!linkData.ibexaLinkClasses) { + writer.setAttribute('ibexaLinkClasses', linkData.ibexaLinkClasses, element); + } + + if (linkData.ibexaLinkAttributes) { + Object.entries(linkData.ibexaLinkAttributes).forEach(([name, value]) => { + writer.setAttribute(name, value, element); + }); + } } } diff --git a/src/bundle/Resources/public/js/CKEditor/link/link-editing.js b/src/bundle/Resources/public/js/CKEditor/link/link-editing.js index c33d8907..7ce458a2 100644 --- a/src/bundle/Resources/public/js/CKEditor/link/link-editing.js +++ b/src/bundle/Resources/public/js/CKEditor/link/link-editing.js @@ -1,13 +1,14 @@ import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; import IbexaLinkCommand from './link-command'; +import { getCustomAttributesConfig, getCustomClassesConfig } from '../custom-attributes/helpers/config-helper'; class IbexaCustomTagEditing extends Plugin { static get requires() { return []; } - defineConverters() { + defineConverters(customAttributesLinkConfig, customClassesLinkConfig) { const { conversion } = this.editor; conversion.for('editingDowncast').attributeToElement({ @@ -40,42 +41,93 @@ class IbexaCustomTagEditing extends Plugin { view: (target, { writer: downcastWriter }) => downcastWriter.createAttributeElement('a', { target }), }); - conversion.for('upcast').elementToAttribute({ - view: { - name: 'a', - attributes: { - href: true, - }, - }, - model: { - key: 'ibexaLinkHref', - value: (viewElement) => viewElement.getAttribute('href'), - }, - }); - - conversion.for('upcast').attributeToAttribute({ - view: { - name: 'a', - key: 'title', - }, - model: 'ibexaLinkTitle', - }); - - conversion.for('upcast').attributeToAttribute({ - view: { - name: 'a', - key: 'target', - }, - model: 'ibexaLinkTarget', + if (customClassesLinkConfig) { + conversion.for('editingDowncast').attributeToElement({ + model: 'ibexaLinkClasses', + view: (classes, { writer: downcastWriter }) => downcastWriter.createAttributeElement('a', { class: classes }), + }); + + conversion.for('dataDowncast').attributeToElement({ + model: 'ibexaLinkClasses', + view: (classes, { writer: downcastWriter }) => downcastWriter.createAttributeElement('a', { class: classes }), + }); + } + + if (customAttributesLinkConfig) { + Object.keys(customAttributesLinkConfig).forEach((customAttributeName) => { + conversion.for('editingDowncast').attributeToElement({ + model: `ibexaLink${customAttributeName}`, + view: (attr, { writer: downcastWriter }) => + downcastWriter.createAttributeElement('a', { [`data-ezattribute-${customAttributeName}`]: attr }), + }); + + conversion.for('dataDowncast').attributeToElement({ + model: `ibexaLink${customAttributeName}`, + view: (attr, { writer: downcastWriter }) => + downcastWriter.createAttributeElement('a', { [`data-ezattribute-${customAttributeName}`]: attr }), + }); + }); + } + + conversion.for('upcast').add((dispatcher) => { + dispatcher.on('element:a', (evt, data, conversionApi) => { + if (conversionApi.consumable.consume(data.viewItem, { attributes: ['href'] })) { + const { modelRange } = conversionApi.convertChildren(data.viewItem, data.modelCursor); + const ibexaLinkHref = data.viewItem.getAttribute('href'); + const ibexaLinkTitle = data.viewItem.getAttribute('title'); + const ibexaLinkTarget = data.viewItem.getAttribute('target'); + const classes = data.viewItem.getAttribute('class'); + + conversionApi.writer.setAttributes( + { + ibexaLinkHref, + ibexaLinkTitle, + ibexaLinkTarget, + }, + modelRange, + ); + + if (classes && customClassesLinkConfig) { + conversionApi.writer.setAttribute('ibexaLinkClasses', classes, modelRange); + } + + if (customAttributesLinkConfig) { + Object.keys(customAttributesLinkConfig).forEach((customAttributeName) => { + const customAttributeValue = data.viewItem.getAttribute(`data-ezattribute-${customAttributeName}`); + + if (customAttributeValue) { + conversionApi.writer.setAttribute(`ibexaLink${customAttributeName}`, customAttributeValue, modelRange); + } + }); + } + } + }); }); } init() { + const customAttributesConfig = getCustomAttributesConfig(); + const customClassesConfig = getCustomClassesConfig(); + const customAttributesLinkConfig = customAttributesConfig.link; + const customClassesLinkConfig = customClassesConfig.link; + this.editor.model.schema.extend('$text', { allowAttributes: 'ibexaLinkHref' }); this.editor.model.schema.extend('$text', { allowAttributes: 'ibexaLinkTitle' }); this.editor.model.schema.extend('$text', { allowAttributes: 'ibexaLinkTarget' }); - this.defineConverters(); + if (customAttributesLinkConfig) { + const attributes = Object.keys(customAttributesLinkConfig); + + attributes.forEach((attribute) => { + this.editor.model.schema.extend('$text', { allowAttributes: `ibexaLink${attribute}` }); + }); + } + + if (customClassesLinkConfig) { + this.editor.model.schema.extend('$text', { allowAttributes: 'ibexaLinkClasses' }); + } + + this.defineConverters(customAttributesLinkConfig, customClassesLinkConfig); this.editor.commands.add('insertIbexaLink', new IbexaLinkCommand(this.editor)); } diff --git a/src/bundle/Resources/public/js/CKEditor/link/link-ui.js b/src/bundle/Resources/public/js/CKEditor/link/link-ui.js index 68b5ab97..7cabc3cc 100644 --- a/src/bundle/Resources/public/js/CKEditor/link/link-ui.js +++ b/src/bundle/Resources/public/js/CKEditor/link/link-ui.js @@ -5,6 +5,7 @@ import findAttributeRange from '@ckeditor/ckeditor5-typing/src/utils/findattribu import IbexaLinkFormView from './ui/link-form-view'; import IbexaButtonView from '../common/button-view/button-view'; +import { getCustomAttributesConfig, getCustomClassesConfig } from '../custom-attributes/helpers/config-helper'; const { Translator } = window; @@ -35,7 +36,7 @@ class IbexaLinkUI extends Plugin { const formView = new IbexaLinkFormView({ locale: this.editor.locale, editor: this.editor }); this.listenTo(formView, 'save-link', () => { - const { url, title, target } = this.formView.getValues(); + const { url, title, target, ibexaLinkClasses, ibexaLinkAttributes } = this.formView.getValues(); const { path: firstPosition } = this.editor.model.document.selection.getFirstPosition(); const { path: lastPosition } = this.editor.model.document.selection.getLastPosition(); const noRangeSelection = firstPosition[0] === lastPosition[0] && firstPosition[1] === lastPosition[1]; @@ -50,7 +51,7 @@ class IbexaLinkUI extends Plugin { this.isNew = false; - this.editor.execute('insertIbexaLink', { href: url, title: title, target: target }); + this.editor.execute('insertIbexaLink', { href: url, title, target, ibexaLinkClasses, ibexaLinkAttributes }); this.hideForm(); }); @@ -88,6 +89,10 @@ class IbexaLinkUI extends Plugin { } showForm() { + const customAttributesConfig = getCustomAttributesConfig(); + const customClassesConfig = getCustomClassesConfig(); + const customAttributesLinkConfig = customAttributesConfig.link; + const customClassesLinkConfig = customClassesConfig.link; const link = this.findLinkElement(); const values = { url: link ? link.getAttribute('href') : '', @@ -95,6 +100,23 @@ class IbexaLinkUI extends Plugin { target: link ? link.getAttribute('target') : '', }; + if (customClassesLinkConfig) { + const defaultCustomClasses = customClassesLinkConfig?.defaultValue ?? ''; + const classesValue = link?.getAttribute('class') ?? defaultCustomClasses; + + values.ibexaLinkClasses = classesValue; + } + + if (customAttributesLinkConfig) { + const attributesValues = Object.entries(customAttributesLinkConfig).reduce((output, [name, config]) => { + output[name] = link?.getAttribute(`data-ezattribute-${name}`) ?? config.defaultValue; + + return output; + }, {}); + + values.ibexaLinkAttributes = attributesValues; + } + this.formView.setValues(values); this.balloon.add({ diff --git a/src/bundle/Resources/public/js/CKEditor/link/ui/link-form-view.js b/src/bundle/Resources/public/js/CKEditor/link/ui/link-form-view.js index a87716fb..ffb63759 100644 --- a/src/bundle/Resources/public/js/CKEditor/link/ui/link-form-view.js +++ b/src/bundle/Resources/public/js/CKEditor/link/ui/link-form-view.js @@ -1,9 +1,14 @@ import View from '@ckeditor/ckeditor5-ui/src/view'; import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview'; import LabeledFieldView from '@ckeditor/ckeditor5-ui/src/labeledfield/labeledfieldview'; -import { createLabeledInputText } from '@ckeditor/ckeditor5-ui/src/labeledfield/utils'; +import Model from '@ckeditor/ckeditor5-ui/src/model'; +import Collection from '@ckeditor/ckeditor5-utils/src/collection'; +import { createLabeledInputText, createLabeledDropdown } from '@ckeditor/ckeditor5-ui/src/labeledfield/utils'; +import { addListToDropdown } from '@ckeditor/ckeditor5-ui/src/dropdown/utils'; import { createLabeledSwitchButton } from '../../common/switch-button/utils'; +import { createLabeledInputNumber } from '../../common/input-number/utils'; +import { getCustomAttributesConfig, getCustomClassesConfig } from '../../custom-attributes/helpers/config-helper'; class IbexaLinkFormView extends View { constructor(props) { @@ -18,8 +23,47 @@ class IbexaLinkFormView extends View { this.urlInputView = this.createTextInput('Link to'); this.titleView = this.createTextInput('Title'); this.targetSwitcherView = this.createBoolean('Open in tab'); + this.attributeRenderMethods = { + string: this.createTextInput, + number: this.createNumberInput, + choice: this.createDropdown, + boolean: this.createBoolean, + }; + this.setValueMethods = { + string: this.setStringValue, + number: this.setNumberValue, + choice: this.setChoiceValue, + boolean: this.setBooleanValue, + }; + this.attributeViews = {}; + + const customAttributesConfig = getCustomAttributesConfig(); + const customClassesConfig = getCustomClassesConfig(); + const customAttributesLinkConfig = customAttributesConfig.link; + const customClassesLinkConfig = customClassesConfig.link; this.children = this.createFormChildren(); + this.attributesChildren = this.createFromAttributesChildren(customAttributesLinkConfig, customClassesLinkConfig); + const customAttributesDefinitions = []; + + if (this.attributesChildren.length > 0) { + customAttributesDefinitions.push({ + tag: 'div', + attributes: { + class: 'ibexa-ckeditor-balloon-form__header', + }, + children: ['Custom Attributes'], + }); + + customAttributesDefinitions.push({ + tag: 'div', + attributes: { + class: 'ibexa-ckeditor-balloon-form__fields ibexa-ckeditor-balloon-form__fields--attributes', + }, + + children: this.attributesChildren, + }); + } this.setTemplate({ tag: 'div', @@ -27,24 +71,26 @@ class IbexaLinkFormView extends View { class: 'ibexa-ckeditor-balloon-form', }, children: [ - { - tag: 'div', - attributes: { - class: 'ibexa-ckeditor-balloon-form__header', - }, - children: ['Link'], - }, { tag: 'form', attributes: { tabindex: '-1', }, children: [ + ...customAttributesDefinitions, + { + tag: 'div', + attributes: { + class: 'ibexa-ckeditor-balloon-form__header', + }, + children: ['Link'], + }, { tag: 'div', attributes: { class: 'ibexa-ckeditor-balloon-form__fields', }, + children: [ this.children.first, { @@ -76,29 +122,82 @@ class IbexaLinkFormView extends View { this.listenTo(this.selectContentButtonView, 'execute', this.chooseContent); } - setValues({ url, title, target }) { - this.urlInputView.fieldView.element.value = url; - this.urlInputView.fieldView.set('value', url); - this.urlInputView.fieldView.set('isEmpty', !url); - - this.titleView.fieldView.element.value = title; - this.titleView.fieldView.set('value', title); - this.titleView.fieldView.set('isEmpty', !title); + setValues({ url, title, target, ibexaLinkClasses, ibexaLinkAttributes = {} }) { + this.setStringValue(this.urlInputView, url); + this.setStringValue(this.titleView, title); this.targetSwitcherView.fieldView.element.value = !!target; this.targetSwitcherView.fieldView.set('value', !!target); this.targetSwitcherView.fieldView.isOn = !!target; this.targetSwitcherView.fieldView.set('isEmpty', false); + + if (ibexaLinkClasses) { + this.setChoiceValue(this.classesView, ibexaLinkClasses); + } + + Object.entries(ibexaLinkAttributes).forEach(([name, value]) => { + const attributeView = this.attributeViews[`ibexaLink${name}`]; + const setValueMethod = this.setValueMethods[this.customAttributes[name].type]; + + if (!attributeView || !setValueMethod) { + return; + } + + setValueMethod(attributeView, value); + }); + } + + setNumberValue(view, value) { + view.fieldView.element.value = value; + view.fieldView.set('value', value); + view.fieldView.set('isEmpty', value !== 0 && !value); + } + + setStringValue(view, value) { + view.fieldView.element.value = value; + view.fieldView.set('value', value); + view.fieldView.set('isEmpty', !value); + } + + setChoiceValue(view, value) { + view.fieldView.element.value = value; + view.fieldView.buttonView.set({ + label: value, + withText: true, + }); + view.set('isEmpty', !value); + } + + setBooleanValue(view, value) { + view.fieldView.isOn = value === 'true'; + view.fieldView.element.value = value; + view.fieldView.set('value', value); + view.fieldView.set('isEmpty', false); } getValues() { const url = this.setProtocol(this.urlInputView.fieldView.element.value); - - return { + const values = { url, title: this.titleView.fieldView.element.value, target: this.targetSwitcherView.fieldView.isOn ? '_blank' : '', }; + const customClassesValue = this.classesView?.fieldView.element.value; + const customAttributesValue = Object.entries(this.attributeViews).reduce((output, [name, view]) => { + output[name] = view.fieldView.element.value; + + return output; + }, {}); + + if (customClassesValue) { + values.ibexaLinkClasses = customClassesValue; + } + + if (Object.keys(customAttributesValue).length > 0) { + values.ibexaLinkAttributes = customAttributesValue; + } + + return values; } setProtocol(href) { @@ -118,6 +217,40 @@ class IbexaLinkFormView extends View { return `http://${href}`; } + createFromAttributesChildren(customAttributes, customClasses) { + const children = this.createCollection(); + + if (customClasses && Object.keys(customClasses).length !== 0) { + const classesView = this.createDropdown(customClasses); + + this.classesView = classesView; + this.customClasses = customClasses; + + children.add(classesView); + } + + if (customAttributes) { + Object.entries(customAttributes).forEach(([name, config]) => { + const createAttributeMethod = this.attributeRenderMethods[config.type]; + + if (!createAttributeMethod) { + return; + } + + const createAttribute = createAttributeMethod.bind(this); + const attributeView = createAttribute(config.label); + + this.attributeViews[`ibexaLink${name}`] = attributeView; + + children.add(attributeView); + }); + + this.customAttributes = customAttributes; + } + + return children; + } + createFormChildren() { const children = this.createCollection(); @@ -129,6 +262,61 @@ class IbexaLinkFormView extends View { return children; } + createDropdown(config) { + const labeledDropdown = new LabeledFieldView(this.locale, createLabeledDropdown); + const itemsList = new Collection(); + + labeledDropdown.label = config.label; + + config.choices.forEach((choice) => { + itemsList.add({ + type: 'button', + model: new Model({ + withText: true, + label: choice, + value: choice, + }), + }); + }); + + addListToDropdown(labeledDropdown.fieldView, itemsList); + + this.listenTo(labeledDropdown.fieldView, 'execute', (event) => { + const value = this.getNewValue(event.source.value, config.multiple, labeledDropdown.fieldView.element.value); + + labeledDropdown.fieldView.buttonView.set({ + label: value, + withText: true, + }); + + labeledDropdown.fieldView.element.value = value; + + if (event.source.value) { + labeledDropdown.set('isEmpty', false); + } + }); + + return labeledDropdown; + } + + getNewValue(clickedValue, multiple, previousValue = '') { + const selectedItems = previousValue ? new Set(previousValue.split(' ')) : new Set(); + + if (selectedItems.has(clickedValue)) { + selectedItems.delete(clickedValue); + + return [...selectedItems].join(' '); + } + + if (!multiple) { + selectedItems.clear(); + } + + selectedItems.add(clickedValue); + + return [...selectedItems].join(' '); + } + createTextInput(label) { const labeledInput = new LabeledFieldView(this.locale, createLabeledInputText); @@ -137,6 +325,14 @@ class IbexaLinkFormView extends View { return labeledInput; } + createNumberInput(config) { + const labeledInput = new LabeledFieldView(this.locale, createLabeledInputNumber); + + labeledInput.label = config.label; + + return labeledInput; + } + createBoolean(label) { const labeledSwitch = new LabeledFieldView(this.locale, createLabeledSwitchButton); diff --git a/src/bundle/Resources/public/scss/_balloon-form.scss b/src/bundle/Resources/public/scss/_balloon-form.scss index 8e2d1aed..6b1b667a 100644 --- a/src/bundle/Resources/public/scss/_balloon-form.scss +++ b/src/bundle/Resources/public/scss/_balloon-form.scss @@ -1,15 +1,20 @@ .ck.ck-reset_all { .ibexa-ckeditor-balloon-form { + padding: 0 calculateRem(16px); + &__header { - background-color: $ibexa-color-light-100; border-top-left-radius: $ibexa-border-radius; border-top-right-radius: $ibexa-border-radius; - padding: calculateRem(2px) calculateRem(16px); + padding: calculateRem(2px) 0; font-weight: bold; } &__fields { - padding: calculateRem(8px) calculateRem(16px); + padding: calculateRem(8px) 0; + + &--attributes { + border-bottom: calculateRem(1px) solid $ibexa-color-light; + } .ck-labeled-field-view { margin-bottom: calculateRem(12px); @@ -128,7 +133,7 @@ } &__actions { - padding: 0 calculateRem(16px) calculateRem(16px); + padding: 0 0 calculateRem(16px); } }