Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,57 @@ 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`
- `phplist.editor_images_dir`

Uploads are stored below `public/<phplist.editor_images_dir>/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.
278 changes: 278 additions & 0 deletions assets/editor/CkEditor.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
<template>
<ckeditor
:id="fieldId"
v-model="localValue"
:editor="ClassicEditor"
:config="editorConfig"
:disabled="isDisabled"
:style="editorStyle"
@ready="handleReady"
/>

<EditorAssetPicker
:open="assetPickerOpen"
:items="assetItems"
:query="assetQuery"
:loading="assetLoading"
:error="assetError"
@close="closeAssetPicker"
@refresh="loadAssets"
@select="insertAsset"
@update:query="assetQuery = $event"
/>
</template>

<script setup>
import { computed, onBeforeUnmount, ref, shallowRef, useId, watch } from 'vue';
import { Ckeditor } from '@ckeditor/ckeditor5-vue';
import { ClassicEditor } from 'ckeditor5';

import { DEFAULT_PLUGINS } from './plugins.ts';
import { DEFAULT_IMAGE_TOOLBAR, DEFAULT_TOOLBAR } from './toolbar.ts';
import EditorUploadAdapter from './uploadAdapter.ts';
import EditorAssetPicker from './EditorAssetPicker.vue';

import 'ckeditor5/ckeditor5.css';

const props = defineProps({
modelValue: {
type: String,
default: '',
},
id: {
type: String,
default: '',
},
readonly: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
minHeight: {
type: [Number, String],
default: 300,
},
uploadEndpoint: {
type: String,
default: '/editor/upload',
},
assetsEndpoint: {
type: String,
default: '/editor/assets',
},
uploadHeaders: {
type: Object,
default: () => ({}),
},
withCredentials: {
type: Boolean,
default: true,
},
toolbar: {
type: Array,
default: null,
},
plugins: {
type: Array,
default: () => [],
},
config: {
type: Object,
default: () => ({}),
},
});

const emit = defineEmits(['update:modelValue', 'ready', 'error']);

const generatedId = useId();
const fieldId = computed(() => props.id || `ckeditor-${generatedId}`);

const localValue = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
});

const editorRef = shallowRef(null);
const assetPickerOpen = ref(false);
const assetLoading = ref(false);
const assetError = ref('');
const assetItems = ref([]);
const assetQuery = ref('');

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 || {};
const extraPlugins = Array.isArray(config.plugins) ? config.plugins : [];
const toolbar = Array.isArray(props.toolbar) && props.toolbar.length > 0
? props.toolbar
: Array.isArray(config.toolbar) && config.toolbar.length > 0
? config.toolbar
: DEFAULT_TOOLBAR;

const imageConfig = {
toolbar: DEFAULT_IMAGE_TOOLBAR,
...(config.image || {}),
};
const htmlSupportConfig = config.htmlSupport || {};
const htmlSupport = {
allow: htmlSupportConfig.allow || [
{
name: /.*/,
attributes: true,
classes: true,
styles: true,
},
],
disallow: [
{ name: 'script' },
{ name: 'iframe' },
{ name: /.*/, attributes: { key: /^on.*/ } },
...(Array.isArray(htmlSupportConfig.disallow) ? htmlSupportConfig.disallow : []),
],
};

return {
...config,
licenseKey: config.licenseKey || 'GPL',
plugins: [
...DEFAULT_PLUGINS,
...(Array.isArray(props.plugins) ? props.plugins : []),
...extraPlugins,
],
toolbar,
image: imageConfig,
htmlSupport,
openAssetPicker: openAssetPicker,
};
});

const installUploadAdapter = (editor) => {
const fileRepository = editor.plugins.get('FileRepository');

fileRepository.createUploadAdapter = (loader) => new EditorUploadAdapter(loader, {
endpoint: props.uploadEndpoint,
headers: props.uploadHeaders,
withCredentials: props.withCredentials,
});
};

const syncReadOnlyState = (editor) => {
if (props.readonly) {
editor.enableReadOnlyMode('ckeditor-field');
return;
}

editor.disableReadOnlyMode('ckeditor-field');
};

const loadAssets = async () => {
assetLoading.value = true;
assetError.value = '';

try {
const response = await fetch(props.assetsEndpoint, {
headers: {
'X-Requested-With': 'XMLHttpRequest',
},
credentials: props.withCredentials ? 'include' : 'same-origin',
});

if (!response.ok) {
throw new Error(`Failed to load assets (${response.status})`);
}

const payload = await response.json();
assetItems.value = Array.isArray(payload?.items) ? payload.items : [];
} catch (error) {
assetError.value = error?.message || 'Failed to load assets.';
} finally {
assetLoading.value = false;
}
};

const openAssetPicker = async () => {
assetPickerOpen.value = true;

if (assetItems.value.length === 0 && !assetLoading.value) {
await loadAssets();
}
};

const closeAssetPicker = () => {
assetPickerOpen.value = false;
};

const getSelectedEditor = () => editorRef.value;

const insertAsset = (asset) => {
if (!asset) {
return;
}

const editor = getSelectedEditor();
if (!editor) {
return;
}

editor.model.change((writer) => {
if (asset.isImage) {
const imageElement = writer.createElement('imageBlock', {
src: asset.url,
alt: asset.fileName,
});
editor.model.insertContent(imageElement, editor.model.document.selection);
return;
}

const linkText = asset.fileName || asset.url;
const textNode = writer.createText(linkText, { linkHref: asset.url });
editor.model.insertContent(textNode, editor.model.document.selection);
});

closeAssetPicker();
};

const handleReady = (editor) => {
editorRef.value = editor;
installUploadAdapter(editor);
syncReadOnlyState(editor);
emit('ready', editor);
};

watch(
() => props.readonly,
() => {
if (!editorRef.value) {
return;
}

syncReadOnlyState(editorRef.value);
}
);

onBeforeUnmount(() => {
if (!editorRef.value) {
return;
}

const editor = editorRef.value;
editorRef.value = null;
editor.destroy();
});
</script>

<style scoped>
:deep(.ck-editor__editable_inline) {
min-height: var(--editor-min-height, 300px);
overflow-y: auto;
}
</style>
Loading
Loading