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.
@@ -0,0 +1,7 @@
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 declare function getCsrfToken(): string | null;
7
+ //# sourceMappingURL=get-csrf-token.d.ts.map
@@ -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"}
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ckeditor5-phoenix",
3
- "version": "1.15.7",
3
+ "version": "1.16.0",
4
4
  "description": "CKEditor 5 integration for Phoenix Framework",
5
5
  "exports": {
6
6
  ".": {
@@ -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 { createSyncEditorWithInputPlugin, createSyncEditorWithPhoenixPlugin } from './plugins';
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 createSyncEditorWithPhoenixPlugin({
193
- editorId,
194
- saveDebounceMs,
195
- events,
196
- pushEvent: this.pushEvent.bind(this),
197
- handleEvent: this.handleEvent.bind(this),
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.
@@ -1,2 +1,3 @@
1
+ export * from './phoenix-upload-adapter';
1
2
  export * from './sync-editor-with-input';
2
3
  export * from './sync-editor-with-phoenix';
@@ -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
+ }
@@ -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