diff --git a/README.md b/README.md index 185b65d..0971e85 100644 --- a/README.md +++ b/README.md @@ -67,3 +67,56 @@ To add new Vue components: 1. Create the component in the `assets/vue/` directory 2. Import and mount it in `assets/app.js` 3. Add a mount point in the appropriate template + +## CKEditor 5 Integration + +The rich text editor lives in `assets/editor/` and is exposed to the existing forms through `assets/vue/components/base/CkEditorField.vue`. + +### Architecture + +- `assets/editor/CkEditor.vue` is the reusable Vue 3 editor component. +- `assets/editor/uploadAdapter.ts` provides CKEditor upload support against a Symfony endpoint. +- `assets/editor/assetBrowserPlugin.js` adds a toolbar button that opens the native asset browser. +- `assets/editor/EditorAssetPicker.vue` shows existing uploaded files and inserts them as images or links. +- `assets/editor/plugins.ts` and `assets/editor/toolbar.ts` keep the editor configuration isolated and reusable. +- `src/Service/EditorUploadService.php` stores uploads in the public filesystem and returns a browser URL. +- `src/Controller/EditorUploadController.php` accepts authenticated uploads and lists existing assets. + +### Reused from the legacy plugin + +- upload path conventions +- image validation rules +- public-file URL generation strategy +- file storage separation for editor content +- asset browsing behavior, expressed as a native Vue modal instead of a popup window + +### New behavior + +- Vue component with `v-model` +- configurable toolbar and CKEditor options +- upload adapter with progress support +- cleanup on unmount +- read-only mode support +- existing file browser for reusing uploaded content + +### Build and test + +```bash +yarn encore dev +yarn test:vue +vendor/bin/phpunit +``` + +### Configuration + +The backend uses the existing phpList upload directory parameters: + +- `phplist.upload_images_dir` + +Uploads are stored below `public//ckeditor5/`. + +### Limitations + +- This integration only covers image uploads from CKEditor. +- elFinder is not embedded in the frontend UI; the frontend now provides a native asset browser instead. +- Existing legacy HTML content is preserved as-is, but custom HTML support still depends on CKEditor 5 output rules. diff --git a/assets/editor/CkEditor.vue b/assets/editor/CkEditor.vue new file mode 100644 index 0000000..1439afb --- /dev/null +++ b/assets/editor/CkEditor.vue @@ -0,0 +1,278 @@ + + + + + diff --git a/assets/editor/EditorAssetPicker.vue b/assets/editor/EditorAssetPicker.vue new file mode 100644 index 0000000..ba16dc2 --- /dev/null +++ b/assets/editor/EditorAssetPicker.vue @@ -0,0 +1,218 @@ + + + diff --git a/assets/editor/assetBrowserPlugin.js b/assets/editor/assetBrowserPlugin.js new file mode 100644 index 0000000..1bf752e --- /dev/null +++ b/assets/editor/assetBrowserPlugin.js @@ -0,0 +1,26 @@ +import { ButtonView, Plugin } from 'ckeditor5'; + +export default class AssetBrowserPlugin extends Plugin { + init() { + const editor = this.editor; + + editor.ui.componentFactory.add('assetBrowser', (locale) => { + const view = new ButtonView(locale); + const openAssetPicker = editor.config.get('openAssetPicker'); + + view.set({ + label: 'Browse files', + tooltip: true, + withText: true, + }); + + view.on('execute', () => { + if (typeof openAssetPicker === 'function') { + openAssetPicker(editor); + } + }); + + return view; + }); + } +} diff --git a/assets/editor/index.ts b/assets/editor/index.ts new file mode 100644 index 0000000..a5c67cf --- /dev/null +++ b/assets/editor/index.ts @@ -0,0 +1,6 @@ +export { default as CkEditor } from './CkEditor.vue'; +export { default as EditorUploadAdapter } from './uploadAdapter.ts'; +export { DEFAULT_TOOLBAR, DEFAULT_IMAGE_TOOLBAR } from './toolbar.ts'; +export { DEFAULT_PLUGINS } from './plugins.ts'; +export { default as EditorAssetPicker } from './EditorAssetPicker.vue'; +export { default as AssetBrowserPlugin } from './assetBrowserPlugin.js'; diff --git a/assets/editor/plugins.ts b/assets/editor/plugins.ts new file mode 100644 index 0000000..8809fcf --- /dev/null +++ b/assets/editor/plugins.ts @@ -0,0 +1,118 @@ +import { + Alignment, + AutoImage, + AutoLink, + Autosave, + BlockQuote, + Bold, + Code, + FontBackgroundColor, + FontColor, + FontFamily, + FontSize, + Essentials, + FileRepository, + GeneralHtmlSupport, + Heading, + Highlight, + HorizontalLine, + HtmlEmbed, + Indent, + IndentBlock, + Image, + ImageCaption, + ImageBlock, + ImageInsert, + ImageInsertUI, + ImageInsertViaUrl, + ImageInline, + ImageResize, + ImageStyle, + ImageToolbar, + ImageUpload, + Italic, + Link, + LinkImage, + List, + ListProperties, + MediaEmbed, + Paragraph, + PictureEditing, + PlainTableOutput, + RemoveFormat, + SourceEditing, + Strikethrough, + Subscript, + Superscript, + Table, + TableCaption, + TableCellProperties, + TableColumnResize, + TableLayout, + TableProperties, + TableToolbar, + TextTransformation, + TodoList, + Underline, +} from 'ckeditor5'; + +import AssetBrowserPlugin from './assetBrowserPlugin.js'; + +export const DEFAULT_PLUGINS = [ + Essentials, + Alignment, + AutoLink, + Autosave, + Paragraph, + Bold, + Italic, + Code, + Heading, + FontBackgroundColor, + FontColor, + FontFamily, + FontSize, + Link, + List, + ListProperties, + BlockQuote, + Highlight, + Table, + TableCaption, + TableCellProperties, + TableColumnResize, + TableLayout, + TableProperties, + TableToolbar, + HorizontalLine, + HtmlEmbed, + SourceEditing, + RemoveFormat, + Strikethrough, + Subscript, + Superscript, + Underline, + TextTransformation, + TodoList, + Indent, + IndentBlock, + MediaEmbed, + Image, + ImageToolbar, + ImageCaption, + ImageStyle, + ImageResize, + ImageUpload, + ImageInsert, + ImageInsertUI, + ImageInsertViaUrl, + ImageInline, + ImageBlock, + LinkImage, + AutoImage, + PictureEditing, + PlainTableOutput, + FileRepository, + GeneralHtmlSupport, + AssetBrowserPlugin, +]; diff --git a/assets/editor/toolbar.ts b/assets/editor/toolbar.ts new file mode 100644 index 0000000..b711b54 --- /dev/null +++ b/assets/editor/toolbar.ts @@ -0,0 +1,50 @@ +export const DEFAULT_TOOLBAR = [ + 'undo', + 'redo', + '|', + 'heading', + '|', + 'fontSize', + 'fontFamily', + 'fontColor', + 'fontBackgroundColor', + '|', + 'bold', + 'italic', + 'underline', + 'strikethrough', + 'subscript', + 'superscript', + 'code', + 'removeFormat', + '|', + 'link', + 'highlight', + 'alignment', + '|', + 'bulletedList', + 'numberedList', + 'todoList', + '|', + 'blockQuote', + 'insertTable', + 'insertTableLayout', + 'insertImage', + 'sourceEditing', + 'htmlEmbed', + 'mediaEmbed', + 'assetBrowser', + '|', + 'horizontalLine', + 'outdent', + 'indent', +]; + +export const DEFAULT_IMAGE_TOOLBAR = [ + 'imageStyle:inline', + 'imageStyle:wrapText', + 'imageStyle:breakText', + '|', + 'toggleImageCaption', + 'imageTextAlternative', +]; diff --git a/assets/editor/uploadAdapter.ts b/assets/editor/uploadAdapter.ts new file mode 100644 index 0000000..f54c273 --- /dev/null +++ b/assets/editor/uploadAdapter.ts @@ -0,0 +1,88 @@ +export default class EditorUploadAdapter { + constructor(loader, options = {}) { + this.loader = loader; + this.endpoint = options.endpoint || '/editor/upload'; + this.headers = options.headers || {}; + this.withCredentials = options.withCredentials !== false; + this.xhr = null; + } + + upload() { + return this.loader.file.then((file) => new Promise((resolve, reject) => { + const xhr = this.xhr = new XMLHttpRequest(); + const formData = new FormData(); + + xhr.open('POST', this.endpoint, true); + xhr.responseType = 'json'; + xhr.withCredentials = this.withCredentials; + xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); + + Object.entries(this.headers).forEach(([key, value]) => { + xhr.setRequestHeader(key, value); + }); + + xhr.upload.addEventListener('progress', (event) => { + if (!event.lengthComputable) { + return; + } + + this.loader.uploadTotal = event.total; + this.loader.uploaded = event.loaded; + }); + + xhr.addEventListener('error', () => { + reject('The file could not be uploaded.'); + }); + + xhr.addEventListener('abort', () => { + reject('The upload was aborted.'); + }); + + xhr.addEventListener('load', () => { + const response = xhr.response || parseResponse(xhr.responseText); + + if (!response) { + reject('The upload response was empty.'); + return; + } + + if (response.error?.message) { + reject(response.error.message); + return; + } + + if (typeof response.url === 'string' && response.url.length > 0) { + resolve({ + default: response.url, + }); + return; + } + + reject('The upload response did not include a file URL.'); + }); + + formData.append('upload', file); + formData.append('fileName', file.name); + + xhr.send(formData); + })); + } + + abort() { + if (this.xhr) { + this.xhr.abort(); + } + } +} + +function parseResponse(responseText) { + if (typeof responseText !== 'string' || responseText.trim() === '') { + return null; + } + + try { + return JSON.parse(responseText); + } catch (_error) { + return null; + } +} diff --git a/assets/vue/App.vue b/assets/vue/App.vue index e38be10..dacfe52 100644 --- a/assets/vue/App.vue +++ b/assets/vue/App.vue @@ -2,7 +2,7 @@
-
+
diff --git a/assets/vue/components/base/CkEditorField.vue b/assets/vue/components/base/CkEditorField.vue index 9a56ad6..2f8e3b3 100644 --- a/assets/vue/components/base/CkEditorField.vue +++ b/assets/vue/components/base/CkEditorField.vue @@ -1,5 +1,5 @@ diff --git a/assets/vue/views/CampaignEditView.vue b/assets/vue/views/CampaignEditView.vue index 55a3acd..9f39a4c 100644 --- a/assets/vue/views/CampaignEditView.vue +++ b/assets/vue/views/CampaignEditView.vue @@ -27,7 +27,7 @@
-
+
+ +
+ `, + }, +})) + +describe('CkEditor', () => { + const createWrapper = (props = {}) => + mount(CkEditor, { + props: { + ...props, + }, + }) + + it('syncs v-model updates', async () => { + const wrapper = createWrapper({ modelValue: '

Initial

' }) + + await wrapper.find('.change-value').trigger('click') + + expect(wrapper.emitted('update:modelValue')).toEqual([ + ['

Updated

'], + ]) + }) + + it('passes the expected editor config', () => { + const wrapper = createWrapper() + + const editor = wrapper.findComponent({ name: 'ckeditor' }) + const config = editor.props('config') + + expect(config.licenseKey).toBe('GPL') + expect(config.toolbar).toContain('insertImage') + expect(config.toolbar).toContain('assetBrowser') + expect(config.htmlSupport.allow[0].name).toBeDefined() + }) + + it('exposes an asset picker hook in the editor config', () => { + const wrapper = createWrapper() + + const editor = wrapper.findComponent({ name: 'ckeditor' }) + const config = editor.props('config') + + expect(typeof config.openAssetPicker).toBe('function') + }) + + it('disables the editor when readonly is set', () => { + const wrapper = createWrapper({ readonly: true }) + + const editor = wrapper.findComponent({ name: 'ckeditor' }) + expect(editor.props('disabled')).toBe(true) + }) +}) diff --git a/tests/Unit/assets/editor/uploadAdapter.spec.js b/tests/Unit/assets/editor/uploadAdapter.spec.js new file mode 100644 index 0000000..7f60758 --- /dev/null +++ b/tests/Unit/assets/editor/uploadAdapter.spec.js @@ -0,0 +1,78 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import EditorUploadAdapter from '../../../../assets/editor/uploadAdapter.ts' + +const originalXhr = global.XMLHttpRequest + +describe('EditorUploadAdapter', () => { + let listeners + let sendMock + + beforeEach(() => { + listeners = {} + sendMock = vi.fn() + + global.XMLHttpRequest = class { + constructor() { + this.upload = { + addEventListener: (event, handler) => { + listeners[`upload:${event}`] = handler + }, + } + this.addEventListener = (event, handler) => { + listeners[event] = handler + } + this.open = vi.fn() + this.setRequestHeader = vi.fn() + this.send = sendMock + this.abort = vi.fn() + this.response = null + this.responseText = '' + this.responseType = '' + this.withCredentials = false + } + } + }) + + afterEach(() => { + global.XMLHttpRequest = originalXhr + }) + + it('uploads files and resolves the returned URL', async () => { + const loader = { + file: Promise.resolve(new File(['image'], 'banner.png', { type: 'image/png' })), + } + + const adapter = new EditorUploadAdapter(loader, { + endpoint: '/editor/upload', + }) + + const uploadPromise = adapter.upload() + await Promise.resolve() + const xhr = adapter.xhr + + xhr.response = { url: '/uploadimages/ckeditor5/banner.png' } + listeners.load() + + await expect(uploadPromise).resolves.toEqual({ + default: '/uploadimages/ckeditor5/banner.png', + }) + + expect(sendMock).toHaveBeenCalledOnce() + expect(xhr.open).toHaveBeenCalledWith('POST', '/editor/upload', true) + }) + + it('rejects when the backend returns an error message', async () => { + const loader = { + file: Promise.resolve(new File(['image'], 'banner.png', { type: 'image/png' })), + } + + const adapter = new EditorUploadAdapter(loader) + const uploadPromise = adapter.upload() + await Promise.resolve() + + adapter.xhr.response = { error: { message: 'Upload failed.' } } + listeners.load() + + await expect(uploadPromise).rejects.toBe('Upload failed.') + }) +}) diff --git a/tests/Unit/assets/vue/components/base/CkEditorField.spec.js b/tests/Unit/assets/vue/components/base/CkEditorField.spec.js index 25047ce..21bf85a 100644 --- a/tests/Unit/assets/vue/components/base/CkEditorField.spec.js +++ b/tests/Unit/assets/vue/components/base/CkEditorField.spec.js @@ -1,50 +1,30 @@ -// CkEditorField.spec.js - import { describe, it, expect, vi } from 'vitest' import { mount } from '@vue/test-utils' import CkEditorField from '../../../../../../assets/vue/components/base/CkEditorField.vue' -vi.mock('ckeditor5', () => ({ - ClassicEditor: {}, - Essentials: {}, - Paragraph: {}, - Bold: {}, - Italic: {}, - Heading: {}, - Link: {}, - List: {}, - BlockQuote: {}, - Table: {}, - TableToolbar: {}, - HorizontalLine: {}, - Image: {}, - ImageToolbar: {}, - ImageCaption: {}, - ImageStyle: {}, - ImageResize: {}, - AutoImage: {}, - PictureEditing: {}, -})) - -const CkeditorStub = { - name: 'ckeditor', +const CkEditorStub = { + name: 'CkEditor', props: [ 'modelValue', - 'editor', - 'config', 'id', + 'readonly', + 'disabled', + 'minHeight', + 'uploadEndpoint', + 'uploadHeaders', + 'withCredentials', + 'toolbar', + 'plugins', + 'config', ], emits: ['update:modelValue'], template: ` -
- -
- `, +
+ +
+ `, } describe('CkEditorField', () => { @@ -55,7 +35,7 @@ describe('CkEditorField', () => { }, global: { stubs: { - ckeditor: CkeditorStub, + CkEditor: CkEditorStub, }, }, }) @@ -80,8 +60,7 @@ describe('CkEditorField', () => { modelValue: '

Hello

', }) - const editor = - wrapper.findComponent(CkeditorStub) + const editor = wrapper.findComponent(CkEditorStub) expect(editor.props('modelValue')) .toBe('

Hello

') @@ -130,55 +109,33 @@ describe('CkEditorField', () => { id: 'editor-1', }) - const editor = - wrapper.findComponent(CkeditorStub) + const editor = wrapper.findComponent(CkEditorStub) expect(editor.props('id')) .toBe('editor-1') }) - it('passes editor instance to ckeditor', () => { - const wrapper = createWrapper() - - const editor = - wrapper.findComponent(CkeditorStub) - - expect(editor.props('editor')) - .toBeDefined() - }) - - it('passes editor config to ckeditor', () => { + it('passes editor options through to the editor component', () => { const wrapper = createWrapper() - const editor = - wrapper.findComponent(CkeditorStub) + const editor = wrapper.findComponent(CkEditorStub) - const config = editor.props('config') + expect(editor.props('uploadEndpoint')) + .toBe('/editor/upload') - expect(config.licenseKey) - .toBe('GPL') - - expect(config.toolbar) - .toContain('bold') - - expect(config.toolbar) - .toContain('italic') - - expect(config.toolbar) - .toContain('insertTable') + expect(editor.props('minHeight')) + .toBe(300) }) - it('contains image toolbar configuration', () => { - const wrapper = createWrapper() - - const config = - wrapper.findComponent(CkeditorStub) - .props('config') - - expect(config.image.toolbar) - .toContain('toggleImageCaption') + it('forwards custom config and toolbar props', () => { + const toolbar = ['undo', 'bold'] + const wrapper = createWrapper({ + toolbar, + config: { placeholder: 'Write here' }, + }) - expect(config.image.toolbar) - .toContain('imageTextAlternative') + const editor = wrapper.findComponent(CkEditorStub) + expect(editor.props('toolbar')).toEqual(toolbar) + expect(editor.props('config')).toEqual({ placeholder: 'Write here' }) }) }) diff --git a/vitest.config.mjs b/vitest.config.mjs index df36cd0..6bd1776 100644 --- a/vitest.config.mjs +++ b/vitest.config.mjs @@ -6,6 +6,10 @@ export default defineConfig({ test: { environment: 'jsdom', globals: true, - include: ['assets/vue/**/*.spec.js', 'tests/Unit/assets/vue/**/*.spec.js'], + include: [ + 'assets/vue/**/*.spec.js', + 'tests/Unit/assets/vue/**/*.spec.js', + 'tests/Unit/assets/editor/**/*.spec.js', + ], }, }) diff --git a/yarn.lock b/yarn.lock index 2b374b3..af05d36 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2119,10 +2119,10 @@ postcss "^8.5.6" tailwindcss "4.2.1" -"@tatevikgr/rest-api-client@^2.1.15": - version "2.1.15" - resolved "https://registry.yarnpkg.com/@tatevikgr/rest-api-client/-/rest-api-client-2.1.15.tgz#5bf87fbaac2b4a8c0d34d478045c113da14f4756" - integrity sha512-HSzpfG5bSsZpEu/gSeiyDzSCpk3+BsIxp9F8zJOTgwoWGPiuJoyQqUs1oE+Md4okqvEt+ZwJKpsgnOYzcDiXhw== +"@tatevikgr/rest-api-client@^2.1.16": + version "2.1.16" + resolved "https://registry.yarnpkg.com/@tatevikgr/rest-api-client/-/rest-api-client-2.1.16.tgz#bcbc25a6177705e7c629b508dc333689d21f5c92" + integrity sha512-VOVp+Z/iXqozgRIDKzZfsevAkmzJ0OwxpnXZt9N9uwz+Xlg+nYBKyb6kJjrBtAJjKDmHQY3LWG4/B9gr2XgRbA== dependencies: axios "^1.6.0"