From c093cd21902bea2205d5aa7e95eeb20a53f97c49 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 8 Jul 2026 18:38:39 +0400 Subject: [PATCH 1/3] Ckeditor --- README.md | 50 +++++ assets/editor/CkEditor.vue | 182 ++++++++++++++++++ assets/editor/index.ts | 4 + assets/editor/plugins.ts | 51 +++++ assets/editor/toolbar.ts | 28 +++ assets/editor/uploadAdapter.ts | 88 +++++++++ assets/vue/components/base/CkEditorField.vue | 154 +++++++-------- config/services.yml | 1 + src/Controller/EditorUploadController.php | 50 +++++ src/Dto/EditorUploadResult.php | 14 ++ src/Service/EditorUploadService.php | 87 +++++++++ .../Controller/EditorUploadControllerTest.php | 76 ++++++++ .../Unit/Service/EditorUploadServiceTest.php | 44 +++++ .../Unit/assets/editor/uploadAdapter.spec.js | 78 ++++++++ .../vue/components/base/CkEditorField.spec.js | 113 ++++------- vitest.config.mjs | 6 +- 16 files changed, 858 insertions(+), 168 deletions(-) create mode 100644 assets/editor/CkEditor.vue create mode 100644 assets/editor/index.ts create mode 100644 assets/editor/plugins.ts create mode 100644 assets/editor/toolbar.ts create mode 100644 assets/editor/uploadAdapter.ts create mode 100644 src/Controller/EditorUploadController.php create mode 100644 src/Dto/EditorUploadResult.php create mode 100644 src/Service/EditorUploadService.php create mode 100644 tests/Integration/Controller/EditorUploadControllerTest.php create mode 100644 tests/Unit/Service/EditorUploadServiceTest.php create mode 100644 tests/Unit/assets/editor/uploadAdapter.spec.js diff --git a/README.md b/README.md index 185b65d..2a3e6c9 100644 --- a/README.md +++ b/README.md @@ -67,3 +67,53 @@ 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/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 returns CKEditor-compatible JSON. + +### Reused from the legacy plugin + +- upload path conventions +- image validation rules +- public-file URL generation strategy +- file storage separation for editor content + +### New behavior + +- Vue component with `v-model` +- configurable toolbar and CKEditor options +- upload adapter with progress support +- cleanup on unmount +- read-only mode support + +### Build and test + +```bash +yarn encore dev +composer test +yarn test:vue +``` + +### Configuration + +The backend uses the existing phpList upload directory parameters: + +- `phplist.upload_images_dir` +- `phplist.editor_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; only the storage model and upload flow are reused. +- 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..966ba6c --- /dev/null +++ b/assets/editor/CkEditor.vue @@ -0,0 +1,182 @@ + + + + + diff --git a/assets/editor/index.ts b/assets/editor/index.ts new file mode 100644 index 0000000..cd0008a --- /dev/null +++ b/assets/editor/index.ts @@ -0,0 +1,4 @@ +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'; diff --git a/assets/editor/plugins.ts b/assets/editor/plugins.ts new file mode 100644 index 0000000..0108105 --- /dev/null +++ b/assets/editor/plugins.ts @@ -0,0 +1,51 @@ +import { + AutoImage, + BlockQuote, + Bold, + Essentials, + FileRepository, + GeneralHtmlSupport, + Heading, + HorizontalLine, + Image, + ImageCaption, + ImageInsert, + ImageInsertViaUrl, + ImageResize, + ImageStyle, + ImageToolbar, + ImageUpload, + Italic, + Link, + List, + Paragraph, + PictureEditing, + Table, + TableToolbar, +} from 'ckeditor5'; + +export const DEFAULT_PLUGINS = [ + Essentials, + Paragraph, + Bold, + Italic, + Heading, + Link, + List, + BlockQuote, + Table, + TableToolbar, + HorizontalLine, + Image, + ImageToolbar, + ImageCaption, + ImageStyle, + ImageResize, + ImageUpload, + ImageInsert, + ImageInsertViaUrl, + AutoImage, + PictureEditing, + FileRepository, + GeneralHtmlSupport, +]; diff --git a/assets/editor/toolbar.ts b/assets/editor/toolbar.ts new file mode 100644 index 0000000..5013b55 --- /dev/null +++ b/assets/editor/toolbar.ts @@ -0,0 +1,28 @@ +export const DEFAULT_TOOLBAR = [ + 'undo', + 'redo', + '|', + 'heading', + '|', + 'bold', + 'italic', + 'link', + '|', + 'bulletedList', + 'numberedList', + '|', + 'blockQuote', + 'insertTable', + 'insertImage', + '|', + 'horizontalLine', +]; + +export const DEFAULT_IMAGE_TOOLBAR = [ + 'imageStyle:inline', + 'imageStyle:block', + 'imageStyle:side', + '|', + '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/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/config/services.yml b/config/services.yml index 76325ac..b27b172 100755 --- a/config/services.yml +++ b/config/services.yml @@ -10,6 +10,7 @@ services: public: false bind: $projectDir: '%kernel.project_dir%' + $editorImagesDir: '%phplist.editor_images_dir%' PhpList\WebFrontend\: resource: '../src/' diff --git a/src/Controller/EditorUploadController.php b/src/Controller/EditorUploadController.php new file mode 100644 index 0000000..f09725f --- /dev/null +++ b/src/Controller/EditorUploadController.php @@ -0,0 +1,50 @@ +files->get('upload'); + + if (!$uploadedFile) { + return new JsonResponse([ + 'error' => [ + 'message' => 'No file was provided.', + ], + ], Response::HTTP_BAD_REQUEST); + } + + try { + $result = $this->editorUploadService->storeImage($uploadedFile); + } catch (RuntimeException $exception) { + return new JsonResponse([ + 'error' => [ + 'message' => $exception->getMessage(), + ], + ], Response::HTTP_BAD_REQUEST); + } + + return new JsonResponse([ + 'url' => $result->relativeUrl, + 'fileName' => $result->fileName, + ]); + } +} diff --git a/src/Dto/EditorUploadResult.php b/src/Dto/EditorUploadResult.php new file mode 100644 index 0000000..c2aa731 --- /dev/null +++ b/src/Dto/EditorUploadResult.php @@ -0,0 +1,14 @@ +isValid()) { + throw new RuntimeException($uploadedFile->getErrorMessage()); + } + + $mimeType = (string) $uploadedFile->getMimeType(); + if (!str_starts_with($mimeType, 'image/')) { + throw new RuntimeException('Only image uploads are supported.'); + } + + $targetDirectory = $this->getTargetDirectory(); + $this->filesystem->mkdir($targetDirectory, 0755); + + $fileName = $this->buildFileName($uploadedFile); + $uploadedFile->move($targetDirectory, $fileName); + + return new EditorUploadResult( + fileName: $fileName, + relativeUrl: $this->buildRelativeUrl($fileName), + ); + } + + public function buildRelativeUrl(string $fileName): string + { + return sprintf('/%s/%s/%s', trim($this->editorImagesDir, '/'), self::STORAGE_SUBDIRECTORY, ltrim($fileName, '/')); + } + + public function getTargetDirectory(): string + { + return rtrim($this->projectDir, '/').'/public/'.trim($this->editorImagesDir, '/').'/'.self::STORAGE_SUBDIRECTORY; + } + + private function buildFileName(UploadedFile $uploadedFile): string + { + $originalName = pathinfo((string) $uploadedFile->getClientOriginalName(), PATHINFO_FILENAME); + $safeName = (new AsciiSlugger())->slug($originalName)->lower()->toString(); + $safeName = $safeName !== '' ? $safeName : 'image'; + + $extension = $uploadedFile->guessExtension(); + if (!is_string($extension) || $extension === '') { + $extension = $this->guessExtensionFromMime((string) $uploadedFile->getMimeType()); + } + + if ($extension === '') { + $extension = 'bin'; + } + + return sprintf('%s-%s.%s', $safeName, bin2hex(random_bytes(6)), $extension); + } + + private function guessExtensionFromMime(string $mimeType): string + { + return match ($mimeType) { + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/gif' => 'gif', + 'image/webp' => 'webp', + 'image/bmp', 'image/x-ms-bmp' => 'bmp', + 'image/svg+xml' => 'svg', + default => '', + }; + } +} diff --git a/tests/Integration/Controller/EditorUploadControllerTest.php b/tests/Integration/Controller/EditorUploadControllerTest.php new file mode 100644 index 0000000..08f774f --- /dev/null +++ b/tests/Integration/Controller/EditorUploadControllerTest.php @@ -0,0 +1,76 @@ +get('router'); + + self::assertSame('/editor/upload', $router->generate('editor_upload')); + } + + public function testUploadReturnsAJsonUrl(): void + { + self::bootKernel(); + + $projectDir = sys_get_temp_dir() . '/phplist-editor-upload-' . bin2hex(random_bytes(4)); + $service = new EditorUploadService($projectDir, 'uploadimages'); + $controller = new EditorUploadController($service); + + $sourceFile = tempnam(sys_get_temp_dir(), 'editor-upload-'); + self::assertIsString($sourceFile); + file_put_contents($sourceFile, base64_decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO3Z4foAAAAASUVORK5CYII=' + )); + + $uploadedFile = new UploadedFile( + $sourceFile, + 'newsletter-image.jpg', + 'image/jpeg', + null, + true + ); + + $request = Request::create('/editor/upload', 'POST', [], [], [ + 'upload' => $uploadedFile, + ]); + $session = new Session(new MockArraySessionStorage()); + $session->set('auth_token', 'integration-token'); + $request->setSession($session); + + $response = $controller->upload($request); + + self::assertSame(200, $response->getStatusCode()); + $payload = $response->toArray(); + + self::assertArrayHasKey('url', $payload); + self::assertArrayHasKey('fileName', $payload); + self::assertSame('/uploadimages/ckeditor5/' . $payload['fileName'], $payload['url']); + self::assertFileExists($projectDir . '/public/uploadimages/ckeditor5/' . $payload['fileName']); + + @unlink($sourceFile); + if (is_file($projectDir . '/public/uploadimages/ckeditor5/' . $payload['fileName'])) { + @unlink($projectDir . '/public/uploadimages/ckeditor5/' . $payload['fileName']); + } + @rmdir($projectDir . '/public/uploadimages/ckeditor5'); + @rmdir($projectDir . '/public/uploadimages'); + @rmdir($projectDir . '/public'); + @rmdir($projectDir); + } +} diff --git a/tests/Unit/Service/EditorUploadServiceTest.php b/tests/Unit/Service/EditorUploadServiceTest.php new file mode 100644 index 0000000..75d3e7c --- /dev/null +++ b/tests/Unit/Service/EditorUploadServiceTest.php @@ -0,0 +1,44 @@ +storeImage($uploadedFile); + + self::assertStringEndsWith('.png', $result->fileName); + self::assertSame('/uploadimages/ckeditor5/' . $result->fileName, $result->relativeUrl); + self::assertFileExists($projectDir . '/public/uploadimages/ckeditor5/' . $result->fileName); + + @unlink($projectDir . '/public/uploadimages/ckeditor5/' . $result->fileName); + @rmdir($projectDir . '/public/uploadimages/ckeditor5'); + @rmdir($projectDir . '/public/uploadimages'); + @rmdir($projectDir . '/public'); + @rmdir($projectDir); + } +} 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', + ], }, }) From d92b65f9414d7c8637660c048ca278344a07469d Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 8 Jul 2026 18:41:36 +0400 Subject: [PATCH 2/3] Integrate CKEditor 5 editor uploads --- README.md | 2 +- assets/editor/CkEditor.vue | 6 ++ .../Controller/EditorUploadControllerTest.php | 2 +- tests/Unit/assets/editor/CkEditor.spec.js | 96 +++++++++++++++++++ 4 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 tests/Unit/assets/editor/CkEditor.spec.js diff --git a/README.md b/README.md index 2a3e6c9..e616ad5 100644 --- a/README.md +++ b/README.md @@ -99,8 +99,8 @@ The rich text editor lives in `assets/editor/` and is exposed to the existing fo ```bash yarn encore dev -composer test yarn test:vue +vendor/bin/phpunit ``` ### Configuration diff --git a/assets/editor/CkEditor.vue b/assets/editor/CkEditor.vue index 966ba6c..590f426 100644 --- a/assets/editor/CkEditor.vue +++ b/assets/editor/CkEditor.vue @@ -5,6 +5,7 @@ :editor="ClassicEditor" :config="editorConfig" :disabled="isDisabled" + :style="editorStyle" @ready="handleReady" /> @@ -80,6 +81,11 @@ const localValue = computed({ const editorRef = shallowRef(null); const isDisabled = computed(() => props.disabled || props.readonly); +const editorStyle = computed(() => ({ + '--editor-min-height': typeof props.minHeight === 'number' + ? `${props.minHeight}px` + : String(props.minHeight), +})); const editorConfig = computed(() => { const config = props.config || {}; diff --git a/tests/Integration/Controller/EditorUploadControllerTest.php b/tests/Integration/Controller/EditorUploadControllerTest.php index 08f774f..6e316c3 100644 --- a/tests/Integration/Controller/EditorUploadControllerTest.php +++ b/tests/Integration/Controller/EditorUploadControllerTest.php @@ -57,7 +57,7 @@ public function testUploadReturnsAJsonUrl(): void $response = $controller->upload($request); self::assertSame(200, $response->getStatusCode()); - $payload = $response->toArray(); + $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); self::assertArrayHasKey('url', $payload); self::assertArrayHasKey('fileName', $payload); diff --git a/tests/Unit/assets/editor/CkEditor.spec.js b/tests/Unit/assets/editor/CkEditor.spec.js new file mode 100644 index 0000000..460cba1 --- /dev/null +++ b/tests/Unit/assets/editor/CkEditor.spec.js @@ -0,0 +1,96 @@ +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import CkEditor from '../../../../assets/editor/CkEditor.vue' + +vi.mock('ckeditor5', () => ({ + ClassicEditor: {}, + Essentials: {}, + Paragraph: {}, + Bold: {}, + Italic: {}, + Heading: {}, + Link: {}, + List: {}, + BlockQuote: {}, + Table: {}, + TableToolbar: {}, + HorizontalLine: {}, + Image: {}, + ImageToolbar: {}, + ImageCaption: {}, + ImageStyle: {}, + ImageResize: {}, + ImageUpload: {}, + ImageInsert: {}, + ImageInsertViaUrl: {}, + AutoImage: {}, + PictureEditing: {}, + FileRepository: {}, + GeneralHtmlSupport: {}, +})) + +vi.mock('@ckeditor/ckeditor5-vue', () => ({ + Ckeditor: { + name: 'ckeditor', + props: [ + 'modelValue', + 'editor', + 'config', + 'id', + 'disabled', + ], + emits: ['update:modelValue', 'ready'], + template: ` +
+ + +
+ `, + }, +})) + +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.htmlSupport.allow[0].name).toBeDefined() + }) + + 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) + }) +}) From 686915f31a5b9e605fe1d7a65f44958773e63b4a Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 8 Jul 2026 18:47:14 +0400 Subject: [PATCH 3/3] Add richer CKEditor asset browser --- README.md | 8 +- assets/editor/CkEditor.vue | 92 ++++++++- assets/editor/EditorAssetPicker.vue | 192 ++++++++++++++++++ assets/editor/assetBrowserPlugin.js | 26 +++ assets/editor/index.ts | 2 + assets/editor/plugins.ts | 67 ++++++ assets/editor/toolbar.ts | 26 ++- composer.json | 3 +- src/Controller/EditorUploadController.php | 11 + src/Dto/EditorAssetItem.php | 30 +++ src/Service/EditorUploadService.php | 57 +++++- .../Controller/EditorUploadControllerTest.php | 67 +++++- .../Unit/Service/EditorUploadServiceTest.php | 67 +++++- tests/Unit/assets/editor/CkEditor.spec.js | 46 ++++- 14 files changed, 673 insertions(+), 21 deletions(-) create mode 100644 assets/editor/EditorAssetPicker.vue create mode 100644 assets/editor/assetBrowserPlugin.js create mode 100644 src/Dto/EditorAssetItem.php diff --git a/README.md b/README.md index e616ad5..aef8fc5 100644 --- a/README.md +++ b/README.md @@ -76,9 +76,11 @@ The rich text editor lives in `assets/editor/` and is exposed to the existing fo - `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 returns CKEditor-compatible JSON. +- `src/Controller/EditorUploadController.php` accepts authenticated uploads and lists existing assets. ### Reused from the legacy plugin @@ -86,6 +88,7 @@ The rich text editor lives in `assets/editor/` and is exposed to the existing fo - 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 @@ -94,6 +97,7 @@ The rich text editor lives in `assets/editor/` and is exposed to the existing fo - upload adapter with progress support - cleanup on unmount - read-only mode support +- existing file browser for reusing uploaded content ### Build and test @@ -115,5 +119,5 @@ Uploads are stored below `public//ckeditor5/`. ### Limitations - This integration only covers image uploads from CKEditor. -- elFinder is not embedded in the frontend UI; only the storage model and upload flow are reused. +- 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 index 590f426..1439afb 100644 --- a/assets/editor/CkEditor.vue +++ b/assets/editor/CkEditor.vue @@ -8,16 +8,29 @@ :style="editorStyle" @ready="handleReady" /> + + 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 index cd0008a..a5c67cf 100644 --- a/assets/editor/index.ts +++ b/assets/editor/index.ts @@ -2,3 +2,5 @@ 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 index 0108105..8809fcf 100644 --- a/assets/editor/plugins.ts +++ b/assets/editor/plugins.ts @@ -1,41 +1,102 @@ 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, @@ -43,9 +104,15 @@ export const DEFAULT_PLUGINS = [ 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 index 5013b55..b711b54 100644 --- a/assets/editor/toolbar.ts +++ b/assets/editor/toolbar.ts @@ -4,24 +4,46 @@ export const DEFAULT_TOOLBAR = [ '|', '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:block', - 'imageStyle:side', + 'imageStyle:wrapText', + 'imageStyle:breakText', '|', 'toggleImageCaption', 'imageTextAlternative', diff --git a/composer.json b/composer.json index 2b1dd66..fdfa974 100755 --- a/composer.json +++ b/composer.json @@ -57,7 +57,8 @@ "symfony/security-bundle": "^6.4", "tatevikgr/rest-api-client": "dev-dev", "phplist/phplist-lan-texts": "^2021.05", - "psr/simple-cache": "^3.0" + "psr/simple-cache": "^3.0", + "ext-fileinfo": "*" }, "require-dev": { "phpunit/phpunit": "^9.5", diff --git a/src/Controller/EditorUploadController.php b/src/Controller/EditorUploadController.php index f09725f..fbf7ac1 100644 --- a/src/Controller/EditorUploadController.php +++ b/src/Controller/EditorUploadController.php @@ -47,4 +47,15 @@ public function upload(Request $request): JsonResponse 'fileName' => $result->fileName, ]); } + + #[Route('/assets', name: 'assets', methods: ['GET'])] + public function assets(): JsonResponse + { + return new JsonResponse([ + 'items' => array_map( + static fn ($asset) => $asset->toArray(), + $this->editorUploadService->listAssets() + ), + ]); + } } diff --git a/src/Dto/EditorAssetItem.php b/src/Dto/EditorAssetItem.php new file mode 100644 index 0000000..ef010d6 --- /dev/null +++ b/src/Dto/EditorAssetItem.php @@ -0,0 +1,30 @@ + $this->fileName, + 'url' => $this->url, + 'mimeType' => $this->mimeType, + 'size' => $this->size, + 'modifiedAt' => $this->modifiedAt, + 'isImage' => $this->isImage, + ]; + } +} diff --git a/src/Service/EditorUploadService.php b/src/Service/EditorUploadService.php index 0d4514a..f230cb2 100644 --- a/src/Service/EditorUploadService.php +++ b/src/Service/EditorUploadService.php @@ -4,7 +4,9 @@ namespace PhpList\WebFrontend\Service; +use PhpList\WebFrontend\Dto\EditorAssetItem; use PhpList\WebFrontend\Dto\EditorUploadResult; +use FilesystemIterator; use RuntimeException; use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\HttpFoundation\File\UploadedFile; @@ -44,14 +46,65 @@ public function storeImage(UploadedFile $uploadedFile): EditorUploadResult ); } + /** + * @return array + */ + public function listAssets(): array + { + $directory = $this->getTargetDirectory(); + + if (!is_dir($directory)) { + return []; + } + + $items = []; + foreach (new FilesystemIterator($directory, FilesystemIterator::SKIP_DOTS) as $fileInfo) { + if (!$fileInfo->isFile()) { + continue; + } + + $fileName = $fileInfo->getBasename(); + if ($fileName === '' || str_starts_with($fileName, '.')) { + continue; + } + + $mimeType = (string) (mime_content_type($fileInfo->getPathname()) ?: 'application/octet-stream'); + + $items[] = new EditorAssetItem( + fileName: $fileName, + url: $this->buildRelativeUrl($fileName), + mimeType: $mimeType, + size: (int) $fileInfo->getSize(), + modifiedAt: (int) $fileInfo->getMTime(), + isImage: str_starts_with($mimeType, 'image/'), + ); + } + + usort( + $items, + static fn (EditorAssetItem $left, EditorAssetItem $right): int => $right->modifiedAt <=> $left->modifiedAt + ); + + return $items; + } + public function buildRelativeUrl(string $fileName): string { - return sprintf('/%s/%s/%s', trim($this->editorImagesDir, '/'), self::STORAGE_SUBDIRECTORY, ltrim($fileName, '/')); + return sprintf( + '/%s/%s/%s', + trim($this->editorImagesDir, '/'), + self::STORAGE_SUBDIRECTORY, + ltrim($fileName, '/') + ); } public function getTargetDirectory(): string { - return rtrim($this->projectDir, '/').'/public/'.trim($this->editorImagesDir, '/').'/'.self::STORAGE_SUBDIRECTORY; + return rtrim($this->projectDir, '/') + . '/public/' + . trim($this->editorImagesDir, '/') + . '/' + . self::STORAGE_SUBDIRECTORY; } private function buildFileName(UploadedFile $uploadedFile): string diff --git a/tests/Integration/Controller/EditorUploadControllerTest.php b/tests/Integration/Controller/EditorUploadControllerTest.php index 6e316c3..6b9f2ab 100644 --- a/tests/Integration/Controller/EditorUploadControllerTest.php +++ b/tests/Integration/Controller/EditorUploadControllerTest.php @@ -64,13 +64,66 @@ public function testUploadReturnsAJsonUrl(): void self::assertSame('/uploadimages/ckeditor5/' . $payload['fileName'], $payload['url']); self::assertFileExists($projectDir . '/public/uploadimages/ckeditor5/' . $payload['fileName']); - @unlink($sourceFile); - if (is_file($projectDir . '/public/uploadimages/ckeditor5/' . $payload['fileName'])) { - @unlink($projectDir . '/public/uploadimages/ckeditor5/' . $payload['fileName']); + if (is_file($sourceFile)) { + unlink($sourceFile); } - @rmdir($projectDir . '/public/uploadimages/ckeditor5'); - @rmdir($projectDir . '/public/uploadimages'); - @rmdir($projectDir . '/public'); - @rmdir($projectDir); + $uploadedFile = $projectDir . '/public/uploadimages/ckeditor5/' . $payload['fileName']; + if (is_file($uploadedFile)) { + unlink($uploadedFile); + } + $this->removePath($projectDir . '/public/uploadimages/ckeditor5'); + $this->removePath($projectDir . '/public/uploadimages'); + $this->removePath($projectDir . '/public'); + $this->removePath($projectDir); + } + + public function testAssetsRouteReturnsExistingFiles(): void + { + self::bootKernel(); + + $projectDir = sys_get_temp_dir() . '/phplist-editor-assets-' . bin2hex(random_bytes(4)); + $service = new EditorUploadService($projectDir, 'uploadimages'); + $controller = new EditorUploadController($service); + + $directory = $projectDir . '/public/uploadimages/ckeditor5'; + mkdir($directory, 0755, true); + file_put_contents($directory . '/asset.txt', 'asset'); + + $response = $controller->assets(); + self::assertSame(200, $response->getStatusCode()); + + $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); + self::assertArrayHasKey('items', $payload); + self::assertCount(1, $payload['items']); + self::assertSame('asset.txt', $payload['items'][0]['fileName']); + + $this->removePath($directory . '/asset.txt'); + $this->removePath($directory); + $this->removePath($projectDir . '/public/uploadimages'); + $this->removePath($projectDir . '/public'); + $this->removePath($projectDir); + } + + private function removePath(string $path): void + { + if (is_file($path) || is_link($path)) { + unlink($path); + + return; + } + + if (!is_dir($path)) { + return; + } + + foreach (scandir($path) as $item) { + if ($item === '.' || $item === '..') { + continue; + } + + $this->removePath($path . DIRECTORY_SEPARATOR . $item); + } + + rmdir($path); } } diff --git a/tests/Unit/Service/EditorUploadServiceTest.php b/tests/Unit/Service/EditorUploadServiceTest.php index 75d3e7c..6615bcf 100644 --- a/tests/Unit/Service/EditorUploadServiceTest.php +++ b/tests/Unit/Service/EditorUploadServiceTest.php @@ -35,10 +35,67 @@ public function testStoreImageMovesTheFileAndBuildsAPublicUrl(): void self::assertSame('/uploadimages/ckeditor5/' . $result->fileName, $result->relativeUrl); self::assertFileExists($projectDir . '/public/uploadimages/ckeditor5/' . $result->fileName); - @unlink($projectDir . '/public/uploadimages/ckeditor5/' . $result->fileName); - @rmdir($projectDir . '/public/uploadimages/ckeditor5'); - @rmdir($projectDir . '/public/uploadimages'); - @rmdir($projectDir . '/public'); - @rmdir($projectDir); + $this->removePath($projectDir . '/public/uploadimages/ckeditor5/' . $result->fileName); + $this->removePath($projectDir . '/public/uploadimages/ckeditor5'); + $this->removePath($projectDir . '/public/uploadimages'); + $this->removePath($projectDir . '/public'); + $this->removePath($projectDir); + } + + public function testListAssetsReturnsUploadedFilesSortedByNewestFirst(): void + { + $projectDir = sys_get_temp_dir() . '/phplist-editor-assets-' . bin2hex(random_bytes(4)); + $service = new EditorUploadService($projectDir, 'uploadimages'); + + $directory = $projectDir . '/public/uploadimages/ckeditor5'; + mkdir($directory, 0755, true); + + $imagePath = $directory . '/image-one.png'; + file_put_contents($imagePath, base64_decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO3Z4foAAAAASUVORK5CYII=' + )); + touch($imagePath, time() - 60); + + $filePath = $directory . '/notes.txt'; + file_put_contents($filePath, 'notes'); + + $assets = $service->listAssets(); + + self::assertCount(2, $assets); + self::assertSame('notes.txt', $assets[0]->fileName); + self::assertFalse($assets[0]->isImage); + self::assertSame('/uploadimages/ckeditor5/notes.txt', $assets[0]->url); + self::assertSame('image-one.png', $assets[1]->fileName); + self::assertTrue($assets[1]->isImage); + + $this->removePath($imagePath); + $this->removePath($filePath); + $this->removePath($directory); + $this->removePath($projectDir . '/public/uploadimages'); + $this->removePath($projectDir . '/public'); + $this->removePath($projectDir); + } + + private function removePath(string $path): void + { + if (is_file($path) || is_link($path)) { + unlink($path); + + return; + } + + if (!is_dir($path)) { + return; + } + + foreach (scandir($path) as $item) { + if ($item === '.' || $item === '..') { + continue; + } + + $this->removePath($path . DIRECTORY_SEPARATOR . $item); + } + + rmdir($path); } } diff --git a/tests/Unit/assets/editor/CkEditor.spec.js b/tests/Unit/assets/editor/CkEditor.spec.js index 460cba1..1848b12 100644 --- a/tests/Unit/assets/editor/CkEditor.spec.js +++ b/tests/Unit/assets/editor/CkEditor.spec.js @@ -4,17 +4,45 @@ import CkEditor from '../../../../assets/editor/CkEditor.vue' vi.mock('ckeditor5', () => ({ ClassicEditor: {}, + Alignment: {}, + AutoImage: {}, + AutoLink: {}, + Autosave: {}, Essentials: {}, Paragraph: {}, Bold: {}, Italic: {}, Heading: {}, + Code: {}, + 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: {}, @@ -22,11 +50,17 @@ vi.mock('ckeditor5', () => ({ ImageResize: {}, ImageUpload: {}, ImageInsert: {}, + ImageInsertUI: {}, ImageInsertViaUrl: {}, - AutoImage: {}, + ImageInline: {}, + ImageBlock: {}, + LinkImage: {}, PictureEditing: {}, + PlainTableOutput: {}, FileRepository: {}, GeneralHtmlSupport: {}, + Plugin: class {}, + ButtonView: class {}, })) vi.mock('@ckeditor/ckeditor5-vue', () => ({ @@ -84,9 +118,19 @@ describe('CkEditor', () => { 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 })