ep_media_upload 0.2.3 → 0.2.5

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.
@@ -1,284 +1,284 @@
1
- 'use strict';
2
-
3
- /**
4
- * ep_media_upload - Client-side hooks
5
- *
6
- * Handles file selection, S3 upload via presigned URL, and hyperlink insertion
7
- * compatible with ep_hyperlinked_text.
8
- */
9
-
10
- console.log('[ep_media_upload] Client hooks loaded');
11
-
12
- // Store ace context for hyperlink insertion
13
- let _aceContext = null;
14
-
15
- /**
16
- * Modal management functions
17
- */
18
- const showModal = (state = 'progress') => {
19
- const modal = $('#mediaUploadModal');
20
- const progressEl = $('#mediaUploadProgress');
21
- const errorEl = $('#mediaUploadError');
22
-
23
- // Hide all states
24
- progressEl.hide();
25
- errorEl.hide();
26
-
27
- // Show requested state
28
- if (state === 'progress') {
29
- progressEl.show();
30
- } else if (state === 'error') {
31
- errorEl.show();
32
- }
33
-
34
- modal.addClass('visible');
35
- };
36
-
37
- const hideModal = () => {
38
- $('#mediaUploadModal').removeClass('visible');
39
- };
40
-
41
- const showError = (message) => {
42
- const errorText = message || 'Upload failed.';
43
- $('.ep-media-upload-error-text').text(errorText);
44
- showModal('error');
45
- };
46
-
47
- /**
48
- * Validate file against configured restrictions
49
- */
50
- const validateFile = (file) => {
51
- const config = clientVars.ep_media_upload || {};
52
-
53
- // Check file type
54
- if (config.fileTypes && Array.isArray(config.fileTypes)) {
55
- const nameParts = file.name.split('.');
56
- if (nameParts.length < 2) {
57
- const errorMsg = html10n.get('ep_media_upload.error.fileType') || 'File type not allowed.';
58
- return { valid: false, error: `${errorMsg} File must have an extension.` };
59
- }
60
- const ext = nameParts.pop().toLowerCase();
61
- if (!config.fileTypes.includes(ext)) {
62
- const allowedTypes = config.fileTypes.join(', ');
63
- const errorMsg = html10n.get('ep_media_upload.error.fileType') || 'File type not allowed.';
64
- return { valid: false, error: `${errorMsg} Allowed: ${allowedTypes}` };
65
- }
66
- }
67
-
68
- // Check file size
69
- if (config.maxFileSize && file.size > config.maxFileSize) {
70
- const maxMB = (config.maxFileSize / (1024 * 1024)).toFixed(1);
71
- const errorMsg = html10n.get('ep_media_upload.error.fileSize', { maxallowed: maxMB })
72
- || `File is too large. Maximum size is ${maxMB} MB.`;
73
- return { valid: false, error: errorMsg };
74
- }
75
-
76
- return { valid: true };
77
- };
78
-
79
- /**
80
- * Upload file to S3 using presigned URL
81
- * Returns the secure download URL (relative path to our authenticated endpoint)
82
- */
83
- const uploadToS3 = async (file) => {
84
- // Step 1: Get presigned URL from server
85
- const queryParams = $.param({ name: file.name, type: file.type });
86
- const presignResponse = await $.getJSON(
87
- `${encodeURIComponent(clientVars.padId)}/pluginfw/ep_media_upload/s3_presign?${queryParams}`
88
- );
89
-
90
- if (!presignResponse || !presignResponse.signedUrl || !presignResponse.downloadUrl) {
91
- throw new Error('Invalid presign response from server');
92
- }
93
-
94
- // Step 2: Upload directly to S3
95
- // Must include Content-Disposition header as it's part of the presigned URL signature
96
- const headers = { 'Content-Type': file.type };
97
- if (presignResponse.contentDisposition) {
98
- headers['Content-Disposition'] = presignResponse.contentDisposition;
99
- }
100
-
101
- const uploadResponse = await fetch(presignResponse.signedUrl, {
102
- method: 'PUT',
103
- headers,
104
- body: file,
105
- });
106
-
107
- if (!uploadResponse.ok) {
108
- throw new Error(`S3 upload failed with status ${uploadResponse.status}`);
109
- }
110
-
111
- // Return the secure download URL (authenticated endpoint, not direct S3)
112
- return presignResponse.downloadUrl;
113
- };
114
-
115
- /**
116
- * Insert hyperlink at cursor position
117
- * Compatible with ep_hyperlinked_text format
118
- */
119
- const doInsertMediaLink = function(url, linkText) {
120
- const editorInfo = this.editorInfo;
121
- const docMan = this.documentAttributeManager;
122
- const rep = editorInfo.ace_getRep();
123
-
124
- if (!editorInfo || !rep || !rep.selStart || !docMan || !url || !linkText) {
125
- console.error('[ep_media_upload] Missing context for hyperlink insertion');
126
- return;
127
- }
128
-
129
- const cursorPos = rep.selStart;
130
- const ZWSP = '\u200B'; // Zero-Width Space for boundary
131
-
132
- // Insert: ZWSP + linkText + ZWSP (same pattern as ep_hyperlinked_text)
133
- const textToInsert = ZWSP + linkText + ZWSP;
134
- editorInfo.ace_replaceRange(cursorPos, cursorPos, textToInsert);
135
-
136
- // Apply hyperlink attribute to the linkText portion (excluding ZWSPs)
137
- const linkStart = [cursorPos[0], cursorPos[1] + ZWSP.length];
138
- const linkEnd = [cursorPos[0], cursorPos[1] + ZWSP.length + linkText.length];
139
-
140
- docMan.setAttributesOnRange(linkStart, linkEnd, [['hyperlink', url]]);
141
-
142
- // Move cursor after the inserted content
143
- const finalPos = [cursorPos[0], cursorPos[1] + textToInsert.length];
144
- editorInfo.ace_performSelectionChange(finalPos, finalPos, false);
145
-
146
- console.log('[ep_media_upload] Inserted hyperlink:', linkText, '->', url);
147
- };
148
-
149
- /**
150
- * Handle file selection and upload
151
- */
152
- const handleFileUpload = async (file, aceContext) => {
153
- // Validate file
154
- const validation = validateFile(file);
155
- if (!validation.valid) {
156
- showError(validation.error);
157
- return;
158
- }
159
-
160
- // Show progress modal
161
- showModal('progress');
162
-
163
- try {
164
- // Upload to S3 and get secure download URL
165
- const downloadUrl = await uploadToS3(file);
166
-
167
- // Insert hyperlink into document (uses authenticated download endpoint)
168
- aceContext.callWithAce((ace) => {
169
- ace.ace_doInsertMediaLink(downloadUrl, file.name);
170
- }, 'insertMediaLink', true);
171
-
172
- // Hide modal on success (no success message needed)
173
- hideModal();
174
-
175
- } catch (err) {
176
- console.error('[ep_media_upload] Upload failed:', err);
177
- // Extract error message from various error formats
178
- let errorMsg = 'Upload failed.';
179
- if (err.responseJSON && err.responseJSON.error) {
180
- // jQuery AJAX error with JSON response
181
- errorMsg = err.responseJSON.error;
182
- } else if (err.message) {
183
- // Standard Error object
184
- errorMsg = err.message;
185
- } else if (typeof err === 'string') {
186
- errorMsg = err;
187
- }
188
- showError(errorMsg);
189
- }
190
- };
191
-
192
- /**
193
- * aceInitialized hook
194
- * Bind the hyperlink insertion function to ace context
195
- */
196
- exports.aceInitialized = (hook, context) => {
197
- context.editorInfo.ace_doInsertMediaLink = doInsertMediaLink.bind(context);
198
- };
199
-
200
- /**
201
- * postAceInit hook
202
- * Set up modal close button handler
203
- */
204
- exports.postAceInit = (hook, context) => {
205
- _aceContext = context.ace;
206
-
207
- // Close button handler for error modal
208
- $(document).on('click', '#mediaUploadErrorClose', () => {
209
- hideModal();
210
- });
211
-
212
- // Click outside modal to close (only for error state)
213
- $(document).on('click', '#mediaUploadModal', (e) => {
214
- if (e.target.id === 'mediaUploadModal' && $('#mediaUploadError').is(':visible')) {
215
- hideModal();
216
- }
217
- });
218
- };
219
-
220
- /**
221
- * postToolbarInit hook
222
- * Register the mediaUpload toolbar command
223
- */
224
- exports.postToolbarInit = (hook, context) => {
225
- const toolbar = context.toolbar;
226
-
227
- toolbar.registerCommand('mediaUpload', () => {
228
- // Remove any existing file input (cleanup from previous attempts)
229
- $('#mediaUploadFileInput').remove();
230
-
231
- // Create hidden file input
232
- const fileInput = $('<input>')
233
- .attr({
234
- type: 'file',
235
- id: 'mediaUploadFileInput',
236
- style: 'position:absolute;left:-9999px;'
237
- });
238
-
239
- // Add accept attribute if file types are configured
240
- if (clientVars.ep_media_upload && clientVars.ep_media_upload.fileTypes) {
241
- const accept = clientVars.ep_media_upload.fileTypes
242
- .map(ext => `.${ext}`)
243
- .join(',');
244
- fileInput.attr('accept', accept);
245
- }
246
-
247
- $('body').append(fileInput);
248
-
249
- // Cleanup function to remove file input
250
- const cleanup = () => {
251
- fileInput.off(); // Remove all event handlers
252
- fileInput.remove();
253
- };
254
-
255
- // Handle file selection - use 'one' so it only fires once
256
- fileInput.one('change', (e) => {
257
- const files = e.target.files;
258
- if (!files || files.length === 0) {
259
- cleanup();
260
- return;
261
- }
262
-
263
- const file = files[0];
264
- handleFileUpload(file, context.ace);
265
- cleanup();
266
- });
267
-
268
- // Handle cancel (user closes file picker without selecting)
269
- // The blur/focus trick: when file picker closes, window regains focus
270
- $(window).one('focus', () => {
271
- // Small delay to allow change event to fire first if file was selected
272
- setTimeout(() => {
273
- if ($('#mediaUploadFileInput').length > 0) {
274
- cleanup();
275
- }
276
- }, 300);
277
- });
278
-
279
- // Trigger file picker
280
- fileInput.trigger('click');
281
- });
282
-
283
- console.log('[ep_media_upload] Toolbar command registered');
284
- };
1
+ 'use strict';
2
+
3
+ /**
4
+ * ep_media_upload - Client-side hooks
5
+ *
6
+ * Handles file selection, S3 upload via presigned URL, and hyperlink insertion
7
+ * compatible with ep_hyperlinked_text.
8
+ */
9
+
10
+ console.log('[ep_media_upload] Client hooks loaded');
11
+
12
+ // Store ace context for hyperlink insertion
13
+ let _aceContext = null;
14
+
15
+ /**
16
+ * Modal management functions
17
+ */
18
+ const showModal = (state = 'progress') => {
19
+ const modal = $('#mediaUploadModal');
20
+ const progressEl = $('#mediaUploadProgress');
21
+ const errorEl = $('#mediaUploadError');
22
+
23
+ // Hide all states
24
+ progressEl.hide();
25
+ errorEl.hide();
26
+
27
+ // Show requested state
28
+ if (state === 'progress') {
29
+ progressEl.show();
30
+ } else if (state === 'error') {
31
+ errorEl.show();
32
+ }
33
+
34
+ modal.addClass('visible');
35
+ };
36
+
37
+ const hideModal = () => {
38
+ $('#mediaUploadModal').removeClass('visible');
39
+ };
40
+
41
+ const showError = (message) => {
42
+ const errorText = message || 'Upload failed.';
43
+ $('.ep-media-upload-error-text').text(errorText);
44
+ showModal('error');
45
+ };
46
+
47
+ /**
48
+ * Validate file against configured restrictions
49
+ */
50
+ const validateFile = (file) => {
51
+ const config = clientVars.ep_media_upload || {};
52
+
53
+ // Check file type
54
+ if (config.fileTypes && Array.isArray(config.fileTypes)) {
55
+ const nameParts = file.name.split('.');
56
+ if (nameParts.length < 2) {
57
+ const errorMsg = html10n.get('ep_media_upload.error.fileType') || 'File type not allowed.';
58
+ return { valid: false, error: `${errorMsg} File must have an extension.` };
59
+ }
60
+ const ext = nameParts.pop().toLowerCase();
61
+ if (!config.fileTypes.includes(ext)) {
62
+ const allowedTypes = config.fileTypes.join(', ');
63
+ const errorMsg = html10n.get('ep_media_upload.error.fileType') || 'File type not allowed.';
64
+ return { valid: false, error: `${errorMsg} Allowed: ${allowedTypes}` };
65
+ }
66
+ }
67
+
68
+ // Check file size
69
+ if (config.maxFileSize && file.size > config.maxFileSize) {
70
+ const maxMB = (config.maxFileSize / (1024 * 1024)).toFixed(1);
71
+ const errorMsg = html10n.get('ep_media_upload.error.fileSize', { maxallowed: maxMB })
72
+ || `File is too large. Maximum size is ${maxMB} MB.`;
73
+ return { valid: false, error: errorMsg };
74
+ }
75
+
76
+ return { valid: true };
77
+ };
78
+
79
+ /**
80
+ * Upload file to S3 using presigned URL
81
+ * Returns the secure download URL (relative path to our authenticated endpoint)
82
+ */
83
+ const uploadToS3 = async (file) => {
84
+ // Step 1: Get presigned URL from server
85
+ const queryParams = $.param({ name: file.name, type: file.type });
86
+ const presignResponse = await $.getJSON(
87
+ `${encodeURIComponent(clientVars.padId)}/pluginfw/ep_media_upload/s3_presign?${queryParams}`
88
+ );
89
+
90
+ if (!presignResponse || !presignResponse.signedUrl || !presignResponse.downloadUrl) {
91
+ throw new Error('Invalid presign response from server');
92
+ }
93
+
94
+ // Step 2: Upload directly to S3
95
+ // Must include Content-Disposition header as it's part of the presigned URL signature
96
+ const headers = { 'Content-Type': file.type };
97
+ if (presignResponse.contentDisposition) {
98
+ headers['Content-Disposition'] = presignResponse.contentDisposition;
99
+ }
100
+
101
+ const uploadResponse = await fetch(presignResponse.signedUrl, {
102
+ method: 'PUT',
103
+ headers,
104
+ body: file,
105
+ });
106
+
107
+ if (!uploadResponse.ok) {
108
+ throw new Error(`S3 upload failed with status ${uploadResponse.status}`);
109
+ }
110
+
111
+ // Return the secure download URL (authenticated endpoint, not direct S3)
112
+ return presignResponse.downloadUrl;
113
+ };
114
+
115
+ /**
116
+ * Insert hyperlink at cursor position
117
+ * Compatible with ep_hyperlinked_text format
118
+ */
119
+ const doInsertMediaLink = function(url, linkText) {
120
+ const editorInfo = this.editorInfo;
121
+ const docMan = this.documentAttributeManager;
122
+ const rep = editorInfo.ace_getRep();
123
+
124
+ if (!editorInfo || !rep || !rep.selStart || !docMan || !url || !linkText) {
125
+ console.error('[ep_media_upload] Missing context for hyperlink insertion');
126
+ return;
127
+ }
128
+
129
+ const cursorPos = rep.selStart;
130
+ const ZWSP = '\u200B'; // Zero-Width Space for boundary
131
+
132
+ // Insert: ZWSP + linkText + ZWSP (same pattern as ep_hyperlinked_text)
133
+ const textToInsert = ZWSP + linkText + ZWSP;
134
+ editorInfo.ace_replaceRange(cursorPos, cursorPos, textToInsert);
135
+
136
+ // Apply hyperlink attribute to the linkText portion (excluding ZWSPs)
137
+ const linkStart = [cursorPos[0], cursorPos[1] + ZWSP.length];
138
+ const linkEnd = [cursorPos[0], cursorPos[1] + ZWSP.length + linkText.length];
139
+
140
+ docMan.setAttributesOnRange(linkStart, linkEnd, [['hyperlink', url]]);
141
+
142
+ // Move cursor after the inserted content
143
+ const finalPos = [cursorPos[0], cursorPos[1] + textToInsert.length];
144
+ editorInfo.ace_performSelectionChange(finalPos, finalPos, false);
145
+
146
+ console.log('[ep_media_upload] Inserted hyperlink:', linkText, '->', url);
147
+ };
148
+
149
+ /**
150
+ * Handle file selection and upload
151
+ */
152
+ const handleFileUpload = async (file, aceContext) => {
153
+ // Validate file
154
+ const validation = validateFile(file);
155
+ if (!validation.valid) {
156
+ showError(validation.error);
157
+ return;
158
+ }
159
+
160
+ // Show progress modal
161
+ showModal('progress');
162
+
163
+ try {
164
+ // Upload to S3 and get secure download URL
165
+ const downloadUrl = await uploadToS3(file);
166
+
167
+ // Insert hyperlink into document (uses authenticated download endpoint)
168
+ aceContext.callWithAce((ace) => {
169
+ ace.ace_doInsertMediaLink(downloadUrl, file.name);
170
+ }, 'insertMediaLink', true);
171
+
172
+ // Hide modal on success (no success message needed)
173
+ hideModal();
174
+
175
+ } catch (err) {
176
+ console.error('[ep_media_upload] Upload failed:', err);
177
+ // Extract error message from various error formats
178
+ let errorMsg = 'Upload failed.';
179
+ if (err.responseJSON && err.responseJSON.error) {
180
+ // jQuery AJAX error with JSON response
181
+ errorMsg = err.responseJSON.error;
182
+ } else if (err.message) {
183
+ // Standard Error object
184
+ errorMsg = err.message;
185
+ } else if (typeof err === 'string') {
186
+ errorMsg = err;
187
+ }
188
+ showError(errorMsg);
189
+ }
190
+ };
191
+
192
+ /**
193
+ * aceInitialized hook
194
+ * Bind the hyperlink insertion function to ace context
195
+ */
196
+ exports.aceInitialized = (hook, context) => {
197
+ context.editorInfo.ace_doInsertMediaLink = doInsertMediaLink.bind(context);
198
+ };
199
+
200
+ /**
201
+ * postAceInit hook
202
+ * Set up modal close button handler
203
+ */
204
+ exports.postAceInit = (hook, context) => {
205
+ _aceContext = context.ace;
206
+
207
+ // Close button handler for error modal
208
+ $(document).on('click', '#mediaUploadErrorClose', () => {
209
+ hideModal();
210
+ });
211
+
212
+ // Click outside modal to close (only for error state)
213
+ $(document).on('click', '#mediaUploadModal', (e) => {
214
+ if (e.target.id === 'mediaUploadModal' && $('#mediaUploadError').is(':visible')) {
215
+ hideModal();
216
+ }
217
+ });
218
+ };
219
+
220
+ /**
221
+ * postToolbarInit hook
222
+ * Register the mediaUpload toolbar command
223
+ */
224
+ exports.postToolbarInit = (hook, context) => {
225
+ const toolbar = context.toolbar;
226
+
227
+ toolbar.registerCommand('mediaUpload', () => {
228
+ // Remove any existing file input (cleanup from previous attempts)
229
+ $('#mediaUploadFileInput').remove();
230
+
231
+ // Create hidden file input
232
+ const fileInput = $('<input>')
233
+ .attr({
234
+ type: 'file',
235
+ id: 'mediaUploadFileInput',
236
+ style: 'position:absolute;left:-9999px;'
237
+ });
238
+
239
+ // Add accept attribute if file types are configured
240
+ if (clientVars.ep_media_upload && clientVars.ep_media_upload.fileTypes) {
241
+ const accept = clientVars.ep_media_upload.fileTypes
242
+ .map(ext => `.${ext}`)
243
+ .join(',');
244
+ fileInput.attr('accept', accept);
245
+ }
246
+
247
+ $('body').append(fileInput);
248
+
249
+ // Cleanup function to remove file input
250
+ const cleanup = () => {
251
+ fileInput.off(); // Remove all event handlers
252
+ fileInput.remove();
253
+ };
254
+
255
+ // Handle file selection - use 'one' so it only fires once
256
+ fileInput.one('change', (e) => {
257
+ const files = e.target.files;
258
+ if (!files || files.length === 0) {
259
+ cleanup();
260
+ return;
261
+ }
262
+
263
+ const file = files[0];
264
+ handleFileUpload(file, context.ace);
265
+ cleanup();
266
+ });
267
+
268
+ // Handle cancel (user closes file picker without selecting)
269
+ // The blur/focus trick: when file picker closes, window regains focus
270
+ $(window).one('focus', () => {
271
+ // Small delay to allow change event to fire first if file was selected
272
+ setTimeout(() => {
273
+ if ($('#mediaUploadFileInput').length > 0) {
274
+ cleanup();
275
+ }
276
+ }, 300);
277
+ });
278
+
279
+ // Trigger file picker
280
+ fileInput.trigger('click');
281
+ });
282
+
283
+ console.log('[ep_media_upload] Toolbar command registered');
284
+ };
@@ -1,8 +1,8 @@
1
- <li class="separator acl-write"></li>
2
-
3
- <li data-type="button" data-key="mediaUpload" class="acl-write" data-l10n-id="ep_media_upload.toolbar.upload.title">
4
- <a class="grouped-left ep_media_upload" data-l10n-id="ep_media_upload.toolbar.upload.title" title="Upload File" aria-label="Upload File">
5
- <button class="buttonicon ep_media_upload media_upload buttonicon-attachment" aria-label="Upload File"></button>
6
- </a>
7
- </li>
8
-
1
+ <li class="separator acl-write"></li>
2
+
3
+ <li data-type="button" data-key="mediaUpload" class="acl-write" data-l10n-id="ep_media_upload.toolbar.upload.title">
4
+ <a class="grouped-left ep_media_upload" data-l10n-id="ep_media_upload.toolbar.upload.title" title="Upload File" aria-label="Upload File">
5
+ <button class="buttonicon ep_media_upload media_upload buttonicon-attachment" aria-label="Upload File"></button>
6
+ </a>
7
+ </li>
8
+
@@ -1,12 +1,12 @@
1
- <div id="mediaUploadModal" class="ep-media-upload-modal">
2
- <div class="ep-media-upload-modal-content">
3
- <div id="mediaUploadProgress" class="ep-media-upload-state">
4
- <div class="ep-media-upload-spinner"></div>
5
- <p class="ep-media-upload-message" data-l10n-id="ep_media_upload.status.uploading">Uploading...</p>
6
- </div>
7
- <div id="mediaUploadError" class="ep-media-upload-state" style="display: none;">
8
- <p class="ep-media-upload-message ep-media-upload-error-text">Upload failed.</p>
9
- <button id="mediaUploadErrorClose" class="ep-media-upload-btn" data-l10n-id="ep_media_upload.button.close">Close</button>
10
- </div>
11
- </div>
12
- </div>
1
+ <div id="mediaUploadModal" class="ep-media-upload-modal">
2
+ <div class="ep-media-upload-modal-content">
3
+ <div id="mediaUploadProgress" class="ep-media-upload-state">
4
+ <div class="ep-media-upload-spinner"></div>
5
+ <p class="ep-media-upload-message" data-l10n-id="ep_media_upload.status.uploading">Uploading...</p>
6
+ </div>
7
+ <div id="mediaUploadError" class="ep-media-upload-state" style="display: none;">
8
+ <p class="ep-media-upload-message ep-media-upload-error-text">Upload failed.</p>
9
+ <button id="mediaUploadErrorClose" class="ep-media-upload-btn" data-l10n-id="ep_media_upload.button.close">Close</button>
10
+ </div>
11
+ </div>
12
+ </div>