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.
- package/CONTRIBUTING.md +135 -0
- package/LICENSE.md +14 -0
- package/README.md +83 -0
- package/editbar.js +10 -0
- package/ep.json +34 -0
- package/exportHTML.js +81 -0
- package/index.js +183 -0
- package/locales/en.json +6 -0
- package/package.json +30 -0
- package/settings.js +11 -0
- package/static/css/ace.css +346 -0
- package/static/css/ep_images_extended.css +15 -0
- package/static/js/clientHooks.js +1848 -0
- package/static/js/contentCollection.js +96 -0
- package/static/js/toolbar.js +177 -0
- package/templates/editbarButton.ejs +7 -0
- package/templates/imageFormatMenu.ejs +29 -0
- package/templates/modal.ejs +13 -0
|
@@ -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
|
+
};
|