ckeditor5-phoenix 1.15.7 → 1.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/hooks/editor/editor.d.ts.map +1 -1
- package/dist/hooks/editor/plugins/index.d.ts +1 -0
- package/dist/hooks/editor/plugins/index.d.ts.map +1 -1
- package/dist/hooks/editor/plugins/phoenix-upload-adapter.d.ts +20 -0
- package/dist/hooks/editor/plugins/phoenix-upload-adapter.d.ts.map +1 -0
- package/dist/index.cjs +2 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +231 -148
- package/dist/index.mjs.map +1 -1
- package/dist/shared/get-csrf-token.d.ts +7 -0
- package/dist/shared/get-csrf-token.d.ts.map +1 -0
- package/dist/shared/index.d.ts +1 -0
- package/dist/shared/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/hooks/editor/editor.test.ts +307 -0
- package/src/hooks/editor/editor.ts +16 -13
- package/src/hooks/editor/plugins/index.ts +1 -0
- package/src/hooks/editor/plugins/phoenix-upload-adapter.ts +155 -0
- package/src/index.ts +1 -0
- package/src/shared/get-csrf-token.test.ts +54 -0
- package/src/shared/get-csrf-token.ts +18 -0
- package/src/shared/index.ts +1 -0
- package/test-utils/editor/create-editor-preset.ts +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"get-csrf-token.d.ts","sourceRoot":"","sources":["../../src/shared/get-csrf-token.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,wBAAgB,YAAY,IAAI,MAAM,GAAG,IAAI,CAY5C"}
|
package/dist/shared/index.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export * from './camel-case';
|
|
|
3
3
|
export * from './debounce';
|
|
4
4
|
export * from './deep-camel-case-keys';
|
|
5
5
|
export * from './filter-object-values';
|
|
6
|
+
export * from './get-csrf-token';
|
|
6
7
|
export * from './hook';
|
|
7
8
|
export * from './is-empty-object';
|
|
8
9
|
export * from './is-nil';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/shared/index.ts"],"names":[],"mappings":"AAAA,cAAc,kBAAkB,CAAC;AACjC,cAAc,cAAc,CAAC;AAC7B,cAAc,YAAY,CAAC;AAC3B,cAAc,wBAAwB,CAAC;AACvC,cAAc,wBAAwB,CAAC;AACvC,cAAc,QAAQ,CAAC;AACvB,cAAc,mBAAmB,CAAC;AAClC,cAAc,UAAU,CAAC;AACzB,cAAc,mBAAmB,CAAC;AAClC,cAAc,qBAAqB,CAAC;AACpC,cAAc,QAAQ,CAAC;AACvB,cAAc,yBAAyB,CAAC;AACxC,cAAc,WAAW,CAAC;AAC1B,cAAc,OAAO,CAAC;AACtB,cAAc,YAAY,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/shared/index.ts"],"names":[],"mappings":"AAAA,cAAc,kBAAkB,CAAC;AACjC,cAAc,cAAc,CAAC;AAC7B,cAAc,YAAY,CAAC;AAC3B,cAAc,wBAAwB,CAAC;AACvC,cAAc,wBAAwB,CAAC;AACvC,cAAc,kBAAkB,CAAC;AACjC,cAAc,QAAQ,CAAC;AACvB,cAAc,mBAAmB,CAAC;AAClC,cAAc,UAAU,CAAC;AACzB,cAAc,mBAAmB,CAAC;AAClC,cAAc,qBAAqB,CAAC;AACpC,cAAc,QAAQ,CAAC;AACvB,cAAc,yBAAyB,CAAC;AACxC,cAAc,WAAW,CAAC;AAC1B,cAAc,OAAO,CAAC;AACtB,cAAc,YAAY,CAAC"}
|
package/package.json
CHANGED
|
@@ -907,4 +907,311 @@ describe('editor hook', () => {
|
|
|
907
907
|
});
|
|
908
908
|
});
|
|
909
909
|
});
|
|
910
|
+
|
|
911
|
+
describe('phoenix upload adapter', () => {
|
|
912
|
+
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
|
913
|
+
|
|
914
|
+
beforeEach(() => {
|
|
915
|
+
fetchSpy = vi.spyOn(globalThis, 'fetch') as any;
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
afterEach(() => {
|
|
919
|
+
fetchSpy.mockRestore();
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
it('should initialize PhoenixUploadAdapter when phoenixUpload.url is configured', async () => {
|
|
923
|
+
const uploadUrl = 'https://example.com/upload';
|
|
924
|
+
const hookElement = createEditorHtmlElement({
|
|
925
|
+
preset: createEditorPreset('classic', {
|
|
926
|
+
phoenixUpload: { url: uploadUrl },
|
|
927
|
+
}),
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
document.body.appendChild(hookElement);
|
|
931
|
+
EditorHook.mounted.call({ el: hookElement });
|
|
932
|
+
|
|
933
|
+
const editor = await waitForTestEditor();
|
|
934
|
+
|
|
935
|
+
expect(editor.plugins.has('PhoenixUploadAdapter')).toBe(true);
|
|
936
|
+
expect(editor.config.get('phoenixUpload.url')).toBe(uploadUrl);
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
it('should not initialize PhoenixUploadAdapter when phoenixUpload.url is not configured', async () => {
|
|
940
|
+
const hookElement = createEditorHtmlElement({
|
|
941
|
+
preset: createEditorPreset('classic', {}),
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
document.body.appendChild(hookElement);
|
|
945
|
+
EditorHook.mounted.call({ el: hookElement });
|
|
946
|
+
|
|
947
|
+
const editor = await waitForTestEditor();
|
|
948
|
+
|
|
949
|
+
// Plugin should be loaded but adapter not registered
|
|
950
|
+
expect(editor.plugins.has('PhoenixUploadAdapter')).toBe(true);
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
it('should upload file successfully', async () => {
|
|
954
|
+
const uploadUrl = 'https://example.com/upload';
|
|
955
|
+
const mockResponse = { url: 'https://example.com/uploads/image.jpg' };
|
|
956
|
+
|
|
957
|
+
fetchSpy.mockResolvedValueOnce(
|
|
958
|
+
new Response(JSON.stringify(mockResponse), {
|
|
959
|
+
status: 200,
|
|
960
|
+
headers: { 'Content-Type': 'application/json' },
|
|
961
|
+
}),
|
|
962
|
+
);
|
|
963
|
+
|
|
964
|
+
const hookElement = createEditorHtmlElement({
|
|
965
|
+
preset: createEditorPreset('classic', {
|
|
966
|
+
phoenixUpload: { url: uploadUrl },
|
|
967
|
+
}),
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
document.body.appendChild(hookElement);
|
|
971
|
+
EditorHook.mounted.call({ el: hookElement });
|
|
972
|
+
|
|
973
|
+
const editor = await waitForTestEditor();
|
|
974
|
+
const fileRepository = editor.plugins.get('FileRepository');
|
|
975
|
+
|
|
976
|
+
// Create a mock file
|
|
977
|
+
const file = new File(['test content'], 'test.jpg', { type: 'image/jpeg' });
|
|
978
|
+
const loader = fileRepository.createLoader(file);
|
|
979
|
+
|
|
980
|
+
if (!loader) {
|
|
981
|
+
throw new Error('Loader was not created');
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
const result = await loader.upload();
|
|
985
|
+
|
|
986
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
987
|
+
|
|
988
|
+
const [calledUrl, calledOptions] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
989
|
+
|
|
990
|
+
expect(calledUrl).toBe(uploadUrl);
|
|
991
|
+
expect(calledOptions.method).toBe('POST');
|
|
992
|
+
expect(calledOptions.body).toBeInstanceOf(FormData);
|
|
993
|
+
|
|
994
|
+
expect(result).toEqual({ default: mockResponse.url });
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
it('should include CSRF token in upload request if available', async () => {
|
|
998
|
+
const uploadUrl = 'https://example.com/upload';
|
|
999
|
+
const mockResponse = { url: 'https://example.com/uploads/image.jpg' };
|
|
1000
|
+
const csrfToken = 'test-csrf-token';
|
|
1001
|
+
|
|
1002
|
+
// Mock CSRF token
|
|
1003
|
+
const metaElement = document.createElement('meta');
|
|
1004
|
+
metaElement.name = 'csrf-token';
|
|
1005
|
+
metaElement.content = csrfToken;
|
|
1006
|
+
document.head.appendChild(metaElement);
|
|
1007
|
+
|
|
1008
|
+
fetchSpy.mockResolvedValueOnce(
|
|
1009
|
+
new Response(JSON.stringify(mockResponse), {
|
|
1010
|
+
status: 200,
|
|
1011
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1012
|
+
}),
|
|
1013
|
+
);
|
|
1014
|
+
|
|
1015
|
+
const hookElement = createEditorHtmlElement({
|
|
1016
|
+
preset: createEditorPreset('classic', {
|
|
1017
|
+
phoenixUpload: { url: uploadUrl },
|
|
1018
|
+
}),
|
|
1019
|
+
});
|
|
1020
|
+
|
|
1021
|
+
document.body.appendChild(hookElement);
|
|
1022
|
+
EditorHook.mounted.call({ el: hookElement });
|
|
1023
|
+
|
|
1024
|
+
const editor = await waitForTestEditor();
|
|
1025
|
+
const fileRepository = editor.plugins.get('FileRepository');
|
|
1026
|
+
|
|
1027
|
+
const file = new File(['test content'], 'test.jpg', { type: 'image/jpeg' });
|
|
1028
|
+
const loader = fileRepository.createLoader(file);
|
|
1029
|
+
|
|
1030
|
+
await loader!.upload();
|
|
1031
|
+
|
|
1032
|
+
const [_url, options] = fetchSpy.mock.calls[0] as [string, any];
|
|
1033
|
+
|
|
1034
|
+
expect(options.headers['X-CSRF-Token']).toBe(csrfToken);
|
|
1035
|
+
|
|
1036
|
+
// Clean up
|
|
1037
|
+
document.head.removeChild(metaElement);
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
it('should handle upload errors', async () => {
|
|
1041
|
+
const uploadUrl = 'https://example.com/upload';
|
|
1042
|
+
const errorMessage = 'File too large';
|
|
1043
|
+
|
|
1044
|
+
fetchSpy.mockResolvedValueOnce(
|
|
1045
|
+
new Response(JSON.stringify({ error: { message: errorMessage } }), {
|
|
1046
|
+
status: 400,
|
|
1047
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1048
|
+
}),
|
|
1049
|
+
);
|
|
1050
|
+
|
|
1051
|
+
const hookElement = createEditorHtmlElement({
|
|
1052
|
+
preset: createEditorPreset('classic', {
|
|
1053
|
+
phoenixUpload: { url: uploadUrl },
|
|
1054
|
+
}),
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
document.body.appendChild(hookElement);
|
|
1058
|
+
EditorHook.mounted.call({ el: hookElement });
|
|
1059
|
+
|
|
1060
|
+
const editor = await waitForTestEditor();
|
|
1061
|
+
const fileRepository = editor.plugins.get('FileRepository');
|
|
1062
|
+
|
|
1063
|
+
const file = new File(['test content'], 'test.jpg', { type: 'image/jpeg' });
|
|
1064
|
+
const loader = fileRepository.createLoader(file);
|
|
1065
|
+
|
|
1066
|
+
await expect(loader!.upload()).rejects.toThrow(errorMessage);
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1069
|
+
it('should handle generic upload errors when error message is not provided', async () => {
|
|
1070
|
+
const uploadUrl = 'https://example.com/upload';
|
|
1071
|
+
|
|
1072
|
+
fetchSpy.mockResolvedValueOnce(
|
|
1073
|
+
new Response('Server error', {
|
|
1074
|
+
status: 500,
|
|
1075
|
+
}),
|
|
1076
|
+
);
|
|
1077
|
+
|
|
1078
|
+
const hookElement = createEditorHtmlElement({
|
|
1079
|
+
preset: createEditorPreset('classic', {
|
|
1080
|
+
phoenixUpload: { url: uploadUrl },
|
|
1081
|
+
}),
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
document.body.appendChild(hookElement);
|
|
1085
|
+
EditorHook.mounted.call({ el: hookElement });
|
|
1086
|
+
|
|
1087
|
+
const editor = await waitForTestEditor();
|
|
1088
|
+
const fileRepository = editor.plugins.get('FileRepository');
|
|
1089
|
+
|
|
1090
|
+
const file = new File(['test content'], 'test.jpg', { type: 'image/jpeg' });
|
|
1091
|
+
const loader = fileRepository.createLoader(file);
|
|
1092
|
+
|
|
1093
|
+
await expect(loader!.upload()).rejects.toThrow('Couldn\'t upload file!');
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
it('should track upload progress', async () => {
|
|
1097
|
+
const uploadUrl = 'https://example.com/upload';
|
|
1098
|
+
const mockResponse = { url: 'https://example.com/uploads/image.jpg' };
|
|
1099
|
+
|
|
1100
|
+
fetchSpy.mockResolvedValueOnce(
|
|
1101
|
+
new Response(JSON.stringify(mockResponse), {
|
|
1102
|
+
status: 200,
|
|
1103
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1104
|
+
}),
|
|
1105
|
+
);
|
|
1106
|
+
|
|
1107
|
+
const hookElement = createEditorHtmlElement({
|
|
1108
|
+
preset: createEditorPreset('classic', {
|
|
1109
|
+
phoenixUpload: { url: uploadUrl },
|
|
1110
|
+
}),
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
document.body.appendChild(hookElement);
|
|
1114
|
+
EditorHook.mounted.call({ el: hookElement });
|
|
1115
|
+
|
|
1116
|
+
const editor = await waitForTestEditor();
|
|
1117
|
+
const fileRepository = editor.plugins.get('FileRepository');
|
|
1118
|
+
|
|
1119
|
+
const fileContent = 'a'.repeat(1024); // 1KB file
|
|
1120
|
+
const file = new File([fileContent], 'test.jpg', { type: 'image/jpeg' });
|
|
1121
|
+
const loader = fileRepository.createLoader(file);
|
|
1122
|
+
|
|
1123
|
+
// Before upload
|
|
1124
|
+
expect(loader!.uploaded).toBe(0);
|
|
1125
|
+
|
|
1126
|
+
await loader!.upload();
|
|
1127
|
+
|
|
1128
|
+
// After upload
|
|
1129
|
+
expect(loader!.uploadTotal).toBe(file.size);
|
|
1130
|
+
expect(loader!.uploaded).toBe(file.size);
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
it('should not initialize adapter if SimpleUploadAdapter plugin is present', async () => {
|
|
1134
|
+
const uploadUrl = 'https://example.com/upload';
|
|
1135
|
+
const hookElement = createEditorHtmlElement({
|
|
1136
|
+
preset: createEditorPreset('classic', {
|
|
1137
|
+
plugins: ['Image', 'ImageUpload', 'SimpleUploadAdapter'],
|
|
1138
|
+
toolbar: [],
|
|
1139
|
+
phoenixUpload: { url: uploadUrl },
|
|
1140
|
+
}),
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
document.body.appendChild(hookElement);
|
|
1144
|
+
EditorHook.mounted.call({ el: hookElement });
|
|
1145
|
+
|
|
1146
|
+
const editor = await waitForTestEditor();
|
|
1147
|
+
const fileRepository = editor.plugins.get('FileRepository');
|
|
1148
|
+
const file = new File(['test'], 'test.jpg', { type: 'image/jpeg' });
|
|
1149
|
+
const loader = fileRepository.createLoader(file);
|
|
1150
|
+
|
|
1151
|
+
try {
|
|
1152
|
+
await loader!.upload();
|
|
1153
|
+
}
|
|
1154
|
+
catch {
|
|
1155
|
+
// Expected to fail or do nothing if no adapter is present
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
expect(fetchSpy).not.toHaveBeenCalledWith(uploadUrl, expect.anything());
|
|
1159
|
+
});
|
|
1160
|
+
|
|
1161
|
+
it('should not initialize adapter if Base64UploadAdapter plugin is present', async () => {
|
|
1162
|
+
const uploadUrl = 'https://example.com/upload';
|
|
1163
|
+
const hookElement = createEditorHtmlElement({
|
|
1164
|
+
preset: createEditorPreset('classic', {
|
|
1165
|
+
plugins: ['Image', 'ImageUpload', 'Base64UploadAdapter'],
|
|
1166
|
+
toolbar: [],
|
|
1167
|
+
phoenixUpload: { url: uploadUrl },
|
|
1168
|
+
}),
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
document.body.appendChild(hookElement);
|
|
1172
|
+
EditorHook.mounted.call({ el: hookElement });
|
|
1173
|
+
|
|
1174
|
+
const editor = await waitForTestEditor();
|
|
1175
|
+
const fileRepository = editor.plugins.get('FileRepository');
|
|
1176
|
+
const file = new File(['test'], 'test.jpg', { type: 'image/jpeg' });
|
|
1177
|
+
const loader = fileRepository.createLoader(file);
|
|
1178
|
+
|
|
1179
|
+
try {
|
|
1180
|
+
await loader!.upload();
|
|
1181
|
+
}
|
|
1182
|
+
catch {
|
|
1183
|
+
// Expected to fail or do nothing if no adapter is present
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
expect(fetchSpy).not.toHaveBeenCalledWith(uploadUrl, expect.anything());
|
|
1187
|
+
});
|
|
1188
|
+
|
|
1189
|
+
it('should not initialize adapter if CKFinderUploadAdapter plugin is present', async () => {
|
|
1190
|
+
const uploadUrl = 'https://example.com/upload';
|
|
1191
|
+
const hookElement = createEditorHtmlElement({
|
|
1192
|
+
preset: createEditorPreset('classic', {
|
|
1193
|
+
plugins: ['Image', 'ImageUpload', 'Link', 'CKFinderUploadAdapter'],
|
|
1194
|
+
toolbar: [],
|
|
1195
|
+
phoenixUpload: { url: uploadUrl },
|
|
1196
|
+
}),
|
|
1197
|
+
});
|
|
1198
|
+
|
|
1199
|
+
document.body.appendChild(hookElement);
|
|
1200
|
+
EditorHook.mounted.call({ el: hookElement });
|
|
1201
|
+
|
|
1202
|
+
const editor = await waitForTestEditor();
|
|
1203
|
+
const fileRepository = editor.plugins.get('FileRepository');
|
|
1204
|
+
const file = new File(['test'], 'test.jpg', { type: 'image/jpeg' });
|
|
1205
|
+
const loader = fileRepository.createLoader(file);
|
|
1206
|
+
|
|
1207
|
+
try {
|
|
1208
|
+
await loader!.upload();
|
|
1209
|
+
}
|
|
1210
|
+
catch {
|
|
1211
|
+
// Expected to fail or do nothing if no adapter is present
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
expect(fetchSpy).not.toHaveBeenCalledWith(uploadUrl, expect.anything());
|
|
1215
|
+
});
|
|
1216
|
+
});
|
|
910
1217
|
});
|
|
@@ -3,15 +3,15 @@ import type { Editor } from 'ckeditor5';
|
|
|
3
3
|
import type { EditorId } from './typings';
|
|
4
4
|
import type { EditorCreator } from './utils';
|
|
5
5
|
|
|
6
|
-
import {
|
|
7
|
-
isEmptyObject,
|
|
8
|
-
parseIntIfNotNull,
|
|
9
|
-
waitFor,
|
|
10
|
-
} from '../../shared';
|
|
6
|
+
import { isEmptyObject, parseIntIfNotNull, waitFor } from '../../shared';
|
|
11
7
|
import { ClassHook, makeHook } from '../../shared/hook';
|
|
12
8
|
import { ContextsRegistry, getNearestContextParentPromise } from '../context';
|
|
13
9
|
import { EditorsRegistry } from './editors-registry';
|
|
14
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
createPhoenixUploadAdapterPlugin,
|
|
12
|
+
createSyncEditorWithInputPlugin,
|
|
13
|
+
createSyncEditorWithPhoenixPlugin,
|
|
14
|
+
} from './plugins';
|
|
15
15
|
import {
|
|
16
16
|
createEditorInContext,
|
|
17
17
|
isSingleRootEditor,
|
|
@@ -189,13 +189,16 @@ class EditorHookImpl extends ClassHook {
|
|
|
189
189
|
}
|
|
190
190
|
|
|
191
191
|
loadedPlugins.push(
|
|
192
|
-
await
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
192
|
+
...await Promise.all([
|
|
193
|
+
createSyncEditorWithPhoenixPlugin({
|
|
194
|
+
editorId,
|
|
195
|
+
saveDebounceMs,
|
|
196
|
+
events,
|
|
197
|
+
pushEvent: this.pushEvent.bind(this),
|
|
198
|
+
handleEvent: this.handleEvent.bind(this),
|
|
199
|
+
}),
|
|
200
|
+
createPhoenixUploadAdapterPlugin(),
|
|
201
|
+
]),
|
|
199
202
|
);
|
|
200
203
|
|
|
201
204
|
// Mix custom translations with loaded translations.
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type { FileLoader, PluginConstructor, UploadAdapter } from 'ckeditor5';
|
|
2
|
+
|
|
3
|
+
import { getCsrfToken } from '../../../shared';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Creates a PhoenixUploadAdapter plugin class for CKEditor 5.
|
|
7
|
+
* This adapter handles image uploads to a Phoenix backend endpoint.
|
|
8
|
+
*/
|
|
9
|
+
export async function createPhoenixUploadAdapterPlugin(): Promise<PluginConstructor> {
|
|
10
|
+
const { Plugin, FileRepository } = await import('ckeditor5');
|
|
11
|
+
|
|
12
|
+
return class PhoenixUploadAdapter extends Plugin {
|
|
13
|
+
/**
|
|
14
|
+
* The name of the plugin.
|
|
15
|
+
*/
|
|
16
|
+
static get pluginName() {
|
|
17
|
+
return 'PhoenixUploadAdapter' as const;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
static get requires() {
|
|
21
|
+
return [FileRepository];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Initializes the plugin.
|
|
26
|
+
*/
|
|
27
|
+
public init(): void {
|
|
28
|
+
const { editor } = this;
|
|
29
|
+
const { plugins, config } = editor;
|
|
30
|
+
const uploadUrl = config.get('phoenixUpload.url');
|
|
31
|
+
|
|
32
|
+
if (!uploadUrl) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Check if we should enable this adapter
|
|
37
|
+
if (
|
|
38
|
+
plugins.has('SimpleUploadAdapter')
|
|
39
|
+
|| plugins.has('Base64UploadAdapter')
|
|
40
|
+
|| plugins.has('CKFinderUploadAdapter')
|
|
41
|
+
) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Register the upload adapter
|
|
46
|
+
const fileRepository = plugins.get(FileRepository);
|
|
47
|
+
|
|
48
|
+
fileRepository.createUploadAdapter = (loader: FileLoader) => new Adapter(loader, uploadUrl);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
declare module '@ckeditor/ckeditor5-core' {
|
|
54
|
+
// eslint-disable-next-line ts/consistent-type-definitions
|
|
55
|
+
interface EditorConfig {
|
|
56
|
+
/**
|
|
57
|
+
* Configuration for Phoenix upload adapter.
|
|
58
|
+
*/
|
|
59
|
+
phoenixUpload?: {
|
|
60
|
+
/**
|
|
61
|
+
* The URL to which files will be uploaded.
|
|
62
|
+
*/
|
|
63
|
+
url: string;
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Upload adapter that handles communication with Phoenix backend.
|
|
70
|
+
*/
|
|
71
|
+
class Adapter implements UploadAdapter {
|
|
72
|
+
private readonly loader: FileLoader;
|
|
73
|
+
private readonly uploadUrl: string;
|
|
74
|
+
private abortController: AbortController | null = null;
|
|
75
|
+
|
|
76
|
+
constructor(loader: FileLoader, uploadUrl: string) {
|
|
77
|
+
this.loader = loader;
|
|
78
|
+
this.uploadUrl = uploadUrl;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Starts the upload process.
|
|
83
|
+
*/
|
|
84
|
+
public async upload(): Promise<{ default: string; }> {
|
|
85
|
+
const file = (await this.loader.file)!;
|
|
86
|
+
|
|
87
|
+
this.abortController = new AbortController();
|
|
88
|
+
|
|
89
|
+
const data = new FormData();
|
|
90
|
+
|
|
91
|
+
data.append('file', file);
|
|
92
|
+
|
|
93
|
+
// Attempt to track progress if the file size is known, though fetch doesn't support
|
|
94
|
+
// upload progress events natively.
|
|
95
|
+
if (file.size) {
|
|
96
|
+
this.loader.uploadTotal = file.size;
|
|
97
|
+
this.loader.uploaded = 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const headers: HeadersInit = {};
|
|
101
|
+
const csrfToken = getCsrfToken();
|
|
102
|
+
|
|
103
|
+
if (csrfToken) {
|
|
104
|
+
headers['X-CSRF-Token'] = csrfToken;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const response = await fetch(this.uploadUrl, {
|
|
109
|
+
method: 'POST',
|
|
110
|
+
headers,
|
|
111
|
+
body: data,
|
|
112
|
+
signal: this.abortController.signal,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
if (!response.ok) {
|
|
116
|
+
let errorMessage = 'Couldn\'t upload file!';
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const errorData = await response.json();
|
|
120
|
+
if (errorData?.error?.message) {
|
|
121
|
+
errorMessage = errorData.error.message;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
catch { /* ignore */ }
|
|
125
|
+
|
|
126
|
+
throw new Error(errorMessage);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
this.loader.uploaded = this.loader.uploadTotal!;
|
|
130
|
+
|
|
131
|
+
const result = await response.json();
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
default: result.url,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
/* v8 ignore next 7 */
|
|
138
|
+
catch (error: any) {
|
|
139
|
+
if (error.name === 'AbortError') {
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
throw error.message || 'Couldn\'t upload file!';
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Aborts the upload process.
|
|
149
|
+
*/
|
|
150
|
+
/* v8 ignore next 4 */
|
|
151
|
+
public abort(): void {
|
|
152
|
+
this.abortController?.abort();
|
|
153
|
+
this.abortController = null;
|
|
154
|
+
}
|
|
155
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { Hooks } from './hooks';
|
|
2
|
+
export { ContextsRegistry } from './hooks/context/contexts-registry';
|
|
2
3
|
export { CustomEditorPluginsRegistry } from './hooks/editor/custom-editor-plugins';
|
|
3
4
|
export { EditorsRegistry } from './hooks/editor/editors-registry';
|
|
4
5
|
export { unwrapEditorContext } from './hooks/editor/utils/create-editor-in-context';
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { getCsrfToken } from './get-csrf-token';
|
|
4
|
+
|
|
5
|
+
describe('getCsrfToken', () => {
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
document.head.innerHTML = '';
|
|
8
|
+
|
|
9
|
+
// Clear cookies by expiring them
|
|
10
|
+
const cookies = document.cookie.split(';');
|
|
11
|
+
|
|
12
|
+
for (let i = 0; i < cookies.length; i++) {
|
|
13
|
+
const cookie = cookies[i]!;
|
|
14
|
+
const eqPos = cookie.indexOf('=');
|
|
15
|
+
const name = eqPos > -1 ? cookie.substring(0, eqPos) : cookie;
|
|
16
|
+
|
|
17
|
+
document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT`;
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('should return null if no token is found', () => {
|
|
22
|
+
expect(getCsrfToken()).toBeNull();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should retrieve token from meta tag', () => {
|
|
26
|
+
const meta = document.createElement('meta');
|
|
27
|
+
meta.name = 'csrf-token';
|
|
28
|
+
meta.content = 'meta-token-123';
|
|
29
|
+
document.head.appendChild(meta);
|
|
30
|
+
|
|
31
|
+
expect(getCsrfToken()).toBe('meta-token-123');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should retrieve token from cookie', () => {
|
|
35
|
+
document.cookie = '_csrf_token=cookie-token-456; other=value';
|
|
36
|
+
expect(getCsrfToken()).toBe('cookie-token-456');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should prioritize meta tag over cookie', () => {
|
|
40
|
+
const meta = document.createElement('meta');
|
|
41
|
+
meta.name = 'csrf-token';
|
|
42
|
+
meta.content = 'meta-token-789';
|
|
43
|
+
document.head.appendChild(meta);
|
|
44
|
+
|
|
45
|
+
document.cookie = '_csrf_token=cookie-token-000';
|
|
46
|
+
|
|
47
|
+
expect(getCsrfToken()).toBe('meta-token-789');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should handle URL encoded cookie values', () => {
|
|
51
|
+
document.cookie = '_csrf_token=token%20with%20spaces';
|
|
52
|
+
expect(getCsrfToken()).toBe('token with spaces');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retrieves the CSRF token from the meta tag or cookie.
|
|
3
|
+
*
|
|
4
|
+
* @returns The CSRF token or null if not found.
|
|
5
|
+
*/
|
|
6
|
+
export function getCsrfToken(): string | null {
|
|
7
|
+
// Try to get from meta tag (Phoenix default).
|
|
8
|
+
const metaTag = document.querySelector('meta[name="csrf-token"]');
|
|
9
|
+
|
|
10
|
+
if (metaTag) {
|
|
11
|
+
return metaTag.getAttribute('content');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Try to get from cookie.
|
|
15
|
+
const match = document.cookie.match(/(?:^|; )_csrf_token=([^;]*)/);
|
|
16
|
+
|
|
17
|
+
return match ? decodeURIComponent(match[1]!) : null;
|
|
18
|
+
}
|
package/src/shared/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ export * from './camel-case';
|
|
|
3
3
|
export * from './debounce';
|
|
4
4
|
export * from './deep-camel-case-keys';
|
|
5
5
|
export * from './filter-object-values';
|
|
6
|
+
export * from './get-csrf-token';
|
|
6
7
|
export * from './hook';
|
|
7
8
|
export * from './is-empty-object';
|
|
8
9
|
export * from './is-nil';
|
|
@@ -9,7 +9,7 @@ export function createEditorPreset(
|
|
|
9
9
|
customTranslations?: object,
|
|
10
10
|
) {
|
|
11
11
|
const defaultConfig: EditorConfig = {
|
|
12
|
-
plugins: ['Essentials', 'Paragraph', 'Bold', 'Italic', 'Undo'],
|
|
12
|
+
plugins: ['Essentials', 'Paragraph', 'Bold', 'Italic', 'Undo', 'Image', 'ImageUpload'],
|
|
13
13
|
toolbar: ['undo', 'redo', '|', 'bold', 'italic'],
|
|
14
14
|
};
|
|
15
15
|
|