ep_images_extended 1.0.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,1848 @@
1
+ 'use strict';
2
+
3
+
4
+ // Simple UUID generator
5
+ function generateUUID() {
6
+ return 'xxxx-xxxx-4xxx-yxxx-xxxx'.replace(/[xy]/g, function(c) {
7
+ const r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8);
8
+ return v.toString(16);
9
+ });
10
+ }
11
+
12
+ // Helper function to find image placeholder position robustly
13
+ function findImagePlaceholderPosition(lineText, imageIndex, fallbackLineElement = null) {
14
+ // ---- Primary strategy: use canonical placeholder scan -------------------
15
+ const canonicalRanges = getAllPlaceholderRanges(lineText);
16
+ if (imageIndex >= 0 && imageIndex < canonicalRanges.length) {
17
+ const r = canonicalRanges[imageIndex];
18
+ return {
19
+ colStart: r.colStart,
20
+ patternLength: r.patternLength,
21
+ pattern: lineText.substr(r.colStart, r.patternLength),
22
+ };
23
+ }
24
+
25
+ // Fallback heuristics (legacy) -------------------------------------------
26
+ // Try different placeholder patterns that might exist
27
+ const placeholderPatterns = [
28
+ '\u200B\u200B\u200B', // 3 × ZWSP (current)
29
+ '\u200B\u00A0\u200B', // ZWSP NBSP ZWSP (toolbar-inserted, incl. tables)
30
+ '\u200B\u200B', // 2 × ZWSP (collapsed)
31
+ '\u00A0', // single NBSP (edge / collapsed)
32
+ '\u200B' // single ZWSP (legacy)
33
+ ];
34
+
35
+ for (const pattern of placeholderPatterns) {
36
+ let searchFromIndex = 0;
37
+ let foundCount = 0;
38
+
39
+ while (foundCount <= imageIndex) {
40
+ const foundIndex = lineText.indexOf(pattern, searchFromIndex);
41
+ if (foundIndex === -1) {
42
+ break; // Pattern not found anymore
43
+ }
44
+
45
+ if (foundCount === imageIndex) {
46
+ // Found the target placeholder
47
+ return {
48
+ colStart: foundIndex,
49
+ patternLength: pattern.length,
50
+ pattern: pattern
51
+ };
52
+ }
53
+
54
+ foundCount++;
55
+ searchFromIndex = foundIndex + pattern.length;
56
+ }
57
+ }
58
+
59
+ // Fallback: try to find any zero-width space near expected position
60
+ const zwspPattern = '\u200B';
61
+ const expectedPosition = imageIndex * 3; // Rough estimate
62
+ const searchStart = Math.max(0, expectedPosition - 10);
63
+ const searchEnd = Math.min(lineText.length, expectedPosition + 20);
64
+
65
+ for (let i = searchStart; i < searchEnd; i++) {
66
+ if (lineText[i] === zwspPattern) {
67
+ console.log(`[ep_images_extended] Fallback: Found ZWSP at position ${i} for image index ${imageIndex}`);
68
+ return {
69
+ colStart: i,
70
+ patternLength: 1,
71
+ pattern: zwspPattern
72
+ };
73
+ }
74
+ }
75
+
76
+ // DOM-based fallback: when text-based approach fails, use DOM positioning
77
+ if (fallbackLineElement) {
78
+ console.log(`[ep_images_extended] DOM fallback: Using DOM-based positioning for image index ${imageIndex}`);
79
+
80
+ // Count characters before the target image element in the DOM
81
+ const allImagePlaceholders = Array.from(fallbackLineElement.querySelectorAll('.inline-image.image-placeholder'));
82
+ if (imageIndex < allImagePlaceholders.length) {
83
+ const targetImageElement = allImagePlaceholders[imageIndex];
84
+
85
+ // Calculate approximate text position by walking through DOM text nodes
86
+ let approximatePosition = 0;
87
+ const walker = document.createTreeWalker(
88
+ fallbackLineElement,
89
+ NodeFilter.SHOW_TEXT,
90
+ null,
91
+ false
92
+ );
93
+
94
+ let currentNode = walker.nextNode();
95
+ while (currentNode) {
96
+ if (currentNode.parentNode &&
97
+ (currentNode.parentNode === targetImageElement ||
98
+ targetImageElement.contains(currentNode.parentNode))) {
99
+ // Found a text node that belongs to our target image
100
+ console.log(`[ep_images_extended] DOM fallback: Found position ${approximatePosition} for image index ${imageIndex}`);
101
+ return {
102
+ colStart: approximatePosition,
103
+ patternLength: 3, // Assume 3 characters for safety
104
+ pattern: '\u200B\u200B\u200B',
105
+ isDomFallback: true
106
+ };
107
+ }
108
+ // Add the length of this text node to our position counter
109
+ approximatePosition += currentNode.textContent.length;
110
+ currentNode = walker.nextNode();
111
+ }
112
+
113
+ // If we couldn't find the exact position, use a reasonable estimate
114
+ console.log(`[ep_images_extended] DOM fallback: Using estimated position ${approximatePosition} for image index ${imageIndex}`);
115
+ return {
116
+ colStart: Math.max(0, approximatePosition - 1),
117
+ patternLength: 3,
118
+ pattern: '\u200B\u200B\u200B',
119
+ isDomFallback: true
120
+ };
121
+ }
122
+ }
123
+
124
+ return null; // No placeholder found
125
+ }
126
+
127
+ // Helper function to validate ace operations and document state
128
+ function validateAceOperation(ace, operation, rangeStart, rangeEnd, context = '') {
129
+ try {
130
+ // Validate that ace exists and has required methods
131
+ if (!ace) {
132
+ console.error(`[ep_images_extended ${context}] ace object not available`);
133
+ return false;
134
+ }
135
+
136
+ // Check for required methods based on operation type
137
+ if (operation === 'applyAttributes' && typeof ace.ace_performDocumentApplyAttributesToRange !== 'function') {
138
+ console.error(`[ep_images_extended ${context}] ace_performDocumentApplyAttributesToRange not available`);
139
+ return false;
140
+ }
141
+
142
+ if (operation === 'replaceRange' && typeof ace.ace_replaceRange !== 'function') {
143
+ console.error(`[ep_images_extended ${context}] ace_replaceRange not available`);
144
+ return false;
145
+ }
146
+
147
+ // Get current rep to validate document state
148
+ const rep = ace.ace_getRep();
149
+ if (!rep || !rep.lines) {
150
+ console.error(`[ep_images_extended ${context}] Document rep not available or invalid`);
151
+ return false;
152
+ }
153
+
154
+ // Validate range bounds if provided
155
+ if (rangeStart && rangeEnd) {
156
+ const [startLine, startCol] = rangeStart;
157
+ const [endLine, endCol] = rangeEnd;
158
+
159
+ // Check if line numbers are valid
160
+ if (startLine < 0 || endLine < 0 || startLine >= rep.lines.length() || endLine >= rep.lines.length()) {
161
+ console.error(`[ep_images_extended ${context}] Invalid line numbers in range:`, [rangeStart, rangeEnd], 'total lines:', rep.lines.length());
162
+ return false;
163
+ }
164
+
165
+ // Check if the lines still exist
166
+ const startLineObj = rep.lines.atIndex(startLine);
167
+ const endLineObj = rep.lines.atIndex(endLine);
168
+ if (!startLineObj || !endLineObj) {
169
+ console.error(`[ep_images_extended ${context}] One or more lines no longer exist:`, startLine, endLine);
170
+ return false;
171
+ }
172
+
173
+ // Validate column bounds
174
+ const startLineText = startLineObj.text;
175
+ const endLineText = endLineObj.text;
176
+ if (startCol < 0 || endCol < 0 || startCol > startLineText.length || endCol > endLineText.length) {
177
+ console.error(`[ep_images_extended ${context}] Invalid column positions in range:`, [rangeStart, rangeEnd],
178
+ 'start line length:', startLineText.length, 'end line length:', endLineText.length);
179
+ return false;
180
+ }
181
+ }
182
+
183
+ return true;
184
+ } catch (error) {
185
+ console.error(`[ep_images_extended ${context}] Error during validation:`, error);
186
+ return false;
187
+ }
188
+ }
189
+
190
+ exports.aceAttribsToClasses = function(name, context) {
191
+ if (context.key === 'image' && context.value) {
192
+ return ['image:' + context.value];
193
+ }
194
+ if (context.key === 'image-width' && context.value) {
195
+ return ['image-width:' + context.value];
196
+ }
197
+ if (context.key === 'image-height' && context.value) {
198
+ return ['image-height:' + context.value];
199
+ }
200
+ // ADDED for imageCssAspectRatio
201
+ if (context.key === 'imageCssAspectRatio' && context.value) {
202
+ return ['imageCssAspectRatio:' + context.value];
203
+ }
204
+ // ADDED for image float style
205
+ if (context.key === 'image-float' && context.value) {
206
+ return ['image-float:' + context.value];
207
+ }
208
+ // ADDED for persistent image ID
209
+ if (context.key === 'image-id' && context.value) {
210
+ return ['image-id-' + context.value];
211
+ }
212
+ return [];
213
+ };
214
+
215
+ exports.aceInitialized = (hook, context) => {
216
+ // Bind the new image insertion function
217
+ context.editorInfo.ace_doInsertImage = doInsertImage.bind(context);
218
+ };
219
+
220
+ // Function to render placeholders into actual images (Currently unused due to CSS background approach)
221
+ /*
222
+ const renderImagePlaceholders = (rootElement) => {
223
+ const placeholders = $(rootElement).find('span.image-placeholder');
224
+ placeholders.each(function() {
225
+ const $placeholder = $(this);
226
+ if ($placeholder.data('processed-image')) {
227
+ return;
228
+ }
229
+ const attribsData = $placeholder.data('image-attribs');
230
+ if (typeof attribsData === 'string') {
231
+ try {
232
+ const imageData = JSON.parse(attribsData);
233
+ if (imageData && imageData.src) {
234
+ const $img = $('<img>').attr('src', imageData.src);
235
+ $img.css({
236
+ 'display': 'inline-block',
237
+ 'max-width': '100%',
238
+ 'max-height': '20em',
239
+ 'vertical-align': 'middle'
240
+ });
241
+ if (imageData.width) $img.attr('width', imageData.width);
242
+ if (imageData.height) $img.attr('height', imageData.height);
243
+ $placeholder.empty().append($img);
244
+ $placeholder.data('processed-image', true);
245
+ } else {
246
+ $placeholder.text('[Invalid Image]');
247
+ $placeholder.data('processed-image', true);
248
+ }
249
+ } catch (e) {
250
+ console.error('[ep_images_extended] Failed to parse image data:', attribsData, e);
251
+ $placeholder.text('[Parse Error]');
252
+ $placeholder.data('processed-image', true);
253
+ }
254
+ } else {
255
+ $placeholder.text('[Missing Data]');
256
+ $placeholder.data('processed-image', true);
257
+ }
258
+ });
259
+ };
260
+ */
261
+
262
+ exports.postAceInit = function (hook, context) {
263
+ const padOuter = $('iframe[name="ace_outer"]').contents().find('body');
264
+ if (padOuter.length === 0) {
265
+ console.error('[ep_images_extended postAceInit] Could not find outer pad body.');
266
+ return;
267
+ }
268
+
269
+ if ($('#imageResizeOutline').length === 0) {
270
+ const $outlineBox = $('<div id="imageResizeOutline"></div>');
271
+ $outlineBox.css({
272
+ position: 'absolute',
273
+ border: '1px dashed #1a73e8',
274
+ backgroundColor: 'rgba(26, 115, 232, 0.1)',
275
+ 'pointer-events': 'none',
276
+ display: 'none',
277
+ 'z-index': 1000,
278
+ 'box-sizing': 'border-box'
279
+ });
280
+ padOuter.append($outlineBox);
281
+ }
282
+
283
+ // Check if image formatting menu exists (should be loaded from template)
284
+ if ($('#imageFormatMenu').length === 0) {
285
+ console.log('[ep_images_extended] Image format menu not found - template may not be loaded yet');
286
+ } else {
287
+ console.log('[ep_images_extended] Image format menu found from template');
288
+ }
289
+
290
+ const $outlineBoxRef = padOuter.find('#imageResizeOutline');
291
+ // Look for the format menu in the main document (loaded from template)
292
+ const $formatMenuRef = $('#imageFormatMenu');
293
+ const _aceContext = context.ace;
294
+
295
+ if (!$outlineBoxRef || $outlineBoxRef.length === 0) {
296
+ console.error('[ep_images_extended postAceInit] FATAL: Could not find #imageResizeOutline OUTSIDE callWithAce.');
297
+ return;
298
+ }
299
+
300
+ if (!$formatMenuRef || $formatMenuRef.length === 0) {
301
+ console.error('[ep_images_extended postAceInit] FATAL: Could not find #imageFormatMenu OUTSIDE callWithAce.');
302
+ return;
303
+ }
304
+
305
+ context.ace.callWithAce((ace) => {
306
+ const $innerIframe = $('iframe[name="ace_outer"]').contents().find('iframe[name="ace_inner"]');
307
+ if ($innerIframe.length === 0) {
308
+ console.error('ep_images_extended: ERROR - Could not find inner iframe (ace_inner).');
309
+ return;
310
+ }
311
+ const innerDocBody = $innerIframe.contents().find('body')[0];
312
+ const $inner = $(innerDocBody);
313
+ const innerDoc = $innerIframe.contents();
314
+
315
+ if (!$inner || $inner.length === 0) {
316
+ console.error('ep_images_extended: ERROR - Could not get body from inner iframe.');
317
+ return;
318
+ }
319
+
320
+ let isDragging = false;
321
+ let startX = 0;
322
+ let startWidth = 0;
323
+ let startHeight = 0;
324
+ // let aspectRatio = 1; // Not directly used for height calculation anymore
325
+ let currentVisualAspectRatioHW = 1;
326
+ let targetOuterSpan = null;
327
+ let targetInnerSpan = null;
328
+ let targetLineNumber = -1;
329
+ let outlineBoxPositioned = false;
330
+ let mousedownClientX = 0;
331
+ let mousedownClientY = 0;
332
+ let clickedHandle = null;
333
+
334
+ // Use a global approach with CSS classes on the body and unique image identifiers
335
+ let selectedImageSrc = null;
336
+ let selectedImageElement = null; // Track the specific selected element
337
+ let selectedImageLine = -1; // Track line number for persistent selection
338
+ let selectedImageCol = -1; // Track column for persistent selection
339
+
340
+ // NEW: Store the active image's unique ID
341
+ window.epImageInsertActiveImageId = null;
342
+
343
+ // Store resize positioning data without DOM attributes to avoid triggering content collection
344
+ let resizePositionData = null;
345
+
346
+ // Function to position and show the format menu below the selected image
347
+ const showFormatMenu = (imageElement) => {
348
+ // Don't show format menu in read-only mode
349
+ if (clientVars && clientVars.readonly) {
350
+ return;
351
+ }
352
+
353
+ if (!imageElement || !$formatMenuRef || $formatMenuRef.length === 0) {
354
+ return;
355
+ }
356
+
357
+ try {
358
+ const innerSpan = imageElement.querySelector('span.image-inner');
359
+ if (!innerSpan) {
360
+ return;
361
+ }
362
+
363
+ // Use the same complex positioning logic as the resize outline
364
+ const imageRect = innerSpan.getBoundingClientRect();
365
+
366
+ // Get all the necessary container references
367
+ let innerBodyRect, innerIframeRect, outerBodyRect;
368
+ let scrollTopInner, scrollLeftInner, scrollTopOuter, scrollLeftOuter;
369
+ try {
370
+ innerBodyRect = innerDocBody.getBoundingClientRect();
371
+ innerIframeRect = $innerIframe[0].getBoundingClientRect();
372
+ outerBodyRect = padOuter[0].getBoundingClientRect();
373
+ scrollTopInner = innerDocBody.scrollTop;
374
+ scrollLeftInner = innerDocBody.scrollLeft;
375
+ scrollTopOuter = padOuter.scrollTop();
376
+ scrollLeftOuter = padOuter.scrollLeft();
377
+ } catch (e) {
378
+ console.error('[ep_images_extended showFormatMenu] Error getting container rects/scrolls:', e);
379
+ return;
380
+ }
381
+
382
+ // Calculate position using the same method as resize outline
383
+ const imageTopRelInner = imageRect.top - innerBodyRect.top + scrollTopInner;
384
+ const imageLeftRelInner = imageRect.left - innerBodyRect.left + scrollLeftInner;
385
+ const imageBottomRelInner = imageRect.bottom - innerBodyRect.top + scrollTopInner;
386
+
387
+ const innerFrameTopRelOuter = innerIframeRect.top - outerBodyRect.top + scrollTopOuter;
388
+ const innerFrameLeftRelOuter = innerIframeRect.left - outerBodyRect.left + scrollLeftOuter;
389
+
390
+ const menuTopOuter = innerFrameTopRelOuter + imageBottomRelInner + 8; // 8px gap below image
391
+ // For left alignment, we want the menu's left edge to match the image's left edge
392
+ const menuLeftOuter = innerFrameLeftRelOuter + imageLeftRelInner;
393
+
394
+ // Apply the same padding adjustments as resize outline
395
+ const outerPadding = window.getComputedStyle(padOuter[0]);
396
+ const outerPaddingTop = parseFloat(outerPadding.paddingTop) || 0;
397
+ const outerPaddingLeft = parseFloat(outerPadding.paddingLeft) || 0;
398
+
399
+ // Apply manual offsets similar to resize outline, but adjusted for menu
400
+ const MENU_MANUAL_OFFSET_TOP = 9; // Same as resize outline
401
+ const MENU_MANUAL_OFFSET_LEFT = 37; // Reduced by 5px to move menu closer to left margin
402
+
403
+ const finalMenuTop = menuTopOuter + outerPaddingTop + MENU_MANUAL_OFFSET_TOP;
404
+ const finalMenuLeft = menuLeftOuter + outerPaddingLeft + MENU_MANUAL_OFFSET_LEFT;
405
+
406
+ // Position the menu using absolute positioning relative to padOuter (like resize outline)
407
+ $formatMenuRef.css({
408
+ position: 'absolute',
409
+ left: Math.max(10, finalMenuLeft) + 'px',
410
+ top: finalMenuTop + 'px',
411
+ 'z-index': '10000'
412
+ }).addClass('visible');
413
+
414
+ // Move the menu to padOuter if it's not already there
415
+ if ($formatMenuRef.parent()[0] !== padOuter[0]) {
416
+ padOuter.append($formatMenuRef);
417
+ }
418
+
419
+ // Update menu button states based on current image float style
420
+ updateMenuButtonStates(imageElement);
421
+
422
+ } catch (e) {
423
+ console.error('[ep_images_extended] Error positioning format menu:', e);
424
+ }
425
+ };
426
+
427
+ // Function to hide the format menu
428
+ const hideFormatMenu = () => {
429
+ if ($formatMenuRef) {
430
+ $formatMenuRef.removeClass('visible');
431
+ }
432
+ };
433
+
434
+ // Function to update menu button states based on current image float style
435
+ const updateMenuButtonStates = (imageElement) => {
436
+ if (!imageElement || !$formatMenuRef || $formatMenuRef.length === 0) {
437
+ return;
438
+ }
439
+
440
+ // Determine current float state
441
+ let currentFloat = 'inline'; // default
442
+ if (imageElement.classList.contains('image-float-left')) {
443
+ currentFloat = 'left';
444
+ } else if (imageElement.classList.contains('image-float-right')) {
445
+ currentFloat = 'right';
446
+ } else if (imageElement.classList.contains('image-float-none')) {
447
+ currentFloat = 'inline';
448
+ }
449
+
450
+ // Update button active states
451
+ $formatMenuRef.find('.image-format-button[data-wrap]').removeClass('active');
452
+ $formatMenuRef.find(`.image-format-button[data-wrap="${currentFloat}"]`).addClass('active');
453
+ };
454
+
455
+ $inner.on('mousedown', '.inline-image.image-placeholder', function(evt) {
456
+ if (evt.button !== 0) return;
457
+
458
+ // Don't allow interaction in read-only mode
459
+ if (clientVars && clientVars.readonly) {
460
+ return;
461
+ }
462
+
463
+ // *** ENHANCED DEBUG: Track image click within table context ***
464
+ console.log('[ep_images_extended] *** IMAGE MOUSEDOWN EVENT START ***');
465
+ console.log('[ep_images_extended] Event target:', evt.target);
466
+ console.log('[ep_images_extended] Event currentTarget:', evt.currentTarget);
467
+
468
+ targetOuterSpan = this;
469
+ const $targetOuterSpan = $(targetOuterSpan);
470
+ console.log('[ep_images_extended] Target outer span element:', targetOuterSpan);
471
+ console.log('[ep_images_extended] Target outer span classes:', targetOuterSpan.className);
472
+ console.log('[ep_images_extended] Target outer span HTML length:', targetOuterSpan.outerHTML?.length || 0);
473
+
474
+ // *** DEBUG: Check table context ***
475
+ const closestTable = targetOuterSpan.closest('table.dataTable');
476
+ const closestTableCell = targetOuterSpan.closest('td, th');
477
+ const closestAceLine = targetOuterSpan.closest('.ace-line');
478
+
479
+ console.log('[ep_images_extended] Is image within table?', !!closestTable);
480
+ if (closestTable) {
481
+ console.log('[ep_images_extended] Table tblId:', closestTable.getAttribute('data-tblId'));
482
+ console.log('[ep_images_extended] Table row:', closestTable.getAttribute('data-row'));
483
+ console.log('[ep_images_extended] Table cell:', !!closestTableCell);
484
+ if (closestTableCell) {
485
+ console.log('[ep_images_extended] Cell data-column:', closestTableCell.getAttribute('data-column'));
486
+ console.log('[ep_images_extended] Cell innerHTML length:', closestTableCell.innerHTML?.length || 0);
487
+ }
488
+ }
489
+ if (closestAceLine) {
490
+ console.log('[ep_images_extended] Ace line ID:', closestAceLine.id);
491
+ console.log('[ep_images_extended] Ace line classes:', closestAceLine.className);
492
+ console.log('[ep_images_extended] Ace line innerHTML length:', closestAceLine.innerHTML?.length || 0);
493
+ }
494
+
495
+ const imageId = $targetOuterSpan.attr('data-image-id');
496
+ console.log('[ep_images_extended] Image ID:', imageId);
497
+
498
+ if (imageId) {
499
+ const previouslyActiveId = window.epImageInsertActiveImageId;
500
+ console.log('[ep_images_extended] Previously active image ID:', previouslyActiveId);
501
+ console.log('[ep_images_extended] Setting new active image ID:', imageId);
502
+
503
+ window.epImageInsertActiveImageId = imageId; // NEW: Set active ID
504
+
505
+ // *** DEBUG: Track selection styling changes ***
506
+ console.log('[ep_images_extended] *** SELECTION STYLING START ***');
507
+
508
+ // Use dynamic CSS injection to avoid triggering content collector on image elements
509
+ if (previouslyActiveId !== imageId) {
510
+ console.log('[ep_images_extended] Updating dynamic CSS selection');
511
+ const innerDoc = $inner[0].ownerDocument;
512
+
513
+ // Remove previous dynamic style if it exists
514
+ let existingStyle = innerDoc.getElementById('ep-image-selection-style');
515
+ if (existingStyle) {
516
+ existingStyle.remove();
517
+ }
518
+
519
+ // Create new dynamic style element for the selected image
520
+ if (imageId) {
521
+ const styleElement = innerDoc.createElement('style');
522
+ styleElement.id = 'ep-image-selection-style';
523
+ styleElement.textContent = `
524
+ span.inline-image.image-placeholder[data-image-id="${imageId}"] span.image-inner {
525
+ outline: 1px solid #1a73e8 !important;
526
+ outline-offset: 1px !important;
527
+ }
528
+ span.inline-image.image-placeholder[data-image-id="${imageId}"] span.image-resize-handle {
529
+ display: block !important;
530
+ }
531
+ `;
532
+ innerDoc.head.appendChild(styleElement);
533
+ console.log('[ep_images_extended] Added dynamic CSS for image:', imageId);
534
+ }
535
+ }
536
+
537
+ console.log('[ep_images_extended] *** SELECTION STYLING END ***');
538
+
539
+ // *** DEBUG: Check DOM state after style changes ***
540
+ if (closestAceLine) {
541
+ console.log('[ep_images_extended] *** DOM STATE AFTER STYLE CHANGES ***');
542
+ console.log('[ep_images_extended] Ace line innerHTML length after style changes:', closestAceLine.innerHTML?.length || 0);
543
+ console.log('[ep_images_extended] Ace line innerHTML (first 500 chars):', closestAceLine.innerHTML?.substring(0, 500) || '');
544
+
545
+ // Check for delimiter presence in the line
546
+ const delimiterCount = (closestAceLine.innerHTML || '').split('|').length - 1;
547
+ console.log('[ep_images_extended] Delimiter count in ace line after style changes:', delimiterCount);
548
+
549
+ // Check if tbljson class is still present
550
+ const hasTbljsonClass = closestAceLine.innerHTML?.includes('tbljson-') || false;
551
+ console.log('[ep_images_extended] Ace line still has tbljson class after changes:', hasTbljsonClass);
552
+ }
553
+
554
+ showFormatMenu(targetOuterSpan);
555
+ } else {
556
+ console.warn('[ep_images_extended mousedown] Image clicked has no data-image-id.');
557
+ }
558
+
559
+ targetInnerSpan = targetOuterSpan.querySelector('span.image-inner');
560
+ if (!targetInnerSpan) {
561
+ console.error('[ep_images_extended mousedown] Could not find inner span.');
562
+ targetOuterSpan = null;
563
+ $targetOuterSpan.removeClass('selected');
564
+ return;
565
+ }
566
+
567
+ const target = $(evt.target);
568
+ const isResizeHandle = target.hasClass('image-resize-handle');
569
+ console.log('[ep_images_extended] Is resize handle clicked?', isResizeHandle);
570
+
571
+ // If clicking on a resize handle, start the resize operation
572
+ if (isResizeHandle) {
573
+ console.log('[ep_images_extended] *** RESIZE HANDLE CLICKED - STARTING RESIZE OPERATION ***');
574
+ isDragging = true;
575
+ outlineBoxPositioned = false;
576
+ startX = evt.clientX;
577
+ mousedownClientX = evt.clientX;
578
+ mousedownClientY = evt.clientY;
579
+ startWidth = targetInnerSpan.offsetWidth || parseInt(targetInnerSpan.style.width, 10) || 0;
580
+ startHeight = targetInnerSpan.offsetHeight || parseInt(targetInnerSpan.style.height, 10) || 0;
581
+ currentVisualAspectRatioHW = (startWidth > 0 && startHeight > 0) ? (startHeight / startWidth) : 1;
582
+
583
+ if (target.hasClass('br')) clickedHandle = 'br';
584
+ else clickedHandle = null;
585
+
586
+ const lineElement = $(targetOuterSpan).closest('.ace-line')[0];
587
+
588
+ if (lineElement) {
589
+ const allImagePlaceholdersInLine = Array.from(lineElement.querySelectorAll('.inline-image.image-placeholder'));
590
+ const imageIndex = allImagePlaceholdersInLine.indexOf(targetOuterSpan);
591
+
592
+ if (imageIndex === -1) {
593
+ console.error('[ep_images_extended mousedown] Clicked image placeholder not found within its line DOM elements.');
594
+ isDragging = false;
595
+ resizePositionData = null;
596
+ $targetOuterSpan.removeClass('selected');
597
+ targetOuterSpan = null;
598
+ return;
599
+ }
600
+
601
+ targetLineNumber = _getLineNumberOfElement(lineElement);
602
+
603
+ // Store positioning data in JavaScript variable instead of DOM attributes to avoid content collection
604
+ _aceContext.callWithAce((ace) => {
605
+ const rep = ace.ace_getRep();
606
+ if (!rep.lines.atIndex(targetLineNumber)) {
607
+ console.error(`[ep_images_extended mousedown] Line ${targetLineNumber} does not exist in rep.`);
608
+ resizePositionData = null;
609
+ return;
610
+ }
611
+ const lineText = rep.lines.atIndex(targetLineNumber).text;
612
+
613
+ // Use helper function to find placeholder position
614
+ const placeholderInfo = findImagePlaceholderPosition(lineText, imageIndex, lineElement);
615
+
616
+ if (placeholderInfo) {
617
+ // Store in JS variable instead of DOM attributes to avoid triggering content collection
618
+ resizePositionData = {
619
+ lineNumber: targetLineNumber,
620
+ colStart: placeholderInfo.colStart,
621
+ patternLength: placeholderInfo.patternLength
622
+ };
623
+ console.log(`[ep_images_extended mousedown] Found placeholder at position ${placeholderInfo.colStart} with pattern length ${placeholderInfo.patternLength}`);
624
+ } else {
625
+ console.error(`[ep_images_extended mousedown] Could not find any placeholder sequence for image index ${imageIndex} in line text: "${lineText}"`);
626
+ resizePositionData = null;
627
+ }
628
+ }, 'getImageColStart', true);
629
+ } else {
630
+ console.error('[ep_images_extended mousedown] Could not find parent .ace-line for the clicked image.');
631
+ isDragging = false;
632
+ resizePositionData = null;
633
+ $targetOuterSpan.removeClass('selected');
634
+ targetOuterSpan = null;
635
+ return;
636
+ }
637
+ evt.preventDefault();
638
+ } else {
639
+ console.log('[ep_images_extended] *** SIMPLE IMAGE CLICK - NO RESIZE HANDLE ***');
640
+ // Position cursor next to the image instead of inside it
641
+ _aceContext.callWithAce((ace) => {
642
+ const lineElement = $(targetOuterSpan).closest('.ace-line')[0];
643
+ if (lineElement) {
644
+ const lineNumber = _getLineNumberOfElement(lineElement);
645
+ const rep = ace.ace_getRep();
646
+ if (rep.lines.atIndex(lineNumber)) {
647
+ const lineText = rep.lines.atIndex(lineNumber).text;
648
+ const allImages = Array.from(lineElement.querySelectorAll('.inline-image.image-placeholder'));
649
+ const imageIndex = allImages.indexOf(targetOuterSpan);
650
+
651
+ if (imageIndex !== -1) {
652
+ const placeholderInfo = findImagePlaceholderPosition(lineText, imageIndex, lineElement);
653
+ if (placeholderInfo) {
654
+ // Determine cursor position based on click location relative to image
655
+ const imageRect = targetInnerSpan.getBoundingClientRect();
656
+ const clickX = evt.clientX;
657
+ const imageCenterX = imageRect.left + imageRect.width / 2;
658
+
659
+ let cursorPos;
660
+ if (clickX < imageCenterX) {
661
+ // Clicked left side - place cursor before image
662
+ cursorPos = [lineNumber, placeholderInfo.colStart];
663
+ } else {
664
+ // Clicked right side - place cursor after image
665
+ cursorPos = [lineNumber, placeholderInfo.colStart + placeholderInfo.patternLength];
666
+ }
667
+
668
+ console.log(`[ep_images_extended] Positioning cursor at [${cursorPos}] based on click position`);
669
+ ace.ace_performSelectionChange(cursorPos, cursorPos, false);
670
+ }
671
+ }
672
+ }
673
+ }
674
+ }, 'positionCursorNextToImage', true);
675
+ }
676
+
677
+ console.log('[ep_images_extended] *** IMAGE MOUSEDOWN EVENT END ***');
678
+ });
679
+
680
+ innerDoc.on('mousemove', function(evt) {
681
+ if (isDragging) {
682
+ if (!outlineBoxPositioned) {
683
+ if (!targetInnerSpan || !padOuter || !targetOuterSpan || !innerDocBody || !$innerIframe) {
684
+ console.error('[ep_images_extended mousemove] Cannot position outline: Required elements missing.');
685
+ return;
686
+ }
687
+ const currentWidth = startWidth;
688
+ const currentHeight = startHeight;
689
+
690
+ // if (currentWidth <= 0 || currentHeight <= 0) { /* Warning for this was removed */ }
691
+
692
+ let innerBodyRect, innerIframeRect, outerBodyRect;
693
+ let scrollTopInner, scrollLeftInner, scrollTopOuter, scrollLeftOuter;
694
+ try {
695
+ innerBodyRect = innerDocBody.getBoundingClientRect();
696
+ innerIframeRect = $innerIframe[0].getBoundingClientRect();
697
+ outerBodyRect = padOuter[0].getBoundingClientRect();
698
+ scrollTopInner = innerDocBody.scrollTop;
699
+ scrollLeftInner = innerDocBody.scrollLeft;
700
+ scrollTopOuter = padOuter.scrollTop();
701
+ scrollLeftOuter = padOuter.scrollLeft();
702
+ } catch (e) {
703
+ console.error('[ep_images_extended mousemove] Error getting container rects/scrolls:', e);
704
+ return;
705
+ }
706
+
707
+ const clickTopRelInner = mousedownClientY - innerBodyRect.top + scrollTopInner;
708
+ const clickLeftRelInner = mousedownClientX - innerBodyRect.left + scrollLeftInner;
709
+ const innerFrameTopRelOuter = innerIframeRect.top - outerBodyRect.top + scrollTopOuter;
710
+ const innerFrameLeftRelOuter = innerIframeRect.left - outerBodyRect.left + scrollLeftOuter;
711
+ const baseClickTopOuter = innerFrameTopRelOuter + clickTopRelInner;
712
+ const baseClickLeftOuter = innerFrameLeftRelOuter + clickLeftRelInner;
713
+ let outlineTop = baseClickTopOuter;
714
+ let outlineLeft = baseClickLeftOuter;
715
+
716
+ // For bottom-right handle, position outline at top-left of image
717
+ if (clickedHandle === 'br') {
718
+ outlineLeft -= currentWidth;
719
+ outlineTop -= currentHeight;
720
+ }
721
+
722
+ const outerPadding = window.getComputedStyle(padOuter[0]);
723
+ const outerPaddingTop = parseFloat(outerPadding.paddingTop) || 0;
724
+ const outerPaddingLeft = parseFloat(outerPadding.paddingLeft) || 0;
725
+ const finalOutlineTop = outlineTop + outerPaddingTop;
726
+ const finalOutlineLeft = outlineLeft + outerPaddingLeft;
727
+ const MANUAL_OFFSET_TOP = 9;
728
+ const MANUAL_OFFSET_LEFT = 42;
729
+ const finalTopWithManualOffset = finalOutlineTop + MANUAL_OFFSET_TOP;
730
+ const finalLeftWithManualOffset = finalOutlineLeft + MANUAL_OFFSET_LEFT;
731
+
732
+ $outlineBoxRef.css({
733
+ left: finalLeftWithManualOffset + 'px',
734
+ top: finalTopWithManualOffset + 'px',
735
+ width: currentWidth + 'px',
736
+ height: currentHeight + 'px',
737
+ display: 'block'
738
+ });
739
+ outlineBoxPositioned = true;
740
+ }
741
+
742
+ if ($outlineBoxRef && $outlineBoxRef.length > 0) {
743
+ const currentX = evt.clientX;
744
+ const deltaX = currentX - startX;
745
+ let newPixelWidth = startWidth + deltaX;
746
+
747
+ if (targetOuterSpan) {
748
+ const $tableCell = $(targetOuterSpan).closest('td, th');
749
+ if ($tableCell.length > 0) {
750
+ const parentWidth = $tableCell.width();
751
+ if (parentWidth > 0) {
752
+ newPixelWidth = Math.min(newPixelWidth, parentWidth);
753
+ }
754
+ }
755
+ }
756
+ newPixelWidth = Math.max(20, newPixelWidth);
757
+ const newPixelHeight = newPixelWidth * currentVisualAspectRatioHW;
758
+ $outlineBoxRef.css({
759
+ width: newPixelWidth + 'px',
760
+ height: newPixelHeight + 'px'
761
+ });
762
+ } else {
763
+ console.error('[ep_images_extended mousemove] Outline box ref missing or invalid during size update!');
764
+ }
765
+ $inner.css('cursor', 'nw-resize');
766
+ }
767
+ });
768
+
769
+ innerDoc.on('mouseup', function(evt) {
770
+ if (isDragging) {
771
+ const finalX = evt.clientX;
772
+ const deltaX = finalX - startX;
773
+ let finalPixelWidth = startWidth + deltaX;
774
+
775
+ if (targetOuterSpan) {
776
+ const $tableCell = $(targetOuterSpan).closest('td, th');
777
+ if ($tableCell.length > 0) {
778
+ const parentWidth = $tableCell.width();
779
+ if (parentWidth > 0) {
780
+ finalPixelWidth = Math.min(finalPixelWidth, parentWidth);
781
+ }
782
+ }
783
+ }
784
+
785
+ finalPixelWidth = Math.max(20, Math.round(finalPixelWidth));
786
+ const widthToApply = `${finalPixelWidth}px`;
787
+ const finalPixelHeight = Math.round(finalPixelWidth * currentVisualAspectRatioHW);
788
+ const heightToApplyPx = `${finalPixelHeight}px`;
789
+ const newCssAspectRatioForVar = (startWidth > 0 && startHeight > 0) ? (startWidth / startHeight).toFixed(4) : '1';
790
+
791
+ // Don't apply styles directly to avoid triggering content collector
792
+ // The visual updates will be handled by acePostWriteDomLineHTML after attributes are applied
793
+ console.log('[ep_images_extended mouseup] Skipping direct style application to avoid content collection triggers');
794
+
795
+ _aceContext.callWithAce((ace) => {
796
+ const outerSpanAlive = (targetOuterSpan && document.contains(targetOuterSpan)) ? targetOuterSpan : null;
797
+ let workingOuterSpan = outerSpanAlive;
798
+
799
+ // Fallback: locate by active image id if our stored element vanished
800
+ if (!workingOuterSpan && window.epImageInsertActiveImageId) {
801
+ workingOuterSpan = $inner.find(`.inline-image.image-placeholder[data-image-id="${window.epImageInsertActiveImageId}"]`)[0];
802
+ }
803
+
804
+ const placeholderRange = getPlaceholderRangeFromOuterSpan(workingOuterSpan, ace, {wholePlaceholder: true});
805
+ if (!placeholderRange) {
806
+ console.error('[ep_images_extended mouseup] Could not determine placeholder range for resize.');
807
+ return;
808
+ }
809
+
810
+ if (!validateAceOperation(ace, 'applyAttributes', placeholderRange[0], placeholderRange[1], 'mouseup resize')) {
811
+ return;
812
+ }
813
+
814
+ try {
815
+ ace.ace_performDocumentApplyAttributesToRange(placeholderRange[0], placeholderRange[1], [
816
+ ['image-width', widthToApply],
817
+ ['image-height', heightToApplyPx],
818
+ ['imageCssAspectRatio', newCssAspectRatioForVar]
819
+ ]);
820
+ console.log('[ep_images_extended mouseup] Successfully applied resize attributes (new targeting)');
821
+ } catch (err) {
822
+ console.error('[ep_images_extended mouseup] Error applying resize attributes:', err);
823
+ }
824
+ }, 'applyImageAttributes', true);
825
+
826
+ // Reset dragging state and clean up
827
+ isDragging = false;
828
+ outlineBoxPositioned = false;
829
+ clickedHandle = null;
830
+ $outlineBoxRef.hide();
831
+ $inner.css('cursor', 'auto');
832
+
833
+ // Keep the image selected after resizing - don't clear selection
834
+ // Clean up resize position data
835
+ resizePositionData = null;
836
+
837
+ // Reset target references
838
+ targetOuterSpan = null;
839
+ targetInnerSpan = null;
840
+ targetLineNumber = -1;
841
+ }
842
+ // Note: We don't clear selection here for simple clicks - only the click-outside handler does that
843
+ });
844
+
845
+ $inner.on('paste', function(evt) {
846
+ const clipboardData = evt.originalEvent.clipboardData || window.clipboardData;
847
+ if (!clipboardData) return;
848
+ let foundImage = false;
849
+ for (let i = 0; i < clipboardData.items.length; i++) {
850
+ const item = clipboardData.items[i];
851
+ if (item.kind === 'file' && item.type.startsWith('image/')) {
852
+ const file = item.getAsFile();
853
+ foundImage = true;
854
+ let isValid = true;
855
+ const errorTitle = html10n.get('ep_images_extended.error.title');
856
+ if (clientVars.ep_images_extended && clientVars.ep_images_extended.fileTypes) {
857
+ const mimedb = clientVars.ep_images_extended.mimeTypes;
858
+ const mimeTypeInfo = mimedb[file.type];
859
+ let validMime = false;
860
+ if (mimeTypeInfo && mimeTypeInfo.extensions) {
861
+ for (const fileType of clientVars.ep_images_extended.fileTypes) {
862
+ if (mimeTypeInfo.extensions.includes(fileType)) {
863
+ validMime = true;
864
+ break;
865
+ }
866
+ }
867
+ }
868
+ if (!validMime) {
869
+ const errorMessage = html10n.get('ep_images_extended.error.fileType');
870
+ $.gritter.add({ title: errorTitle, text: errorMessage, sticky: true, class_name: 'error' });
871
+ isValid = false;
872
+ }
873
+ }
874
+ if (isValid && clientVars.ep_images_extended && file.size > clientVars.ep_images_extended.maxFileSize) {
875
+ const allowedSize = (clientVars.ep_images_extended.maxFileSize / 1000000);
876
+ const errorText = html10n.get('ep_images_extended.error.fileSize', { maxallowed: allowedSize });
877
+ $.gritter.add({ title: errorTitle, text: errorText, sticky: true, class_name: 'error' });
878
+ isValid = false;
879
+ }
880
+ if (isValid) {
881
+ evt.preventDefault();
882
+
883
+ // Determine storage strategy (default base64)
884
+ const storageType = (clientVars && clientVars.ep_images_extended && clientVars.ep_images_extended.storageType) || 'base64';
885
+
886
+ // Global cache to avoid re-uploading the same blob within a pad session
887
+ window.epImageInsertUploadCache = window.epImageInsertUploadCache || {};
888
+
889
+ // Helper to actually insert an <img> (via ace_doInsertImage)
890
+ const insertIntoPad = (src, widthPx = null, heightPx = null) => {
891
+ _aceContext.callWithAce((ace) => {
892
+ ace.ace_doInsertImage(src, widthPx, heightPx);
893
+ }, 'pasteImage', true);
894
+ };
895
+
896
+ // Fallback helper: convert blob to base64 and insert
897
+ const insertAsDataUrl = (blob) => {
898
+ const readerB64 = new FileReader();
899
+ readerB64.onload = (e_reader) => {
900
+ const dataUrl = e_reader.target.result;
901
+ const probeImg = new Image();
902
+ probeImg.onload = () => insertIntoPad(dataUrl, `${probeImg.naturalWidth}px`, `${probeImg.naturalHeight}px`);
903
+ probeImg.onerror = () => insertIntoPad(dataUrl);
904
+ probeImg.src = dataUrl;
905
+ };
906
+ readerB64.onerror = (e_reader) => {
907
+ console.error('[ep_images_extended paste] FileReader error:', e_reader);
908
+ $.gritter.add({ title: errorTitle, text: 'Error reading pasted image file.', sticky: true, class_name: 'error' });
909
+ };
910
+ readerB64.readAsDataURL(blob);
911
+ };
912
+
913
+ if (storageType === 'base64') {
914
+ // Original behaviour retained
915
+ insertAsDataUrl(file);
916
+ } else if (storageType === 's3_presigned') {
917
+ // Upload directly to S3 (or reuse if already uploaded)
918
+ (async () => {
919
+ try {
920
+ // Compute SHA-256 hash for deduplication
921
+ const arrayBuf = await file.arrayBuffer();
922
+ const hashBuf = await crypto.subtle.digest('SHA-256', arrayBuf);
923
+ const hashHex = Array.from(new Uint8Array(hashBuf)).map(b => b.toString(16).padStart(2, '0')).join('');
924
+
925
+ if (window.epImageInsertUploadCache[hashHex]) {
926
+ const cachedUrl = window.epImageInsertUploadCache[hashHex];
927
+ const probeImg = new Image();
928
+ probeImg.onload = () => insertIntoPad(cachedUrl, `${probeImg.naturalWidth}px`, `${probeImg.naturalHeight}px`);
929
+ probeImg.onerror = () => insertIntoPad(cachedUrl);
930
+ probeImg.src = cachedUrl;
931
+ return;
932
+ }
933
+
934
+ const queryParams = $.param({ name: file.name || `${hashHex}.png`, type: file.type });
935
+ const presignData = await $.getJSON(`${clientVars.padId}/pluginfw/ep_images_extended/s3_presign?${queryParams}`);
936
+ if (!presignData || !presignData.signedUrl || !presignData.publicUrl) {
937
+ throw new Error('Invalid presign response');
938
+ }
939
+
940
+ const uploadResp = await fetch(presignData.signedUrl, {
941
+ method: 'PUT',
942
+ headers: { 'Content-Type': file.type },
943
+ body: file,
944
+ });
945
+ if (!uploadResp.ok) throw new Error(`S3 upload failed with status ${uploadResp.status}`);
946
+
947
+ const publicUrl = presignData.publicUrl;
948
+ window.epImageInsertUploadCache[hashHex] = publicUrl;
949
+
950
+ const probeImg = new Image();
951
+ probeImg.onload = () => insertIntoPad(publicUrl, `${probeImg.naturalWidth}px`, `${probeImg.naturalHeight}px`);
952
+ probeImg.onerror = () => insertIntoPad(publicUrl);
953
+ probeImg.src = publicUrl;
954
+ } catch (err) {
955
+ console.error('[ep_images_extended paste] S3 upload failed, falling back to base64:', err);
956
+ insertAsDataUrl(file);
957
+ }
958
+ })();
959
+ } else {
960
+ // Generic server upload (local disk etc.)
961
+ (async () => {
962
+ try {
963
+ const formData = new FormData();
964
+ formData.append('file', file, file.name);
965
+ const uploadUrl = await $.ajax({
966
+ type: 'POST',
967
+ url: `${clientVars.padId}/pluginfw/ep_images_extended/upload`,
968
+ data: formData,
969
+ cache: false,
970
+ contentType: false,
971
+ processData: false,
972
+ timeout: 60000,
973
+ });
974
+
975
+ const probeImg = new Image();
976
+ probeImg.onload = () => insertIntoPad(uploadUrl, `${probeImg.naturalWidth}px`, `${probeImg.naturalHeight}px`);
977
+ probeImg.onerror = () => insertIntoPad(uploadUrl);
978
+ probeImg.src = uploadUrl;
979
+ } catch (err) {
980
+ console.error('[ep_images_extended paste] Server upload failed, falling back to base64:', err);
981
+ insertAsDataUrl(file);
982
+ }
983
+ })();
984
+ }
985
+ }
986
+ break;
987
+ }
988
+ }
989
+ // if (foundImage) { /* handled by preventDefault */ }
990
+ // else { /* Allow default paste for non-image content */ }
991
+ });
992
+
993
+ // Handle clicking outside images to deselect them
994
+ $(innerDoc).on('mousedown', function(evt) {
995
+ if (!$(evt.target).closest('.inline-image.image-placeholder').length) {
996
+
997
+ if (window.epImageInsertActiveImageId) {
998
+ const previouslyActiveId = window.epImageInsertActiveImageId;
999
+ window.epImageInsertActiveImageId = null;
1000
+ // Remove dynamic CSS selection
1001
+ const innerDoc = $inner[0].ownerDocument;
1002
+ const existingStyle = innerDoc.getElementById('ep-image-selection-style');
1003
+ if (existingStyle) {
1004
+ existingStyle.remove();
1005
+ }
1006
+ // Clean up any pending resize position data
1007
+ resizePositionData = null;
1008
+ // _aceContext.callWithAce((ace) => {
1009
+ // ace.ace_callRepaint(); // Repaint might not be enough, direct class removal is better.
1010
+ // }, 'repaintAfterDeselect', true);
1011
+ }
1012
+ hideFormatMenu();
1013
+ }
1014
+ });
1015
+
1016
+ // Handle keyboard events for deselecting images (e.g., Escape key)
1017
+ $(innerDoc).on('keydown', function(evt) {
1018
+ if (evt.key === 'Escape') {
1019
+ if (window.epImageInsertActiveImageId) {
1020
+ const previouslyActiveId = window.epImageInsertActiveImageId;
1021
+ window.epImageInsertActiveImageId = null;
1022
+ // Remove dynamic CSS selection
1023
+ const innerDoc = $inner[0].ownerDocument;
1024
+ const existingStyle = innerDoc.getElementById('ep-image-selection-style');
1025
+ if (existingStyle) {
1026
+ existingStyle.remove();
1027
+ }
1028
+ // Clean up any pending resize position data
1029
+ resizePositionData = null;
1030
+ // _aceContext.callWithAce((ace) => {
1031
+ // ace.ace_callRepaint();
1032
+ // }, 'repaintAfterDeselectEscape', true);
1033
+ }
1034
+ hideFormatMenu();
1035
+ }
1036
+ });
1037
+
1038
+ // Handle clicking outside the format menu to hide it
1039
+ $(document).on('mousedown', function(evt) {
1040
+ if (!$(evt.target).closest('#imageFormatMenu').length &&
1041
+ !$(evt.target).closest('.inline-image.image-placeholder').length) {
1042
+ hideFormatMenu();
1043
+ }
1044
+ });
1045
+
1046
+ // Function to show user feedback for copy/cut operations
1047
+ const showCopyFeedback = (message) => {
1048
+ console.log(`[ep_images_extended] ${message}`);
1049
+ // Could be enhanced with a temporary toast notification
1050
+ };
1051
+
1052
+ // Function to handle image copy/cut operations
1053
+ const handleImageCopy = async (shouldCut) => {
1054
+ try {
1055
+ let currentElement = null;
1056
+ if (window.epImageInsertActiveImageId) {
1057
+ currentElement = $inner.find(`.inline-image.image-placeholder[data-image-id="${window.epImageInsertActiveImageId}"]`)[0];
1058
+ }
1059
+
1060
+ if (!currentElement) {
1061
+ console.error('[ep_images_extended copy] No image selected or active image ID not found in DOM.');
1062
+ return;
1063
+ }
1064
+
1065
+ // Get the image source from the selected element
1066
+ const classes = currentElement.className.split(' ');
1067
+ let imageSrc = null;
1068
+ for (const cls of classes) {
1069
+ if (cls.startsWith('image:')) {
1070
+ imageSrc = decodeURIComponent(cls.substring(6));
1071
+ break;
1072
+ }
1073
+ }
1074
+
1075
+ if (!imageSrc) {
1076
+ console.error('[ep_images_extended copy] Could not find image source');
1077
+ return;
1078
+ }
1079
+
1080
+ // Check if we have clipboard API support
1081
+ if (!navigator.clipboard || !window.ClipboardItem) {
1082
+ console.error('[ep_images_extended copy] Clipboard API not supported');
1083
+ // Fallback: copy the image src as text
1084
+ try {
1085
+ await navigator.clipboard.writeText(imageSrc);
1086
+ showCopyFeedback(shouldCut ? 'Image URL cut to clipboard (text fallback)' : 'Image URL copied to clipboard (text fallback)');
1087
+ } catch (e) {
1088
+ console.error('[ep_images_extended copy] Fallback text copy failed:', e);
1089
+ }
1090
+ return;
1091
+ }
1092
+
1093
+ // Helper function to convert image to PNG blob
1094
+ const convertImageToPngBlob = async (imageSrc) => {
1095
+ return new Promise((resolve, reject) => {
1096
+ const img = new Image();
1097
+ img.crossOrigin = 'anonymous'; // Handle CORS if needed
1098
+
1099
+ img.onload = () => {
1100
+ try {
1101
+ // Create a canvas to convert the image to PNG
1102
+ const canvas = document.createElement('canvas');
1103
+ const ctx = canvas.getContext('2d');
1104
+
1105
+ canvas.width = img.naturalWidth;
1106
+ canvas.height = img.naturalHeight;
1107
+
1108
+ // Draw the image onto the canvas
1109
+ ctx.drawImage(img, 0, 0);
1110
+
1111
+ // Convert canvas to PNG blob
1112
+ canvas.toBlob((blob) => {
1113
+ if (blob) {
1114
+ resolve(blob);
1115
+ } else {
1116
+ reject(new Error('Failed to convert image to PNG blob'));
1117
+ }
1118
+ }, 'image/png');
1119
+ } catch (error) {
1120
+ reject(error);
1121
+ }
1122
+ };
1123
+
1124
+ img.onerror = () => {
1125
+ reject(new Error('Failed to load image for conversion'));
1126
+ };
1127
+
1128
+ img.src = imageSrc;
1129
+ });
1130
+ };
1131
+
1132
+ try {
1133
+ let blob;
1134
+
1135
+ // For data URLs, convert to PNG regardless of original format
1136
+ if (imageSrc.startsWith('data:')) {
1137
+ blob = await convertImageToPngBlob(imageSrc);
1138
+ }
1139
+ // For HTTP URLs, fetch and convert to PNG
1140
+ else if (imageSrc.startsWith('http')) {
1141
+ // First try to fetch the image
1142
+ const response = await fetch(imageSrc, { mode: 'cors' });
1143
+ if (!response.ok) {
1144
+ throw new Error(`HTTP ${response.status}`);
1145
+ }
1146
+
1147
+ // Convert the fetched image to a data URL, then to PNG
1148
+ const originalBlob = await response.blob();
1149
+ const dataUrl = await new Promise((resolve) => {
1150
+ const reader = new FileReader();
1151
+ reader.onload = () => resolve(reader.result);
1152
+ reader.readAsDataURL(originalBlob);
1153
+ });
1154
+
1155
+ blob = await convertImageToPngBlob(dataUrl);
1156
+ }
1157
+ // For relative URLs or other formats, try to convert to PNG
1158
+ else {
1159
+ blob = await convertImageToPngBlob(imageSrc);
1160
+ }
1161
+
1162
+ // Create ClipboardItem with the PNG blob
1163
+ const clipboardItem = new ClipboardItem({
1164
+ 'image/png': blob
1165
+ });
1166
+
1167
+ // Write to clipboard
1168
+ await navigator.clipboard.write([clipboardItem]);
1169
+ showCopyFeedback(shouldCut ? 'Image cut to clipboard' : 'Image copied to clipboard');
1170
+
1171
+ } catch (e) {
1172
+ console.error('[ep_images_extended copy] Failed to copy image as PNG:', e);
1173
+ // Fallback to text copy
1174
+ try {
1175
+ await navigator.clipboard.writeText(imageSrc);
1176
+ showCopyFeedback(shouldCut ? 'Image URL cut to clipboard (fallback)' : 'Image URL copied to clipboard (fallback)');
1177
+ } catch (textError) {
1178
+ console.error('[ep_images_extended copy] Text fallback also failed:', textError);
1179
+ showCopyFeedback('Copy operation failed');
1180
+ }
1181
+ }
1182
+
1183
+ // If this is a cut operation, delete the image after successful copy
1184
+ if (shouldCut) {
1185
+ // Reuse the existing delete logic
1186
+ const outerSpan = currentElement;
1187
+ const lineElement = $(outerSpan).closest('.ace-line')[0];
1188
+ if (lineElement) {
1189
+ const allImagePlaceholdersInLine = Array.from(lineElement.querySelectorAll('.inline-image.image-placeholder'));
1190
+ const imageIndex = allImagePlaceholdersInLine.indexOf(outerSpan);
1191
+
1192
+ if (imageIndex !== -1) {
1193
+ const targetLineNumber = _getLineNumberOfElement(lineElement);
1194
+
1195
+ _aceContext.callWithAce((ace) => {
1196
+ const rep = ace.ace_getRep();
1197
+ if (!rep.lines.atIndex(targetLineNumber)) {
1198
+ console.error(`[ep_images_extended cut] Line ${targetLineNumber} does not exist in rep.`);
1199
+ return;
1200
+ }
1201
+
1202
+ const lineText = rep.lines.atIndex(targetLineNumber).text;
1203
+
1204
+ // Use consolidated helper to build placeholder range
1205
+ const placeholderRange = getPlaceholderRangeFromOuterSpan(outerSpan, ace, {wholePlaceholder: true});
1206
+
1207
+ if (placeholderRange) {
1208
+ const rangeStart = placeholderRange[0];
1209
+ const rangeEnd = placeholderRange[1];
1210
+
1211
+ try {
1212
+ // Use helper function to validate ace operation
1213
+ if (!validateAceOperation(ace, 'replaceRange', rangeStart, rangeEnd, 'cut')) {
1214
+ return;
1215
+ }
1216
+
1217
+ // Delete the image by replacing the text range with empty string
1218
+ ace.ace_replaceRange(rangeStart, rangeEnd, '');
1219
+
1220
+ console.log('Successfully cut image at line', targetLineNumber, 'column', placeholderInfo.colStart);
1221
+
1222
+ } catch (error) {
1223
+ console.error('[ep_images_extended cut] Error deleting image:', error);
1224
+ console.error('[ep_images_extended cut] Range was:', [rangeStart, rangeEnd]);
1225
+ }
1226
+ } else {
1227
+ console.error('[ep_images_extended cut] Could not find placeholder sequence in line text');
1228
+ }
1229
+ }, 'cutImage', true);
1230
+
1231
+ // Clear selection state since image will be deleted
1232
+ if (window.epImageInsertActiveImageId && $(outerSpan).attr('data-image-id') === window.epImageInsertActiveImageId) {
1233
+ window.epImageInsertActiveImageId = null;
1234
+ }
1235
+
1236
+ hideFormatMenu();
1237
+ }
1238
+ }
1239
+ }
1240
+
1241
+ } catch (error) {
1242
+ console.error('[ep_images_extended copy] Clipboard operation failed:', error);
1243
+ showCopyFeedback('Copy operation failed');
1244
+ }
1245
+ };
1246
+
1247
+ // Handle format menu button clicks
1248
+ $formatMenuRef.on('click', '.image-format-button', function(evt) {
1249
+ evt.preventDefault();
1250
+ evt.stopPropagation();
1251
+
1252
+ const $button = $(this);
1253
+ const wrapType = $button.data('wrap');
1254
+ const action = $button.data('action');
1255
+
1256
+ let currentElement = null;
1257
+ if (window.epImageInsertActiveImageId) {
1258
+ currentElement = $inner.find(`.inline-image.image-placeholder[data-image-id="${window.epImageInsertActiveImageId}"]`)[0];
1259
+ }
1260
+
1261
+ if (!currentElement) {
1262
+ console.warn('[ep_images_extended formatMenu] No active image found for action.');
1263
+ return;
1264
+ }
1265
+
1266
+ if (wrapType) {
1267
+ // Handle wrap type buttons
1268
+ $formatMenuRef.find('.image-format-button[data-wrap]').removeClass('active');
1269
+ $button.addClass('active');
1270
+
1271
+ // Use the specific selected image element (like resize logic does)
1272
+ // Check both the local reference and the global current reference (for DOM regeneration)
1273
+ if (currentElement) {
1274
+ const outerSpan = currentElement;
1275
+
1276
+ // Determine the float value to store as attribute
1277
+ let floatValue;
1278
+ switch (wrapType) {
1279
+ case 'inline':
1280
+ floatValue = 'none';
1281
+ break;
1282
+ case 'left':
1283
+ floatValue = 'left';
1284
+ break;
1285
+ case 'right':
1286
+ floatValue = 'right';
1287
+ break;
1288
+ default:
1289
+ floatValue = 'none';
1290
+ }
1291
+
1292
+ // Apply the attribute using the same method as resize functionality
1293
+ const lineElement = $(outerSpan).closest('.ace-line')[0];
1294
+ if (lineElement) {
1295
+ const allImagePlaceholdersInLine = Array.from(lineElement.querySelectorAll('.inline-image.image-placeholder'));
1296
+ const imageIndex = allImagePlaceholdersInLine.indexOf(outerSpan);
1297
+
1298
+ if (imageIndex !== -1) {
1299
+ const targetLineNumber = _getLineNumberOfElement(lineElement);
1300
+
1301
+ _aceContext.callWithAce((ace) => {
1302
+ const rep = ace.ace_getRep();
1303
+ if (!rep.lines.atIndex(targetLineNumber)) {
1304
+ console.error(`[ep_images_extended float] Line ${targetLineNumber} does not exist in rep.`);
1305
+ return;
1306
+ }
1307
+
1308
+ const lineText = rep.lines.atIndex(targetLineNumber).text;
1309
+
1310
+ // Use consolidated helper to build placeholder range
1311
+ const placeholderRange = getPlaceholderRangeFromOuterSpan(outerSpan, ace, {wholePlaceholder: true});
1312
+
1313
+ if (placeholderRange) {
1314
+ const rangeStart = placeholderRange[0];
1315
+ const rangeEnd = placeholderRange[1];
1316
+
1317
+ try {
1318
+ // Use helper function to validate ace operation
1319
+ if (!validateAceOperation(ace, 'applyAttributes', rangeStart, rangeEnd, 'float')) {
1320
+ return;
1321
+ }
1322
+
1323
+ ace.ace_performDocumentApplyAttributesToRange(rangeStart, rangeEnd, [
1324
+ ['image-float', floatValue]
1325
+ ]);
1326
+
1327
+ console.log('Applied float attribute:', floatValue, 'to image');
1328
+
1329
+ } catch (error) {
1330
+ console.error('[ep_images_extended float] Error applying float attribute:', error);
1331
+ console.error('[ep_images_extended float] Range was:', [rangeStart, rangeEnd]);
1332
+ console.error('[ep_images_extended float] Float value was:', floatValue);
1333
+ }
1334
+ } else {
1335
+ console.error('[ep_images_extended float] Could not find placeholder sequence in line text');
1336
+ }
1337
+ }, 'applyImageFloatAttribute', true);
1338
+ }
1339
+ }
1340
+ }
1341
+ } else if (action) {
1342
+ // Handle action buttons
1343
+ if (action === 'copy') {
1344
+ console.log('Copy image action triggered');
1345
+ handleImageCopy(false); // false = copy (don't delete after copy)
1346
+ } else if (action === 'cut') {
1347
+ console.log('Cut image action triggered');
1348
+ handleImageCopy(true); // true = cut (delete after copy)
1349
+ } else if (action === 'delete') {
1350
+ console.log('Delete image action triggered');
1351
+
1352
+ // Use the specific selected image element (like float and resize logic does)
1353
+ if (currentElement) {
1354
+ const outerSpan = currentElement;
1355
+ _aceContext.callWithAce((ace) => {
1356
+ const placeholderRange = getPlaceholderRangeFromOuterSpan(outerSpan, ace, {wholePlaceholder: true});
1357
+ if (!placeholderRange) {
1358
+ console.error('[ep_images_extended delete] Could not locate placeholder range for deletion.');
1359
+ return;
1360
+ }
1361
+
1362
+ const [rangeStart, rangeEnd] = placeholderRange;
1363
+
1364
+ if (!validateAceOperation(ace, 'replaceRange', rangeStart, rangeEnd, 'delete')) {
1365
+ return;
1366
+ }
1367
+
1368
+ try {
1369
+ ace.ace_replaceRange(rangeStart, rangeEnd, '');
1370
+ console.log('[ep_images_extended delete] Successfully deleted image via helper');
1371
+ } catch (err) {
1372
+ console.error('[ep_images_extended delete] Error deleting image:', err);
1373
+ }
1374
+ }, 'deleteImage', true);
1375
+ }
1376
+
1377
+ hideFormatMenu();
1378
+ }
1379
+ }
1380
+ });
1381
+ }, 'image_resize_listeners', true);
1382
+ };
1383
+
1384
+ function _getLineNumberOfElement(element) {
1385
+ let currentElement = element;
1386
+ let count = 0;
1387
+ while (currentElement = currentElement.previousElementSibling) {
1388
+ count++;
1389
+ }
1390
+ return count;
1391
+ }
1392
+
1393
+ exports.aceEditorCSS = (hookName, context) => {
1394
+ console.log('[ep_images_extended] aceEditorCSS called - loading CSS file');
1395
+ return [
1396
+ 'ep_images_extended/static/css/ace.css'
1397
+ ];
1398
+ };
1399
+
1400
+ exports.aceRegisterBlockElements = () => ['img'];
1401
+
1402
+ exports.aceCreateDomLine = (hookName, args, cb) => {
1403
+ if (args.cls && args.cls.indexOf('image:') >= 0) {
1404
+ const clss = [];
1405
+ let imageId = null;
1406
+ const argClss = args.cls.split(' ');
1407
+ for (let i = 0; i < argClss.length; i++) {
1408
+ const cls = argClss[i];
1409
+ clss.push(cls);
1410
+ if (cls.startsWith('image-id-')) {
1411
+ imageId = cls.substring(9);
1412
+ }
1413
+ }
1414
+ clss.push('inline-image', 'character', 'image-placeholder');
1415
+ const handleHtml =
1416
+ '<span class="image-resize-handle br" contenteditable="false"></span>';
1417
+
1418
+ // The 'cls' in the modifier will be applied to the *outermost* span ACE creates for the line segment.
1419
+ // We will add data-image-id to this span in acePostWriteDomLineHTML.
1420
+ const modifier = {
1421
+ extraOpenTags: `<span class="image-inner"></span>${handleHtml}`,
1422
+ extraCloseTags: '',
1423
+ cls: clss.join(' '),
1424
+ };
1425
+ return cb([modifier]);
1426
+ } else {
1427
+ return cb();
1428
+ }
1429
+ };
1430
+
1431
+ const Changeset = require('ep_etherpad-lite/static/js/Changeset');
1432
+ exports.acePostWriteDomLineHTML = (hookName, context) => {
1433
+ const lineNode = context.node;
1434
+ if (!lineNode) return;
1435
+
1436
+ const placeholders = lineNode.querySelectorAll('span.image-placeholder');
1437
+ placeholders.forEach((placeholder, index) => {
1438
+ const outerSpan = placeholder;
1439
+ const innerSpan = outerSpan.querySelector('span.image-inner');
1440
+ if (!innerSpan) return;
1441
+
1442
+ // Ensure both outer and inner spans are non-editable so cursor keys cannot land inside
1443
+ if (!innerSpan.hasAttribute('contenteditable')) {
1444
+ innerSpan.setAttribute('contenteditable', 'false');
1445
+ }
1446
+ if (!outerSpan.hasAttribute('contenteditable')) {
1447
+ outerSpan.setAttribute('contenteditable', 'false');
1448
+ }
1449
+
1450
+ let escapedSrc = null;
1451
+ let imageWidth = null;
1452
+ let imageCssAspectRatioVal = null;
1453
+ let imageFloatVal = null;
1454
+ let imageIdFromClass = null;
1455
+
1456
+ const classes = outerSpan.className.split(' ');
1457
+ for (const cls of classes) {
1458
+ if (cls.startsWith('image:')) {
1459
+ escapedSrc = cls.substring(6);
1460
+ } else if (cls.startsWith('image-width:')) {
1461
+ const widthValue = cls.substring(12);
1462
+ if (/\d+px$/.test(widthValue)) {
1463
+ imageWidth = widthValue;
1464
+ }
1465
+ } else if (cls.startsWith('imageCssAspectRatio:')) {
1466
+ imageCssAspectRatioVal = cls.substring(20);
1467
+ } else if (cls.startsWith('image-float:')) {
1468
+ imageFloatVal = cls.substring(12);
1469
+ } else if (cls.startsWith('image-id-')) {
1470
+ imageIdFromClass = cls.substring(9);
1471
+ }
1472
+ }
1473
+
1474
+ if (imageIdFromClass) {
1475
+ outerSpan.setAttribute('data-image-id', imageIdFromClass);
1476
+ } else {
1477
+ // If it's an old image without an id class from a previous version, try to remove data-image-id if it exists
1478
+ if (outerSpan.hasAttribute('data-image-id')) {
1479
+ outerSpan.removeAttribute('data-image-id');
1480
+ }
1481
+ }
1482
+
1483
+ const currentDataImageId = outerSpan.getAttribute('data-image-id');
1484
+
1485
+ // acePostWriteDomLineHTML is part of the rendering pipeline, so inline styles should be safe here
1486
+ if (imageWidth) {
1487
+ innerSpan.style.width = imageWidth;
1488
+ } // else { /* Optional: Apply a default width? */ }
1489
+
1490
+ if (imageCssAspectRatioVal) {
1491
+ innerSpan.style.setProperty('--image-css-aspect-ratio', imageCssAspectRatioVal);
1492
+ } else {
1493
+ innerSpan.style.setProperty('--image-css-aspect-ratio', '1');
1494
+ }
1495
+ innerSpan.style.removeProperty('height');
1496
+
1497
+ if (escapedSrc) {
1498
+ try {
1499
+ const src = decodeURIComponent(escapedSrc);
1500
+ if (src && (src.startsWith('data:') || src.startsWith('http') || src.startsWith('/'))) {
1501
+ innerSpan.style.setProperty('--image-src', `url("${src}")`);
1502
+ } // else { /* Invalid unescaped src warning removed */ }
1503
+ } catch (e) {
1504
+ console.error(`[ep_images_extended acePostWriteDomLineHTML] Error setting CSS var for placeholder #${index}:`, e);
1505
+ }
1506
+ } // else { /* Placeholder found, but no image:* class warning removed */ }
1507
+
1508
+ // Apply float style classes based on the attribute
1509
+ outerSpan.classList.remove('image-float-left', 'image-float-right', 'image-float-none');
1510
+ if (imageFloatVal) {
1511
+ switch (imageFloatVal) {
1512
+ case 'left':
1513
+ outerSpan.classList.add('image-float-left');
1514
+ break;
1515
+ case 'right':
1516
+ outerSpan.classList.add('image-float-right');
1517
+ break;
1518
+ case 'none':
1519
+ case 'inline':
1520
+ outerSpan.classList.add('image-float-none');
1521
+ break;
1522
+ }
1523
+ } else {
1524
+ // Default to inline/none if no float attribute
1525
+ outerSpan.classList.add('image-float-none');
1526
+ }
1527
+
1528
+ // Selection styling is now handled purely via CSS using CSS custom property
1529
+ // No DOM modifications needed here to avoid triggering content collection
1530
+
1531
+ // Remove the old data-image-unique-id if it exists from previous versions
1532
+ if (outerSpan.hasAttribute('data-image-unique-id')) {
1533
+ outerSpan.removeAttribute('data-image-unique-id');
1534
+ }
1535
+ });
1536
+ };
1537
+
1538
+ exports.aceAttribClasses = (hook, attr) => {
1539
+ return [];
1540
+ };
1541
+
1542
+ exports.collectContentPost = function(name, context) {
1543
+ const node = context.node;
1544
+ const state = context.state;
1545
+ const tname = context.tname;
1546
+
1547
+ if (tname === 'span' && node && node.classList && node.classList.contains('image-inner')) {
1548
+ const innerNode = node;
1549
+ // let widthPx = null; // Not needed to initialize here
1550
+ // let heightPx = null; // Not needed to initialize here
1551
+
1552
+ if (innerNode.style && innerNode.style.width) {
1553
+ const widthMatch = innerNode.style.width.match(/^(\d+)(?:px)?$/);
1554
+ if (widthMatch && widthMatch[1]) {
1555
+ const widthVal = parseInt(widthMatch[1], 10);
1556
+ if (!isNaN(widthVal) && widthVal > 0) {
1557
+ let widthToAttrib = `${widthVal}px`;
1558
+ if (innerNode.offsetWidth && innerNode.offsetWidth !== widthVal) {
1559
+ widthToAttrib = `${innerNode.offsetWidth}px`;
1560
+ }
1561
+ state.attribs = state.attribs || {};
1562
+ state.attribs['image-width'] = widthToAttrib;
1563
+ } // else { /* Parsed width not positive warning removed */ }
1564
+ } // else { /* Could not parse width warning removed */ }
1565
+ } // else { /* style.width missing warning removed; decision not to delete attribute if style missing */ }
1566
+
1567
+ if (innerNode.style && innerNode.style.height) {
1568
+ const heightMatch = innerNode.style.height.match(/^(\d+)(?:px)?$/);
1569
+ if (heightMatch && heightMatch[1]) {
1570
+ const heightVal = parseInt(heightMatch[1], 10);
1571
+ if (!isNaN(heightVal) && heightVal > 0) {
1572
+ state.attribs = state.attribs || {};
1573
+ state.attribs['image-height'] = `${heightVal}px`;
1574
+ } // else { /* Parsed height not positive warning removed */ }
1575
+ } // else { /* Could not parse height warning removed */ }
1576
+ } // else { /* style.height missing warning removed */ }
1577
+
1578
+ const computedStyle = window.getComputedStyle(innerNode);
1579
+ const cssAspectRatioFromVar = computedStyle.getPropertyValue('--image-css-aspect-ratio');
1580
+ if (cssAspectRatioFromVar && cssAspectRatioFromVar.trim() !== '') {
1581
+ state.attribs = state.attribs || {};
1582
+ state.attribs['imageCssAspectRatio'] = cssAspectRatioFromVar.trim();
1583
+ } else {
1584
+ if (innerNode.offsetWidth > 0 && innerNode.offsetHeight > 0) {
1585
+ const calculatedCssAspectRatio = (innerNode.offsetWidth / innerNode.offsetHeight).toFixed(4);
1586
+ state.attribs = state.attribs || {};
1587
+ state.attribs['imageCssAspectRatio'] = calculatedCssAspectRatio;
1588
+ }
1589
+ }
1590
+
1591
+ // Preserve float attribute by checking the outer span's classes
1592
+ const outerNode = innerNode.parentElement;
1593
+ if (outerNode && outerNode.classList) {
1594
+ let floatValue = null;
1595
+ if (outerNode.classList.contains('image-float-left')) {
1596
+ floatValue = 'left';
1597
+ } else if (outerNode.classList.contains('image-float-right')) {
1598
+ floatValue = 'right';
1599
+ } else if (outerNode.classList.contains('image-float-none')) {
1600
+ floatValue = 'none';
1601
+ }
1602
+
1603
+ if (floatValue) {
1604
+ state.attribs = state.attribs || {};
1605
+ state.attribs['image-float'] = floatValue;
1606
+ }
1607
+ }
1608
+
1609
+ // NEW: Preserve image-id attribute
1610
+ if (outerNode && outerNode.getAttribute('data-image-id')) {
1611
+ const imageId = outerNode.getAttribute('data-image-id');
1612
+ if (imageId) {
1613
+ state.attribs = state.attribs || {};
1614
+ state.attribs['image-id'] = imageId;
1615
+ }
1616
+ }
1617
+ }
1618
+ };
1619
+
1620
+ exports.aceKeyEvent = (hookName, context, cb) => {
1621
+ const { evt, editorInfo } = context;
1622
+ if (evt.type !== 'keydown') return cb(false);
1623
+ const key = evt.key;
1624
+
1625
+ // Only special-casing Backspace, Delete, printable characters
1626
+ if (!['Backspace', 'Delete'].includes(key) && key.length !== 1) return cb(false);
1627
+
1628
+ const ace = editorInfo;
1629
+ const rep = ace.ace_getRep();
1630
+ if (!rep || !rep.selStart || !rep.selEnd) return cb(false);
1631
+
1632
+ // Only handle collapsed selections on a single line
1633
+ if (rep.selStart[0] !== rep.selEnd[0] || rep.selStart[1] !== rep.selEnd[1]) return cb(false);
1634
+ const lineNumber = rep.selStart[0];
1635
+ let col = rep.selStart[1];
1636
+
1637
+ const lineObj = rep.lines.atIndex(lineNumber);
1638
+ if (!lineObj) return cb(false);
1639
+ const lineText = lineObj.text;
1640
+
1641
+ const placeholders = getAllPlaceholderRanges(lineText);
1642
+ if (placeholders.length === 0) return cb(false);
1643
+
1644
+ const hit = placeholders.find(r => col >= r.colStart && col <= r.colStart + r.patternLength);
1645
+ const afterHit = placeholders.find(r => col === r.colStart + r.patternLength);
1646
+ const beforeHit = placeholders.find(r => col === r.colStart);
1647
+
1648
+ // Case 1: cursor inside placeholder – move it to end of placeholder to keep typing out
1649
+ if (hit && (col > hit.colStart && col < hit.colStart + hit.patternLength)) {
1650
+ ace.ace_performSelectionChange([lineNumber, hit.colStart + hit.patternLength], [lineNumber, hit.colStart + hit.patternLength], false);
1651
+ evt.preventDefault();
1652
+ return cb(true);
1653
+ }
1654
+
1655
+ // Case 2: Backspace immediately after placeholder – delete whole placeholder
1656
+ if (key === 'Backspace' && afterHit) {
1657
+ ace.ace_replaceRange([lineNumber, afterHit.colStart], [lineNumber, afterHit.colStart + afterHit.patternLength], '');
1658
+ evt.preventDefault();
1659
+ return cb(true);
1660
+ }
1661
+
1662
+ // Case 3: Delete immediately before placeholder – delete whole placeholder
1663
+ if (key === 'Delete' && beforeHit) {
1664
+ ace.ace_replaceRange([lineNumber, beforeHit.colStart], [lineNumber, beforeHit.colStart + beforeHit.patternLength], '');
1665
+ evt.preventDefault();
1666
+ return cb(true);
1667
+ }
1668
+
1669
+ return cb(false);
1670
+ };
1671
+
1672
+ const doInsertImage = function (src, widthPx, heightPx) {
1673
+ const ZWSP = '\u200B';
1674
+ const PLACEHOLDER = '\u200B';
1675
+ const editorInfo = this.editorInfo;
1676
+ const rep = editorInfo.ace_getRep();
1677
+ const docMan = this.documentAttributeManager;
1678
+
1679
+ if (!editorInfo || !rep || !rep.selStart || !docMan || !src) {
1680
+ console.error('[ep_images_extended doInsertImage] Missing context or src');
1681
+ return;
1682
+ }
1683
+
1684
+ const cursorPos = rep.selStart;
1685
+ const insertText = ZWSP + PLACEHOLDER + ZWSP; // REMOVED trailing space ' '
1686
+
1687
+ // Insert the image placeholder text with trailing space
1688
+ editorInfo.ace_replaceRange(cursorPos, cursorPos, insertText);
1689
+
1690
+ const imageAttrStart = [cursorPos[0], cursorPos[1] + ZWSP.length];
1691
+ const imageAttrEnd = [cursorPos[0], cursorPos[1] + ZWSP.length + PLACEHOLDER.length];
1692
+ const escapedSrc = encodeURIComponent(src);
1693
+ const attributesToSet = [['image', escapedSrc]];
1694
+
1695
+ if (widthPx && /^\d+px$/.test(widthPx)) {
1696
+ attributesToSet.push(['image-width', widthPx]);
1697
+ }
1698
+ if (heightPx && /^\d+px$/.test(heightPx)) {
1699
+ attributesToSet.push(['image-height', heightPx]);
1700
+ }
1701
+ if (widthPx && heightPx) {
1702
+ const naturalWidthNum = parseInt(widthPx, 10);
1703
+ const naturalHeightNum = parseInt(heightPx, 10);
1704
+ if (naturalWidthNum > 0 && naturalHeightNum > 0) {
1705
+ const cssAspectRatio = (naturalWidthNum / naturalHeightNum).toFixed(4);
1706
+ attributesToSet.push(['imageCssAspectRatio', cssAspectRatio]);
1707
+ }
1708
+ }
1709
+
1710
+ // NEW: Add image-id attribute
1711
+ const imageId = generateUUID();
1712
+ attributesToSet.push(['image-id', imageId]);
1713
+
1714
+ // Apply the image attributes
1715
+ docMan.setAttributesOnRange(imageAttrStart, imageAttrEnd, attributesToSet);
1716
+
1717
+ // CRITICAL FIX: Move cursor after the inserted image to prevent overlapping placeholders
1718
+ // This ensures that if user inserts multiple images in sequence, they don't overlap
1719
+ const newCursorPos = [cursorPos[0], cursorPos[1] + insertText.length];
1720
+ editorInfo.ace_performSelectionChange(newCursorPos, newCursorPos, false);
1721
+ };
1722
+
1723
+ // NEW helper: build a fresh document range for an image placeholder using the DOM element.
1724
+ // Returns null if it cannot determine the range.
1725
+ function getPlaceholderRangeFromOuterSpan(outerSpan, ace, opts = {wholePlaceholder: true}) {
1726
+ try {
1727
+ if (!outerSpan) return null;
1728
+ const lineElement = $(outerSpan).closest('.ace-line')[0];
1729
+ if (!lineElement) return null;
1730
+
1731
+ const imagePlaceholders = Array.from(lineElement.querySelectorAll('.inline-image.image-placeholder'));
1732
+ const imageIndex = imagePlaceholders.indexOf(outerSpan);
1733
+ if (imageIndex === -1) return null;
1734
+
1735
+ const lineNumber = _getLineNumberOfElement(lineElement);
1736
+ const rep = ace.ace_getRep();
1737
+ if (!rep.lines.atIndex(lineNumber)) return null;
1738
+
1739
+ const lineText = rep.lines.atIndex(lineNumber).text;
1740
+ const placeholderInfo = findImagePlaceholderPosition(lineText, imageIndex, lineElement);
1741
+ if (!placeholderInfo) return null;
1742
+
1743
+ if (opts.wholePlaceholder) {
1744
+ return [
1745
+ [lineNumber, placeholderInfo.colStart],
1746
+ [lineNumber, placeholderInfo.colStart + placeholderInfo.patternLength]
1747
+ ];
1748
+ }
1749
+ // middle-character fallback
1750
+ const mid = placeholderInfo.colStart + Math.floor(placeholderInfo.patternLength / 2);
1751
+ return [[lineNumber, mid], [lineNumber, mid + 1]];
1752
+ } catch (e) {
1753
+ console.error('[ep_images_extended] getPlaceholderRangeFromOuterSpan error:', e);
1754
+ return null;
1755
+ }
1756
+ }
1757
+
1758
+ // Helper: return all placeholder ranges (startCol,length) within a line
1759
+ function getAllPlaceholderRanges(lineText) {
1760
+ const placeholderPatterns = [
1761
+ '\u200B\u200B\u200B', // 3 × ZWSP
1762
+ '\u200B\u00A0\u200B', // ZWSP NBSP ZWSP
1763
+ '\u200B\u200B', // 2 × ZWSP
1764
+ '\u00A0', // single NBSP
1765
+ '\u200B' // single ZWSP
1766
+ ].map(p => p.replace(/\\u200B/g, '\u200B').replace(/\\u00A0/g, '\u00A0')); // real chars
1767
+
1768
+ const ranges = [];
1769
+ let idx = 0;
1770
+ while (idx < lineText.length) {
1771
+ let matched = false;
1772
+ for (const pattern of placeholderPatterns) {
1773
+ if (lineText.startsWith(pattern, idx)) {
1774
+ ranges.push({colStart: idx, patternLength: pattern.length});
1775
+ idx += pattern.length;
1776
+ matched = true;
1777
+ break;
1778
+ }
1779
+ }
1780
+ if (!matched) idx += 1;
1781
+ }
1782
+ return ranges;
1783
+ }
1784
+
1785
+ /* ------------------------------------------------------------------
1786
+ * Image rendering support for the read-only time-slider view
1787
+ * ------------------------------------------------------------------ */
1788
+
1789
+ /**
1790
+ * Apply inline styles to the image placeholder `outerSpan` so that the image
1791
+ * becomes visible in read-only contexts (timeslider or export preview).
1792
+ */
1793
+ function _applyImageStylesForElement(outerSpan) {
1794
+ if (!outerSpan) return;
1795
+ const innerSpan = outerSpan.querySelector('span.image-inner');
1796
+ if (!innerSpan) return;
1797
+
1798
+ // Recover attribute values from the CSS-classes that ACE placed on the span.
1799
+ let escSrc = null, width = null, aspect = null, floatVal = null;
1800
+ for (const cls of outerSpan.className.split(' ')) {
1801
+ if (cls.startsWith('image:')) escSrc = cls.slice(6);
1802
+ if (cls.startsWith('image-width:')) width = cls.slice(12);
1803
+ if (cls.startsWith('imageCssAspectRatio:')) aspect = cls.slice(20);
1804
+ if (cls.startsWith('image-float:')) floatVal = cls.slice(12);
1805
+ }
1806
+
1807
+ // Set CSS custom properties / inline styles exactly like acePostWriteDomLineHTML.
1808
+ if (escSrc) {
1809
+ try {
1810
+ const decoded = decodeURIComponent(escSrc);
1811
+ innerSpan.style.setProperty('--image-src', `url("${decoded}")`);
1812
+ } catch (_) { /* ignore */ }
1813
+ }
1814
+ if (width) innerSpan.style.width = width;
1815
+ if (aspect) innerSpan.style.setProperty('--image-css-aspect-ratio', aspect);
1816
+
1817
+ // Float behaviour (left / right / inline)
1818
+ outerSpan.classList.remove('image-float-left', 'image-float-right', 'image-float-none');
1819
+ switch (floatVal) {
1820
+ case 'left': outerSpan.classList.add('image-float-left'); break;
1821
+ case 'right': outerSpan.classList.add('image-float-right'); break;
1822
+ default: outerSpan.classList.add('image-float-none');
1823
+ }
1824
+ }
1825
+
1826
+ /**
1827
+ * Client-side hook that runs in the time-slider once the UI is ready.
1828
+ * It ensures all image placeholders are hydrated with the correct styles and
1829
+ * repeats that every time the slider jumps to a different revision.
1830
+ */
1831
+ exports.postTimesliderInit = () => {
1832
+ // Helper that (re)applies styles to every image currently in the DOM.
1833
+ const renderAllImages = () => {
1834
+ const $placeholders = $('#innerdocbody').find('span.inline-image.image-placeholder');
1835
+ $placeholders.each((_idx, el) => _applyImageStylesForElement(el));
1836
+ };
1837
+
1838
+ // Initial render for the first revision shown.
1839
+ renderAllImages();
1840
+
1841
+ // Re-render after every slider movement (revision change).
1842
+ if (window.BroadcastSlider && typeof window.BroadcastSlider.onSlider === 'function') {
1843
+ window.BroadcastSlider.onSlider(() => {
1844
+ // Allow the DOM update from broadcast.js to finish first.
1845
+ setTimeout(renderAllImages, 0);
1846
+ });
1847
+ }
1848
+ };