Skip to content

Commit 0a3d0d0

Browse files
TatevikGrtatevikg1
andauthored
Ckeditor (#90)
* Ckeditor * Integrate CKEditor 5 editor uploads * Add richer CKEditor asset browser * Fix: AuthenticationException handling * init * fix * esc key event * no assets found message * set max upload file size to 10 MB * introduce UpstreamServiceException for handling API errors and update response status codes * ApiExceptionSubscriber * sort uploaded assets by modification date to display newest first * sort uploaded assets by modification date to display newest first * make campagn edit responsive * update rest-api-client dependency to version 2.1.16 and adjust uploadsClient method call --------- Co-authored-by: Tatevik <tatevikg1@gmail.com>
1 parent fc288d2 commit 0a3d0d0

33 files changed

Lines changed: 2107 additions & 195 deletions

README.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,56 @@ To add new Vue components:
6767
1. Create the component in the `assets/vue/` directory
6868
2. Import and mount it in `assets/app.js`
6969
3. Add a mount point in the appropriate template
70+
71+
## CKEditor 5 Integration
72+
73+
The rich text editor lives in `assets/editor/` and is exposed to the existing forms through `assets/vue/components/base/CkEditorField.vue`.
74+
75+
### Architecture
76+
77+
- `assets/editor/CkEditor.vue` is the reusable Vue 3 editor component.
78+
- `assets/editor/uploadAdapter.ts` provides CKEditor upload support against a Symfony endpoint.
79+
- `assets/editor/assetBrowserPlugin.js` adds a toolbar button that opens the native asset browser.
80+
- `assets/editor/EditorAssetPicker.vue` shows existing uploaded files and inserts them as images or links.
81+
- `assets/editor/plugins.ts` and `assets/editor/toolbar.ts` keep the editor configuration isolated and reusable.
82+
- `src/Service/EditorUploadService.php` stores uploads in the public filesystem and returns a browser URL.
83+
- `src/Controller/EditorUploadController.php` accepts authenticated uploads and lists existing assets.
84+
85+
### Reused from the legacy plugin
86+
87+
- upload path conventions
88+
- image validation rules
89+
- public-file URL generation strategy
90+
- file storage separation for editor content
91+
- asset browsing behavior, expressed as a native Vue modal instead of a popup window
92+
93+
### New behavior
94+
95+
- Vue component with `v-model`
96+
- configurable toolbar and CKEditor options
97+
- upload adapter with progress support
98+
- cleanup on unmount
99+
- read-only mode support
100+
- existing file browser for reusing uploaded content
101+
102+
### Build and test
103+
104+
```bash
105+
yarn encore dev
106+
yarn test:vue
107+
vendor/bin/phpunit
108+
```
109+
110+
### Configuration
111+
112+
The backend uses the existing phpList upload directory parameters:
113+
114+
- `phplist.upload_images_dir`
115+
116+
Uploads are stored below `public/<phplist.upload_images_dir>/ckeditor5/`.
117+
118+
### Limitations
119+
120+
- This integration only covers image uploads from CKEditor.
121+
- elFinder is not embedded in the frontend UI; the frontend now provides a native asset browser instead.
122+
- Existing legacy HTML content is preserved as-is, but custom HTML support still depends on CKEditor 5 output rules.

assets/editor/CkEditor.vue

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
<template>
2+
<ckeditor
3+
:id="fieldId"
4+
v-model="localValue"
5+
:editor="ClassicEditor"
6+
:config="editorConfig"
7+
:disabled="isDisabled"
8+
:style="editorStyle"
9+
@ready="handleReady"
10+
/>
11+
12+
<EditorAssetPicker
13+
:open="assetPickerOpen"
14+
:items="assetItems"
15+
:query="assetQuery"
16+
:loading="assetLoading"
17+
:error="assetError"
18+
@close="closeAssetPicker"
19+
@refresh="loadAssets"
20+
@select="insertAsset"
21+
@update:query="assetQuery = $event"
22+
/>
23+
</template>
24+
25+
<script setup>
26+
import { computed, onBeforeUnmount, ref, shallowRef, useId, watch } from 'vue';
27+
import { Ckeditor } from '@ckeditor/ckeditor5-vue';
28+
import { ClassicEditor } from 'ckeditor5';
29+
30+
import { DEFAULT_PLUGINS } from './plugins.ts';
31+
import { DEFAULT_IMAGE_TOOLBAR, DEFAULT_TOOLBAR } from './toolbar.ts';
32+
import EditorUploadAdapter from './uploadAdapter.ts';
33+
import EditorAssetPicker from './EditorAssetPicker.vue';
34+
35+
import 'ckeditor5/ckeditor5.css';
36+
37+
const props = defineProps({
38+
modelValue: {
39+
type: String,
40+
default: '',
41+
},
42+
id: {
43+
type: String,
44+
default: '',
45+
},
46+
readonly: {
47+
type: Boolean,
48+
default: false,
49+
},
50+
disabled: {
51+
type: Boolean,
52+
default: false,
53+
},
54+
minHeight: {
55+
type: [Number, String],
56+
default: 300,
57+
},
58+
uploadEndpoint: {
59+
type: String,
60+
default: '/editor/upload',
61+
},
62+
assetsEndpoint: {
63+
type: String,
64+
default: '/editor/assets',
65+
},
66+
uploadHeaders: {
67+
type: Object,
68+
default: () => ({}),
69+
},
70+
withCredentials: {
71+
type: Boolean,
72+
default: true,
73+
},
74+
toolbar: {
75+
type: Array,
76+
default: null,
77+
},
78+
plugins: {
79+
type: Array,
80+
default: () => [],
81+
},
82+
config: {
83+
type: Object,
84+
default: () => ({}),
85+
},
86+
});
87+
88+
const emit = defineEmits(['update:modelValue', 'ready', 'error']);
89+
90+
const generatedId = useId();
91+
const fieldId = computed(() => props.id || `ckeditor-${generatedId}`);
92+
93+
const localValue = computed({
94+
get: () => props.modelValue,
95+
set: (value) => emit('update:modelValue', value),
96+
});
97+
98+
const editorRef = shallowRef(null);
99+
const assetPickerOpen = ref(false);
100+
const assetLoading = ref(false);
101+
const assetError = ref('');
102+
const assetItems = ref([]);
103+
const assetQuery = ref('');
104+
105+
const isDisabled = computed(() => props.disabled || props.readonly);
106+
const editorStyle = computed(() => ({
107+
'--editor-min-height': typeof props.minHeight === 'number'
108+
? `${props.minHeight}px`
109+
: String(props.minHeight),
110+
}));
111+
112+
const editorConfig = computed(() => {
113+
const config = props.config || {};
114+
const extraPlugins = Array.isArray(config.plugins) ? config.plugins : [];
115+
const toolbar = Array.isArray(props.toolbar) && props.toolbar.length > 0
116+
? props.toolbar
117+
: Array.isArray(config.toolbar) && config.toolbar.length > 0
118+
? config.toolbar
119+
: DEFAULT_TOOLBAR;
120+
121+
const imageConfig = {
122+
toolbar: DEFAULT_IMAGE_TOOLBAR,
123+
...(config.image || {}),
124+
};
125+
const htmlSupportConfig = config.htmlSupport || {};
126+
const htmlSupport = {
127+
allow: htmlSupportConfig.allow || [
128+
{
129+
name: /.*/,
130+
attributes: true,
131+
classes: true,
132+
styles: true,
133+
},
134+
],
135+
disallow: [
136+
{ name: 'script' },
137+
{ name: 'iframe' },
138+
{ name: /.*/, attributes: { key: /^on.*/ } },
139+
...(Array.isArray(htmlSupportConfig.disallow) ? htmlSupportConfig.disallow : []),
140+
],
141+
};
142+
143+
return {
144+
...config,
145+
licenseKey: config.licenseKey || 'GPL',
146+
plugins: [
147+
...DEFAULT_PLUGINS,
148+
...(Array.isArray(props.plugins) ? props.plugins : []),
149+
...extraPlugins,
150+
],
151+
toolbar,
152+
image: imageConfig,
153+
htmlSupport,
154+
openAssetPicker: openAssetPicker,
155+
};
156+
});
157+
158+
const installUploadAdapter = (editor) => {
159+
const fileRepository = editor.plugins.get('FileRepository');
160+
161+
fileRepository.createUploadAdapter = (loader) => new EditorUploadAdapter(loader, {
162+
endpoint: props.uploadEndpoint,
163+
headers: props.uploadHeaders,
164+
withCredentials: props.withCredentials,
165+
});
166+
};
167+
168+
const syncReadOnlyState = (editor) => {
169+
if (props.readonly) {
170+
editor.enableReadOnlyMode('ckeditor-field');
171+
return;
172+
}
173+
174+
editor.disableReadOnlyMode('ckeditor-field');
175+
};
176+
177+
const loadAssets = async () => {
178+
assetLoading.value = true;
179+
assetError.value = '';
180+
181+
try {
182+
const response = await fetch(props.assetsEndpoint, {
183+
headers: {
184+
'X-Requested-With': 'XMLHttpRequest',
185+
},
186+
credentials: props.withCredentials ? 'include' : 'same-origin',
187+
});
188+
189+
if (!response.ok) {
190+
throw new Error(`Failed to load assets (${response.status})`);
191+
}
192+
193+
const payload = await response.json();
194+
assetItems.value = Array.isArray(payload?.items) ? payload.items : [];
195+
} catch (error) {
196+
assetError.value = error?.message || 'Failed to load assets.';
197+
} finally {
198+
assetLoading.value = false;
199+
}
200+
};
201+
202+
const openAssetPicker = async () => {
203+
assetPickerOpen.value = true;
204+
205+
if (assetItems.value.length === 0 && !assetLoading.value) {
206+
await loadAssets();
207+
}
208+
};
209+
210+
const closeAssetPicker = () => {
211+
assetPickerOpen.value = false;
212+
};
213+
214+
const getSelectedEditor = () => editorRef.value;
215+
216+
const insertAsset = (asset) => {
217+
if (!asset) {
218+
return;
219+
}
220+
221+
const editor = getSelectedEditor();
222+
if (!editor) {
223+
return;
224+
}
225+
226+
editor.model.change((writer) => {
227+
if (asset.isImage) {
228+
const imageElement = writer.createElement('imageBlock', {
229+
src: asset.url,
230+
alt: asset.fileName,
231+
});
232+
editor.model.insertContent(imageElement, editor.model.document.selection);
233+
return;
234+
}
235+
236+
const linkText = asset.fileName || asset.url;
237+
const textNode = writer.createText(linkText, { linkHref: asset.url });
238+
editor.model.insertContent(textNode, editor.model.document.selection);
239+
});
240+
241+
closeAssetPicker();
242+
};
243+
244+
const handleReady = (editor) => {
245+
editorRef.value = editor;
246+
installUploadAdapter(editor);
247+
syncReadOnlyState(editor);
248+
emit('ready', editor);
249+
};
250+
251+
watch(
252+
() => props.readonly,
253+
() => {
254+
if (!editorRef.value) {
255+
return;
256+
}
257+
258+
syncReadOnlyState(editorRef.value);
259+
}
260+
);
261+
262+
onBeforeUnmount(() => {
263+
if (!editorRef.value) {
264+
return;
265+
}
266+
267+
const editor = editorRef.value;
268+
editorRef.value = null;
269+
editor.destroy();
270+
});
271+
</script>
272+
273+
<style scoped>
274+
:deep(.ck-editor__editable_inline) {
275+
min-height: var(--editor-min-height, 300px);
276+
overflow-y: auto;
277+
}
278+
</style>

0 commit comments

Comments
 (0)