ep_images_extended
Version:
Insert images inline with text, float them, resize them, and more.
1,878 lines • 86.6 kB
JavaScript
'use strict';
// Modified from ep_image_insert 1.0.7
// Optional helper (shared with ep_docx_html_customizer) that provides a CORS fetch with
// automatic same-origin proxy fallback. If the plugin is not present we simply fall back
// to the native fetch logic.
let fetchWithCorsProxy;
try {
({fetchWithCorsProxy} = require('../../../ep_docx_html_customizer/transform_common'));
} catch (_) { /* helper not available – fallback to plain fetch */ }
if (!fetchWithCorsProxy && typeof window !== 'undefined') {
fetchWithCorsProxy = window.fetchWithCorsProxy;
}
// Simple UUID generator
function generateUUID() {
return 'xxxx-xxxx-4xxx-yxxx-xxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
// Helper function to find image placeholder position robustly
function findImagePlaceholderPosition(lineText, imageIndex, fallbackLineElement = null) {
// ---- Primary strategy: use canonical placeholder scan -------------------
const canonicalRanges = getAllPlaceholderRanges(lineText);
if (imageIndex >= 0 && imageIndex < canonicalRanges.length) {
const r = canonicalRanges[imageIndex];
return {
colStart: r.colStart,
patternLength: r.patternLength,
pattern: lineText.substr(r.colStart, r.patternLength),
};
}
// Fallback heuristics (legacy) -------------------------------------------
// Try different placeholder patterns that might exist
const placeholderPatterns = [
'\u200B\u200B\u200B', // 3 × ZWSP (current)
'\u200B\u00A0\u200B', // ZWSP NBSP ZWSP (toolbar-inserted, incl. tables)
'\u200B\u200B', // 2 × ZWSP (collapsed)
'\u00A0', // single NBSP (edge / collapsed)
'\u200B' // single ZWSP (legacy)
];
for (const pattern of placeholderPatterns) {
let searchFromIndex = 0;
let foundCount = 0;
while (foundCount <= imageIndex) {
const foundIndex = lineText.indexOf(pattern, searchFromIndex);
if (foundIndex === -1) {
break; // Pattern not found anymore
}
if (foundCount === imageIndex) {
// Found the target placeholder
return {
colStart: foundIndex,
patternLength: pattern.length,
pattern: pattern
};
}
foundCount++;
searchFromIndex = foundIndex + pattern.length;
}
}
// Fallback: try to find any zero-width space near expected position
const zwspPattern = '\u200B';
const expectedPosition = imageIndex * 3; // Rough estimate
const searchStart = Math.max(0, expectedPosition - 10);
const searchEnd = Math.min(lineText.length, expectedPosition + 20);
for (let i = searchStart; i < searchEnd; i++) {
if (lineText[i] === zwspPattern) {
// console.log(`[ep_images_extended] Fallback: Found ZWSP at position ${i} for image index ${imageIndex}`);
return {
colStart: i,
patternLength: 1,
pattern: zwspPattern
};
}
}
// DOM-based fallback: when text-based approach fails, use DOM positioning
if (fallbackLineElement) {
// console.log(`[ep_images_extended] DOM fallback: Using DOM-based positioning for image index ${imageIndex}`);
// Count characters before the target image element in the DOM
const allImagePlaceholders = Array.from(fallbackLineElement.querySelectorAll('.inline-image.image-placeholder'));
if (imageIndex < allImagePlaceholders.length) {
const targetImageElement = allImagePlaceholders[imageIndex];
// Calculate approximate text position by walking through DOM text nodes
let approximatePosition = 0;
const walker = document.createTreeWalker(
fallbackLineElement,
NodeFilter.SHOW_TEXT,
null,
false
);
let currentNode = walker.nextNode();
while (currentNode) {
if (currentNode.parentNode &&
(currentNode.parentNode === targetImageElement ||
targetImageElement.contains(currentNode.parentNode))) {
// Found a text node that belongs to our target image
// console.log(`[ep_images_extended] DOM fallback: Found position ${approximatePosition} for image index ${imageIndex}`);
return {
colStart: approximatePosition,
patternLength: 3, // Assume 3 characters for safety
pattern: '\u200B\u200B\u200B',
isDomFallback: true
};
}
// Add the length of this text node to our position counter
approximatePosition += currentNode.textContent.length;
currentNode = walker.nextNode();
}
// If we couldn't find the exact position, use a reasonable estimate
// console.log(`[ep_images_extended] DOM fallback: Using estimated position ${approximatePosition} for image index ${imageIndex}`);
return {
colStart: Math.max(0, approximatePosition - 1),
patternLength: 3,
pattern: '\u200B\u200B\u200B',
isDomFallback: true
};
}
}
return null; // No placeholder found
}
// Helper function to validate ace operations and document state
function validateAceOperation(ace, operation, rangeStart, rangeEnd, context = '') {
try {
// Validate that ace exists and has required methods
if (!ace) {
console.error(`[ep_images_extended ${context}] ace object not available`);
return false;
}
// Check for required methods based on operation type
if (operation === 'applyAttributes' && typeof ace.ace_performDocumentApplyAttributesToRange !== 'function') {
console.error(`[ep_images_extended ${context}] ace_performDocumentApplyAttributesToRange not available`);
return false;
}
if (operation === 'replaceRange' && typeof ace.ace_replaceRange !== 'function') {
console.error(`[ep_images_extended ${context}] ace_replaceRange not available`);
return false;
}
// Get current rep to validate document state
const rep = ace.ace_getRep();
if (!rep || !rep.lines) {
console.error(`[ep_images_extended ${context}] Document rep not available or invalid`);
return false;
}
// Validate range bounds if provided
if (rangeStart && rangeEnd) {
const [startLine, startCol] = rangeStart;
const [endLine, endCol] = rangeEnd;
// Check if line numbers are valid
if (startLine < 0 || endLine < 0 || startLine >= rep.lines.length() || endLine >= rep.lines.length()) {
console.error(`[ep_images_extended ${context}] Invalid line numbers in range:`, [rangeStart, rangeEnd], 'total lines:', rep.lines.length());
return false;
}
// Check if the lines still exist
const startLineObj = rep.lines.atIndex(startLine);
const endLineObj = rep.lines.atIndex(endLine);
if (!startLineObj || !endLineObj) {
console.error(`[ep_images_extended ${context}] One or more lines no longer exist:`, startLine, endLine);
return false;
}
// Validate column bounds
const startLineText = startLineObj.text;
const endLineText = endLineObj.text;
if (startCol < 0 || endCol < 0 || startCol > startLineText.length || endCol > endLineText.length) {
console.error(`[ep_images_extended ${context}] Invalid column positions in range:`, [rangeStart, rangeEnd],
'start line length:', startLineText.length, 'end line length:', endLineText.length);
return false;
}
}
return true;
} catch (error) {
console.error(`[ep_images_extended ${context}] Error during validation:`, error);
return false;
}
}
exports.aceAttribsToClasses = function(name, context) {
if (context.key === 'image' && context.value) {
return ['image:' + context.value];
}
if (context.key === 'image-width' && context.value) {
return ['image-width:' + context.value];
}
if (context.key === 'image-height' && context.value) {
return ['image-height:' + context.value];
}
// ADDED for imageCssAspectRatio
if (context.key === 'imageCssAspectRatio' && context.value) {
return ['imageCssAspectRatio:' + context.value];
}
// ADDED for image float style
if (context.key === 'image-float' && context.value) {
return ['image-float:' + context.value];
}
// ADDED for persistent image ID
if (context.key === 'image-id' && context.value) {
return ['image-id-' + context.value];
}
return [];
};
exports.aceInitialized = (hook, context) => {
// Bind the new image insertion function
context.editorInfo.ace_doInsertImage = doInsertImage.bind(context);
};
// Function to render placeholders into actual images (Currently unused due to CSS background approach)
/*
const renderImagePlaceholders = (rootElement) => {
const placeholders = $(rootElement).find('span.image-placeholder');
placeholders.each(function() {
const $placeholder = $(this);
if ($placeholder.data('processed-image')) {
return;
}
const attribsData = $placeholder.data('image-attribs');
if (typeof attribsData === 'string') {
try {
const imageData = JSON.parse(attribsData);
if (imageData && imageData.src) {
const $img = $('<img>').attr('src', imageData.src);
$img.css({
'display': 'inline-block',
'max-width': '100%',
'max-height': '20em',
'vertical-align': 'middle'
});
if (imageData.width) $img.attr('width', imageData.width);
if (imageData.height) $img.attr('height', imageData.height);
$placeholder.empty().append($img);
$placeholder.data('processed-image', true);
} else {
$placeholder.text('[Invalid Image]');
$placeholder.data('processed-image', true);
}
} catch (e) {
console.error('[ep_images_extended] Failed to parse image data:', attribsData, e);
$placeholder.text('[Parse Error]');
$placeholder.data('processed-image', true);
}
} else {
$placeholder.text('[Missing Data]');
$placeholder.data('processed-image', true);
}
});
};
*/
exports.postAceInit = function (hook, context) {
const padOuter = $('iframe[name="ace_outer"]').contents().find('body');
if (padOuter.length === 0) {
console.error('[ep_images_extended postAceInit] Could not find outer pad body.');
return;
}
if ($('#imageResizeOutline').length === 0) {
const $outlineBox = $('<div id="imageResizeOutline"></div>');
$outlineBox.css({
position: 'absolute',
border: '1px dashed #1a73e8',
backgroundColor: 'rgba(26, 115, 232, 0.1)',
'pointer-events': 'none',
display: 'none',
'z-index': 1000,
'box-sizing': 'border-box'
});
padOuter.append($outlineBox);
}
// Check if image formatting menu exists (should be loaded from template)
if ($('#imageFormatMenu').length === 0) {
// console.log('[ep_images_extended] Image format menu not found - template may not be loaded yet');
} else {
// console.log('[ep_images_extended] Image format menu found from template');
}
const $outlineBoxRef = padOuter.find('#imageResizeOutline');
// Look for the format menu in the main document (loaded from template)
const $formatMenuRef = $('#imageFormatMenu');
const _aceContext = context.ace;
if (!$outlineBoxRef || $outlineBoxRef.length === 0) {
console.error('[ep_images_extended postAceInit] FATAL: Could not find #imageResizeOutline OUTSIDE callWithAce.');
return;
}
if (!$formatMenuRef || $formatMenuRef.length === 0) {
console.error('[ep_images_extended postAceInit] FATAL: Could not find #imageFormatMenu OUTSIDE callWithAce.');
return;
}
context.ace.callWithAce((ace) => {
const $innerIframe = $('iframe[name="ace_outer"]').contents().find('iframe[name="ace_inner"]');
if ($innerIframe.length === 0) {
console.error('ep_images_extended: ERROR - Could not find inner iframe (ace_inner).');
return;
}
const innerDocBody = $innerIframe.contents().find('body')[0];
const $inner = $(innerDocBody);
const innerDoc = $innerIframe.contents();
if (!$inner || $inner.length === 0) {
console.error('ep_images_extended: ERROR - Could not get body from inner iframe.');
return;
}
let isDragging = false;
let startX = 0;
let startWidth = 0;
let startHeight = 0;
// let aspectRatio = 1; // Not directly used for height calculation anymore
let currentVisualAspectRatioHW = 1;
let targetOuterSpan = null;
let targetInnerSpan = null;
let targetLineNumber = -1;
let outlineBoxPositioned = false;
let mousedownClientX = 0;
let mousedownClientY = 0;
let clickedHandle = null;
// Use a global approach with CSS classes on the body and unique image identifiers
let selectedImageSrc = null;
let selectedImageElement = null; // Track the specific selected element
let selectedImageLine = -1; // Track line number for persistent selection
let selectedImageCol = -1; // Track column for persistent selection
// NEW: Store the active image's unique ID
window.epImageInsertActiveImageId = null;
// Store resize positioning data without DOM attributes to avoid triggering content collection
let resizePositionData = null;
// Function to position and show the format menu below the selected image
const showFormatMenu = (imageElement) => {
// Don't show format menu in read-only mode
if (clientVars && clientVars.readonly) {
return;
}
if (!imageElement || !$formatMenuRef || $formatMenuRef.length === 0) {
return;
}
try {
const innerSpan = imageElement.querySelector('span.image-inner');
if (!innerSpan) {
return;
}
// Use the same complex positioning logic as the resize outline
const imageRect = innerSpan.getBoundingClientRect();
// Get all the necessary container references
let innerBodyRect, innerIframeRect, outerBodyRect;
let scrollTopInner, scrollLeftInner, scrollTopOuter, scrollLeftOuter;
try {
innerBodyRect = innerDocBody.getBoundingClientRect();
innerIframeRect = $innerIframe[0].getBoundingClientRect();
outerBodyRect = padOuter[0].getBoundingClientRect();
scrollTopInner = innerDocBody.scrollTop;
scrollLeftInner = innerDocBody.scrollLeft;
scrollTopOuter = padOuter.scrollTop();
scrollLeftOuter = padOuter.scrollLeft();
} catch (e) {
console.error('[ep_images_extended showFormatMenu] Error getting container rects/scrolls:', e);
return;
}
// Calculate position using the same method as resize outline
const imageTopRelInner = imageRect.top - innerBodyRect.top + scrollTopInner;
const imageLeftRelInner = imageRect.left - innerBodyRect.left + scrollLeftInner;
const imageBottomRelInner = imageRect.bottom - innerBodyRect.top + scrollTopInner;
const innerFrameTopRelOuter = innerIframeRect.top - outerBodyRect.top + scrollTopOuter;
const innerFrameLeftRelOuter = innerIframeRect.left - outerBodyRect.left + scrollLeftOuter;
const menuTopOuter = innerFrameTopRelOuter + imageBottomRelInner + 8; // 8px gap below image
// For left alignment, we want the menu's left edge to match the image's left edge
const menuLeftOuter = innerFrameLeftRelOuter + imageLeftRelInner;
// Apply the same padding adjustments as resize outline
const outerPadding = window.getComputedStyle(padOuter[0]);
const outerPaddingTop = parseFloat(outerPadding.paddingTop) || 0;
const outerPaddingLeft = parseFloat(outerPadding.paddingLeft) || 0;
// Apply manual offsets similar to resize outline, but adjusted for menu
const MENU_MANUAL_OFFSET_TOP = 9; // Same as resize outline
const MENU_MANUAL_OFFSET_LEFT = 37; // Reduced by 5px to move menu closer to left margin
const finalMenuTop = menuTopOuter + outerPaddingTop + MENU_MANUAL_OFFSET_TOP;
const finalMenuLeft = menuLeftOuter + outerPaddingLeft + MENU_MANUAL_OFFSET_LEFT;
// Position the menu using absolute positioning relative to padOuter (like resize outline)
$formatMenuRef.css({
position: 'absolute',
left: Math.max(10, finalMenuLeft) + 'px',
top: finalMenuTop + 'px',
'z-index': '10000'
}).addClass('visible');
// Move the menu to padOuter if it's not already there
if ($formatMenuRef.parent()[0] !== padOuter[0]) {
padOuter.append($formatMenuRef);
}
// Update menu button states based on current image float style
updateMenuButtonStates(imageElement);
} catch (e) {
console.error('[ep_images_extended] Error positioning format menu:', e);
}
};
// Function to hide the format menu
const hideFormatMenu = () => {
if ($formatMenuRef) {
$formatMenuRef.removeClass('visible');
}
};
// Function to update menu button states based on current image float style
const updateMenuButtonStates = (imageElement) => {
if (!imageElement || !$formatMenuRef || $formatMenuRef.length === 0) {
return;
}
// Determine current float state
let currentFloat = 'inline'; // default
if (imageElement.classList.contains('image-float-left')) {
currentFloat = 'left';
} else if (imageElement.classList.contains('image-float-right')) {
currentFloat = 'right';
} else if (imageElement.classList.contains('image-float-none')) {
currentFloat = 'inline';
}
// Update button active states
$formatMenuRef.find('.image-format-button[data-wrap]').removeClass('active');
$formatMenuRef.find(`.image-format-button[data-wrap="${currentFloat}"]`).addClass('active');
};
$inner.on('mousedown', '.inline-image.image-placeholder', function(evt) {
if (evt.button !== 0) return;
// Don't allow interaction in read-only mode
if (clientVars && clientVars.readonly) {
return;
}
// *** ENHANCED DEBUG: Track image click within table context ***
// console.log('[ep_images_extended] *** IMAGE MOUSEDOWN EVENT START ***');
// console.log('[ep_images_extended] Event target:', evt.target);
// console.log('[ep_images_extended] Event currentTarget:', evt.currentTarget);
targetOuterSpan = this;
const $targetOuterSpan = $(targetOuterSpan);
// console.log('[ep_images_extended] Target outer span element:', targetOuterSpan);
// console.log('[ep_images_extended] Target outer span classes:', targetOuterSpan.className);
// console.log('[ep_images_extended] Target outer span HTML length:', targetOuterSpan.outerHTML?.length || 0);
// *** DEBUG: Check table context ***
const closestTable = targetOuterSpan.closest('table.dataTable');
const closestTableCell = targetOuterSpan.closest('td, th');
const closestAceLine = targetOuterSpan.closest('.ace-line');
// console.log('[ep_images_extended] Is image within table?', !!closestTable);
if (closestTable) {
// console.log('[ep_images_extended] Table tblId:', closestTable.getAttribute('data-tblId'));
// console.log('[ep_images_extended] Table row:', closestTable.getAttribute('data-row'));
// console.log('[ep_images_extended] Table cell:', !!closestTableCell);
if (closestTableCell) {
// console.log('[ep_images_extended] Cell data-column:', closestTableCell.getAttribute('data-column'));
// console.log('[ep_images_extended] Cell innerHTML length:', closestTableCell.innerHTML?.length || 0);
}
}
if (closestAceLine) {
// console.log('[ep_images_extended] Ace line ID:', closestAceLine.id);
// console.log('[ep_images_extended] Ace line classes:', closestAceLine.className);
// console.log('[ep_images_extended] Ace line innerHTML length:', closestAceLine.innerHTML?.length || 0);
}
const imageId = $targetOuterSpan.attr('data-image-id');
// console.log('[ep_images_extended] Image ID:', imageId);
if (imageId) {
const previouslyActiveId = window.epImageInsertActiveImageId;
// console.log('[ep_images_extended] Previously active image ID:', previouslyActiveId);
// console.log('[ep_images_extended] Setting new active image ID:', imageId);
window.epImageInsertActiveImageId = imageId; // NEW: Set active ID
// *** DEBUG: Track selection styling changes ***
// console.log('[ep_images_extended] *** SELECTION STYLING START ***');
// Use dynamic CSS injection to avoid triggering content collector on image elements
if (previouslyActiveId !== imageId) {
// console.log('[ep_images_extended] Updating dynamic CSS selection');
const innerDoc = $inner[0].ownerDocument;
// Remove previous dynamic style if it exists
let existingStyle = innerDoc.getElementById('ep-image-selection-style');
if (existingStyle) {
existingStyle.remove();
}
// Create new dynamic style element for the selected image
if (imageId) {
const styleElement = innerDoc.createElement('style');
styleElement.id = 'ep-image-selection-style';
styleElement.textContent = `
span.inline-image.image-placeholder[data-image-id="${imageId}"] span.image-inner {
outline: 1px solid #1a73e8 !important;
outline-offset: 1px !important;
}
span.inline-image.image-placeholder[data-image-id="${imageId}"] span.image-resize-handle {
display: block !important;
}
`;
innerDoc.head.appendChild(styleElement);
// console.log('[ep_images_extended] Added dynamic CSS for image:', imageId);
}
}
// console.log('[ep_images_extended] *** SELECTION STYLING END ***');
// *** DEBUG: Check DOM state after style changes ***
if (closestAceLine) {
// console.log('[ep_images_extended] *** DOM STATE AFTER STYLE CHANGES ***');
// console.log('[ep_images_extended] Ace line innerHTML length after style changes:', closestAceLine.innerHTML?.length || 0);
// console.log('[ep_images_extended] Ace line innerHTML (first 500 chars):', closestAceLine.innerHTML?.substring(0, 500) || '');
// Check for delimiter presence in the line
const delimiterCount = (closestAceLine.innerHTML || '').split('|').length - 1;
// console.log('[ep_images_extended] Delimiter count in ace line after style changes:', delimiterCount);
// Check if tbljson class is still present
const hasTbljsonClass = closestAceLine.innerHTML?.includes('tbljson-') || false;
// console.log('[ep_images_extended] Ace line still has tbljson class after changes:', hasTbljsonClass);
}
showFormatMenu(targetOuterSpan);
} else {
console.warn('[ep_images_extended mousedown] Image clicked has no data-image-id.');
}
targetInnerSpan = targetOuterSpan.querySelector('span.image-inner');
if (!targetInnerSpan) {
console.error('[ep_images_extended mousedown] Could not find inner span.');
targetOuterSpan = null;
$targetOuterSpan.removeClass('selected');
return;
}
const target = $(evt.target);
const isResizeHandle = target.hasClass('image-resize-handle');
// console.log('[ep_images_extended] Is resize handle clicked?', isResizeHandle);
// If clicking on a resize handle, start the resize operation
if (isResizeHandle) {
// console.log('[ep_images_extended] *** RESIZE HANDLE CLICKED - STARTING RESIZE OPERATION ***');
isDragging = true;
outlineBoxPositioned = false;
startX = evt.clientX;
mousedownClientX = evt.clientX;
mousedownClientY = evt.clientY;
startWidth = targetInnerSpan.offsetWidth || parseInt(targetInnerSpan.style.width, 10) || 0;
startHeight = targetInnerSpan.offsetHeight || parseInt(targetInnerSpan.style.height, 10) || 0;
currentVisualAspectRatioHW = (startWidth > 0 && startHeight > 0) ? (startHeight / startWidth) : 1;
if (target.hasClass('br')) clickedHandle = 'br';
else clickedHandle = null;
const lineElement = $(targetOuterSpan).closest('.ace-line')[0];
if (lineElement) {
const allImagePlaceholdersInLine = Array.from(lineElement.querySelectorAll('.inline-image.image-placeholder'));
const imageIndex = allImagePlaceholdersInLine.indexOf(targetOuterSpan);
if (imageIndex === -1) {
console.error('[ep_images_extended mousedown] Clicked image placeholder not found within its line DOM elements.');
isDragging = false;
resizePositionData = null;
$targetOuterSpan.removeClass('selected');
targetOuterSpan = null;
return;
}
targetLineNumber = _getLineNumberOfElement(lineElement);
// Store positioning data in JavaScript variable instead of DOM attributes to avoid content collection
_aceContext.callWithAce((ace) => {
const rep = ace.ace_getRep();
if (!rep.lines.atIndex(targetLineNumber)) {
console.error(`[ep_images_extended mousedown] Line ${targetLineNumber} does not exist in rep.`);
resizePositionData = null;
return;
}
const lineText = rep.lines.atIndex(targetLineNumber).text;
// Use helper function to find placeholder position
const placeholderInfo = findImagePlaceholderPosition(lineText, imageIndex, lineElement);
if (placeholderInfo) {
// Store in JS variable instead of DOM attributes to avoid triggering content collection
resizePositionData = {
lineNumber: targetLineNumber,
colStart: placeholderInfo.colStart,
patternLength: placeholderInfo.patternLength
};
// console.log(`[ep_images_extended mousedown] Found placeholder at position ${placeholderInfo.colStart} with pattern length ${placeholderInfo.patternLength}`);
} else {
console.error(`[ep_images_extended mousedown] Could not find any placeholder sequence for image index ${imageIndex} in line text: "${lineText}"`);
resizePositionData = null;
}
}, 'getImageColStart', true);
} else {
console.error('[ep_images_extended mousedown] Could not find parent .ace-line for the clicked image.');
isDragging = false;
resizePositionData = null;
$targetOuterSpan.removeClass('selected');
targetOuterSpan = null;
return;
}
evt.preventDefault();
} else {
// console.log('[ep_images_extended] *** SIMPLE IMAGE CLICK - NO RESIZE HANDLE ***');
// Position cursor next to the image instead of inside it
_aceContext.callWithAce((ace) => {
const lineElement = $(targetOuterSpan).closest('.ace-line')[0];
if (lineElement) {
const lineNumber = _getLineNumberOfElement(lineElement);
const rep = ace.ace_getRep();
if (rep.lines.atIndex(lineNumber)) {
const lineText = rep.lines.atIndex(lineNumber).text;
const allImages = Array.from(lineElement.querySelectorAll('.inline-image.image-placeholder'));
const imageIndex = allImages.indexOf(targetOuterSpan);
if (imageIndex !== -1) {
const placeholderInfo = findImagePlaceholderPosition(lineText, imageIndex, lineElement);
if (placeholderInfo) {
// Determine cursor position based on click location relative to image
const imageRect = targetInnerSpan.getBoundingClientRect();
const clickX = evt.clientX;
const imageCenterX = imageRect.left + imageRect.width / 2;
let cursorPos;
if (clickX < imageCenterX) {
// Clicked left side - place cursor before image
cursorPos = [lineNumber, placeholderInfo.colStart];
} else {
// Clicked right side - place cursor after image
cursorPos = [lineNumber, placeholderInfo.colStart + placeholderInfo.patternLength];
}
// console.log(`[ep_images_extended] Positioning cursor at [${cursorPos}] based on click position`);
ace.ace_performSelectionChange(cursorPos, cursorPos, false);
}
}
}
}
}, 'positionCursorNextToImage', true);
}
// console.log('[ep_images_extended] *** IMAGE MOUSEDOWN EVENT END ***');
});
innerDoc.on('mousemove', function(evt) {
if (isDragging) {
if (!outlineBoxPositioned) {
if (!targetInnerSpan || !padOuter || !targetOuterSpan || !innerDocBody || !$innerIframe) {
console.error('[ep_images_extended mousemove] Cannot position outline: Required elements missing.');
return;
}
const currentWidth = startWidth;
const currentHeight = startHeight;
// if (currentWidth <= 0 || currentHeight <= 0) { /* Warning for this was removed */ }
let innerBodyRect, innerIframeRect, outerBodyRect;
let scrollTopInner, scrollLeftInner, scrollTopOuter, scrollLeftOuter;
try {
innerBodyRect = innerDocBody.getBoundingClientRect();
innerIframeRect = $innerIframe[0].getBoundingClientRect();
outerBodyRect = padOuter[0].getBoundingClientRect();
scrollTopInner = innerDocBody.scrollTop;
scrollLeftInner = innerDocBody.scrollLeft;
scrollTopOuter = padOuter.scrollTop();
scrollLeftOuter = padOuter.scrollLeft();
} catch (e) {
console.error('[ep_images_extended mousemove] Error getting container rects/scrolls:', e);
return;
}
const clickTopRelInner = mousedownClientY - innerBodyRect.top + scrollTopInner;
const clickLeftRelInner = mousedownClientX - innerBodyRect.left + scrollLeftInner;
const innerFrameTopRelOuter = innerIframeRect.top - outerBodyRect.top + scrollTopOuter;
const innerFrameLeftRelOuter = innerIframeRect.left - outerBodyRect.left + scrollLeftOuter;
const baseClickTopOuter = innerFrameTopRelOuter + clickTopRelInner;
const baseClickLeftOuter = innerFrameLeftRelOuter + clickLeftRelInner;
let outlineTop = baseClickTopOuter;
let outlineLeft = baseClickLeftOuter;
// For bottom-right handle, position outline at top-left of image
if (clickedHandle === 'br') {
outlineLeft -= currentWidth;
outlineTop -= currentHeight;
}
const outerPadding = window.getComputedStyle(padOuter[0]);
const outerPaddingTop = parseFloat(outerPadding.paddingTop) || 0;
const outerPaddingLeft = parseFloat(outerPadding.paddingLeft) || 0;
const finalOutlineTop = outlineTop + outerPaddingTop;
const finalOutlineLeft = outlineLeft + outerPaddingLeft;
const MANUAL_OFFSET_TOP = 9;
const MANUAL_OFFSET_LEFT = 42;
const finalTopWithManualOffset = finalOutlineTop + MANUAL_OFFSET_TOP;
const finalLeftWithManualOffset = finalOutlineLeft + MANUAL_OFFSET_LEFT;
$outlineBoxRef.css({
left: finalLeftWithManualOffset + 'px',
top: finalTopWithManualOffset + 'px',
width: currentWidth + 'px',
height: currentHeight + 'px',
display: 'block'
});
outlineBoxPositioned = true;
}
if ($outlineBoxRef && $outlineBoxRef.length > 0) {
const currentX = evt.clientX;
const deltaX = currentX - startX;
let newPixelWidth = startWidth + deltaX;
if (targetOuterSpan) {
const $tableCell = $(targetOuterSpan).closest('td, th');
if ($tableCell.length > 0) {
const parentWidth = $tableCell.width();
if (parentWidth > 0) {
newPixelWidth = Math.min(newPixelWidth, parentWidth);
}
}
}
newPixelWidth = Math.max(20, newPixelWidth);
const newPixelHeight = newPixelWidth * currentVisualAspectRatioHW;
$outlineBoxRef.css({
width: newPixelWidth + 'px',
height: newPixelHeight + 'px'
});
} else {
console.error('[ep_images_extended mousemove] Outline box ref missing or invalid during size update!');
}
$inner.css('cursor', 'nw-resize');
}
});
innerDoc.on('mouseup', function(evt) {
if (isDragging) {
const finalX = evt.clientX;
const deltaX = finalX - startX;
let finalPixelWidth = startWidth + deltaX;
if (targetOuterSpan) {
const $tableCell = $(targetOuterSpan).closest('td, th');
if ($tableCell.length > 0) {
const parentWidth = $tableCell.width();
if (parentWidth > 0) {
finalPixelWidth = Math.min(finalPixelWidth, parentWidth);
}
}
}
finalPixelWidth = Math.max(20, Math.round(finalPixelWidth));
const widthToApply = `${finalPixelWidth}px`;
const finalPixelHeight = Math.round(finalPixelWidth * currentVisualAspectRatioHW);
const heightToApplyPx = `${finalPixelHeight}px`;
const newCssAspectRatioForVar = (startWidth > 0 && startHeight > 0) ? (startWidth / startHeight).toFixed(4) : '1';
// Don't apply styles directly to avoid triggering content collector
// The visual updates will be handled by acePostWriteDomLineHTML after attributes are applied
// console.log('[ep_images_extended mouseup] Skipping direct style application to avoid content collection triggers');
_aceContext.callWithAce((ace) => {
const outerSpanAlive = (targetOuterSpan && document.contains(targetOuterSpan)) ? targetOuterSpan : null;
let workingOuterSpan = outerSpanAlive;
// Fallback: locate by active image id if our stored element vanished
if (!workingOuterSpan && window.epImageInsertActiveImageId) {
workingOuterSpan = $inner.find(`.inline-image.image-placeholder[data-image-id="${window.epImageInsertActiveImageId}"]`)[0];
}
const placeholderRange = getPlaceholderRangeFromOuterSpan(workingOuterSpan, ace, {wholePlaceholder: true});
if (!placeholderRange) {
console.error('[ep_images_extended mouseup] Could not determine placeholder range for resize.');
return;
}
if (!validateAceOperation(ace, 'applyAttributes', placeholderRange[0], placeholderRange[1], 'mouseup resize')) {
return;
}
try {
ace.ace_performDocumentApplyAttributesToRange(placeholderRange[0], placeholderRange[1], [
['image-width', widthToApply],
['image-height', heightToApplyPx],
['imageCssAspectRatio', newCssAspectRatioForVar]
]);
// console.log('[ep_images_extended mouseup] Successfully applied resize attributes (new targeting)');
} catch (err) {
console.error('[ep_images_extended mouseup] Error applying resize attributes:', err);
}
}, 'applyImageAttributes', true);
// Reset dragging state and clean up
isDragging = false;
outlineBoxPositioned = false;
clickedHandle = null;
$outlineBoxRef.hide();
$inner.css('cursor', 'auto');
// Keep the image selected after resizing - don't clear selection
// Clean up resize position data
resizePositionData = null;
// Reset target references
targetOuterSpan = null;
targetInnerSpan = null;
targetLineNumber = -1;
}
// Note: We don't clear selection here for simple clicks - only the click-outside handler does that
});
$inner.on('paste', function(evt) {
const clipboardData = evt.originalEvent.clipboardData || window.clipboardData;
if (!clipboardData) return;
let foundImage = false;
for (let i = 0; i < clipboardData.items.length; i++) {
const item = clipboardData.items[i];
if (item.kind === 'file' && item.type.startsWith('image/')) {
const file = item.getAsFile();
foundImage = true;
let isValid = true;
const errorTitle = html10n.get('ep_images_extended.error.title');
if (clientVars.ep_images_extended && clientVars.ep_images_extended.fileTypes) {
const mimedb = clientVars.ep_images_extended.mimeTypes;
const mimeTypeInfo = mimedb[file.type];
let validMime = false;
if (mimeTypeInfo && mimeTypeInfo.extensions) {
for (const fileType of clientVars.ep_images_extended.fileTypes) {
if (mimeTypeInfo.extensions.includes(fileType)) {
validMime = true;
break;
}
}
}
if (!validMime) {
const errorMessage = html10n.get('ep_images_extended.error.fileType');
$.gritter.add({ title: errorTitle, text: errorMessage, sticky: true, class_name: 'error' });
isValid = false;
}
}
if (isValid && clientVars.ep_images_extended && file.size > clientVars.ep_images_extended.maxFileSize) {
const allowedSize = (clientVars.ep_images_extended.maxFileSize / 1000000);
const errorText = html10n.get('ep_images_extended.error.fileSize', { maxallowed: allowedSize });
$.gritter.add({ title: errorTitle, text: errorText, sticky: true, class_name: 'error' });
isValid = false;
}
if (isValid) {
evt.preventDefault();
// Determine storage strategy (default base64)
const storageType = (clientVars && clientVars.ep_images_extended && clientVars.ep_images_extended.storageType) || 'base64';
// Global cache to avoid re-uploading the same blob within a pad session
window.epImageInsertUploadCache = window.epImageInsertUploadCache || {};
// Helper to actually insert an <img> (via ace_doInsertImage)
const insertIntoPad = (src, widthPx = null, heightPx = null) => {
_aceContext.callWithAce((ace) => {
ace.ace_doInsertImage(src, widthPx, heightPx);
}, 'pasteImage', true);
};
// Fallback helper: convert blob to base64 and insert
const insertAsDataUrl = (blob) => {
const readerB64 = new FileReader();
readerB64.onload = (e_reader) => {
const dataUrl = e_reader.target.result;
const probeImg = new Image();
probeImg.onload = () => insertIntoPad(dataUrl, `${probeImg.naturalWidth}px`, `${probeImg.naturalHeight}px`);
probeImg.onerror = () => insertIntoPad(dataUrl);
probeImg.src = dataUrl;
};
readerB64.onerror = (e_reader) => {
console.error('[ep_images_extended paste] FileReader error:', e_reader);
$.gritter.add({ title: errorTitle, text: 'Error reading pasted image file.', sticky: true, class_name: 'error' });
};
readerB64.readAsDataURL(blob);
};
if (storageType === 'base64') {
// Original behaviour retained
insertAsDataUrl(file);
} else if (storageType === 's3_presigned') {
// Upload directly to S3 (or reuse if already uploaded)
(async () => {
try {
// Compute SHA-256 hash for deduplication
const arrayBuf = await file.arrayBuffer();
const hashBuf = await crypto.subtle.digest('SHA-256', arrayBuf);
const hashHex = Array.from(new Uint8Array(hashBuf)).map(b => b.toString(16).padStart(2, '0')).join('');
if (window.epImageInsertUploadCache[hashHex]) {
const cachedUrl = window.epImageInsertUploadCache[hashHex];
const probeImg = new Image();
probeImg.onload = () => insertIntoPad(cachedUrl, `${probeImg.naturalWidth}px`, `${probeImg.naturalHeight}px`);
probeImg.onerror = () => insertIntoPad(cachedUrl);
probeImg.src = cachedUrl;
return;
}
const queryParams = $.param({ name: file.name || `${hashHex}.png`, type: file.type });
const presignData = await $.getJSON(`${clientVars.padId}/pluginfw/ep_images_extended/s3_presign?${queryParams}`);
if (!presignData || !presignData.signedUrl || !presignData.publicUrl) {
throw new Error('Invalid presign response');
}
const uploadResp = await fetch(presignData.signedUrl, {
method: 'PUT',
headers: { 'Content-Type': file.type },
body: file,
});
if (!uploadResp.ok) throw new Error(`S3 upload failed with status ${uploadResp.status}`);
const publicUrl = presignData.publicUrl;
window.epImageInsertUploadCache[hashHex] = publicUrl;
const probeImg = new Image();
probeImg.onload = () => insertIntoPad(publicUrl, `${probeImg.naturalWidth}px`, `${probeImg.naturalHeight}px`);
probeImg.onerror = () => insertIntoPad(publicUrl);
probeImg.src = publicUrl;
} catch (err) {
console.error('[ep_images_extended paste] S3 upload failed, falling back to base64:', err);
insertAsDataUrl(file);
}
})();
} else {
// Generic server upload (local disk etc.)
(async () => {
try {
const formData = new FormData();
formData.append('file', file, file.name);
const uploadResp = await $.ajax({
type: 'POST',
url: `${clientVars.padId}/pluginfw/ep_images_extended/upload`,
data: formData,
cache: false,
contentType: false,
processData: false,
timeout: 60000,
dataType: 'json',
});
if (!uploadResp || !uploadResp.url) {
throw new Error('Invalid upload response');
}
const publicUrl = uploadResp.url;
const probeImg = new Image();
probeImg.onload = () => insertIntoPad(publicUrl, `${probeImg.naturalWidth}px`, `${probeImg.naturalHeight}px`);
probeImg.onerror = () => insertIntoPad(publicUrl);
probeImg.src = publicUrl;
} catch (err) {
console.error('[ep_images_extended paste] Server upload failed, falling back to base64:', err);
insertAsDataUrl(file);
}
})();
}
}
break;
}
}
// if (foundImage) { /* handled by preventDefault */ }
// else { /* Allow default paste for non-image content */ }
});
// Handle clicking outside images to deselect them
$(innerDoc).on('mousedown', function(evt) {
if (!$(evt.target).closest('.inline-image.image-placeholder').length) {
if (window.epImageInsertActiveImageId) {
const previouslyActiveId = window.epImageInsertActiveImageId;
window.epImageInsertActiveImageId = null;
// Remove dynamic CSS selection
const innerDoc = $inner[0].ownerDocument;
const existingStyle = innerDoc.getElementById('ep-image-selection-style');
if (existingStyle) {
existingStyle.remove();
}
// Clean up any pending resize position data
resizePositionData = null;
// _aceContext.callWithAce((ace) => {
// ace.ace_callRepaint(); // Repaint might not be enough, direct class removal is better.
// }, 'repaintAfterDeselect', true);
}
hideFormatMenu();
}
});
// Handle keyboard events for deselecting images (e.g., Escape key)
$(innerDoc).on('keydown', function(evt) {
if (evt.key === 'Escape') {
if (window.epImageInsertActiveImageId) {
const previouslyActiveId = window.epImageInsertActiveImageId;
window.epImageInsertActiveImageId = null;
// Remove dynamic CSS selection
const innerDoc = $inner[0].ownerDocument;
const existingStyle = innerDoc.getElementById('ep-image-selection-style');
if (existingStyle) {
existingStyle.remove();
}
// Clean up any pending resize position data
resizePositionData = null;
// _aceContext.callWithAce((ace) => {
// ace.ace_callRepaint();
// }, 'repaintAfterDeselectEscape', true);
}
hideFormatMenu();
}
});
// Handle clicking outside the format menu to hide it
$(document).on('mousedown', function(evt) {
if (!$(evt.target).closest('#imageFormatMenu').length &&
!$(evt.target).closest('.inline-image.image-placeholder').length) {
hideFormatMenu();
}
});
// Function to show user feedback for copy/cut operations
const showCopyFeedback = (message) => {
// console.log(`[ep_images_extended] ${message}`);
// Could be enhanced with a temporary toast notification
};
// Function to handle image copy/cut operations
const handleImageCopy = async (shouldCut) => {
try {
let currentElement = null;
if (window.epImageInsertActiveImageId) {
currentElement = $inner.find(`.inline-image.image-placeholder[data-image-id="${window.epImageInsertActiveImageId}"]`)[0];
}
if (!currentElement) {
console.error('[ep_images_extended copy] No image selected or active image ID not found in DOM.');
return;
}
// Get the image source from the selected element
const classes = currentElement.className.split(' ');
let imageSrc = null;
for (const cls of classes) {
if (cls.startsWith('image:')) {
imageSrc = decodeURIComponent(cls.substring(6));
break;
}
}
if (!imageSrc) {
console.error('[ep_images_extended copy] Could not find image source');
return;
}
// Check if we have clipboard API support
if (!navigator.clipboard || !window.ClipboardItem) {
console.error('[ep_images_extended copy] Clipboard API not supported');
// Fallback: copy the image src as text
try {
await navigator.clipboard.writeText(imageSrc);
showCopyFeedback(shouldCut ? 'Image URL cut to clipboard (text fallback)' : 'Image URL copied to clipboard (text fallback)');
} catch (e) {
console.error('[ep_images_extended copy] Fallback text copy failed:', e);
}
return;
}
// Helper function to convert image to PNG blob
const convertImageToPngBlob = async (imageSrc) => {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous'; // Handle CORS if needed
img.onload = () => {
try {
// Create a canvas to convert the image to PNG
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
// Draw the image onto the canvas
ctx.drawImage(img, 0, 0);
// Convert canvas to PNG blob
canvas.toBlob((blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error('Failed to convert image to PNG blob'));
}
}, 'image/png');
} catch (error) {
reject(error);
}
};
img.onerror = () => {
reject(new Error('Failed to load image for conversion'));
};
img.src = imageSrc;
});
};
try {
let blob;
// For data URLs, convert to PNG regardless of original format
if (imageSrc.startsWith('data:')) {
blob = await convertImageToPngBlob(imageSrc);
}
// For HTTP URLs, fetch and convert to PNG
else if (imageSrc.startsWith('http')) {
// First try to fetch the image
let response;
try {
if (typeof fetchWithCorsProxy === 'function') {
response = await fetchWithCorsProxy(imageSrc);
} else {
response = await fetch(imageSrc, {mode: 'cors'});
if (!response.ok) throw new Error(`status ${response.status}`);
}
} catch (fetchErr) {
// Remote fetch failed – fall back to copying the URL as plain text
await navigator.clipboard.writeText(imageSrc);
showCopyFeedback(shouldCut ? 'Image URL cut to clipboard (fallback)' : 'Image URL copied to clipboard (fallback)');
return; // Abort PNG-blob path
}
// Convert the fetched image to a data URL, then to PNG
const originalBlob = await response.blob();
const dataUrl = await new Promise((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.readAsDataURL(originalBlob);
});
blob = await convertImageToPngBlob(dataUrl);
}
// For relative URLs or other formats, try to convert to PNG
else {
blob = await convertImageToPngBlob(imageSrc);
}
// Create ClipboardItem with the PNG blob
const clipboardItem = new ClipboardItem({
'image/png': blob
});
// Write to clipboard
await navigator.clipboard.write([clipboardItem]);
showCopyFeedback(shouldCut ? 'Image cut to clipboard' : 'Image copied to clipboard');
} catch (e) {
console.error('[ep_images_extended copy] Failed to copy image as PNG:', e);
// Fallback to text copy
try {
await navigator.clipboard.writeText(imageSrc);
showCopyFeedback(shouldCut ? 'Image URL cut to clipboard (fallback)' : 'Image URL copied to clipboard (fallback)');
} catch (textError) {
console.error('[ep_images_extended copy] Text fallback also failed:', textError);
showCopyFeedback('Copy operation failed');
}
}
// If this is a cut operation, delete the image after successful copy
if (shouldCut) {
// Reuse the existing delete logic
const outerSpan = currentElement;
const lineElement = $(outerSpan).closest('.ace-line')[0];
if (lineElement) {
const allImagePlaceholdersInLine = Array.from(lineElement.querySelectorAll('.inline-image.image-placeholder'));
const imageIndex = allImagePlaceholdersInLine.indexOf(outerSpan);
if (imageIndex !== -1) {
const targetLineNumber = _getLineNumberOfElement(lineElement);
_aceContext.callWithAce((ace) => {
const rep = ace.ace_getRep();
if (!rep.lines.atIndex(targetLineNumber)) {
console.error(`[ep_images_extended cut] Line ${targetLineNumber} does not exist in rep.`);
return;
}
const lineText = rep.lines.atIndex(targetLineNumber).text;
// Use consolidated helper to build placeholder range
const placeholderRange = getPlaceholderRangeFromOuterSpan(outerSpan, ace, {wholePlaceholder: true});
if (placeholderRange) {
const rangeStart = placeholderRange[0];
const rangeEnd = placeholderRange[1];
try {
// Use helper function to validate ace operation
if (!validateAceOperation(ace, 'replaceRange', rangeStart, rangeEnd, 'cut')) {
return;
}
// Delete the image by replacing the text range with empty string
ace.ace_replaceRange(rangeStart, rangeEnd, '');
// console.log('Successfully cut image at line', targetLineNumber, 'column', placeholderInfo.colStart);
} catch (error) {
console.error('[ep_images_extended cut] Error deleting image:', error);
console.error('[ep_images_extended cut] Range was:', [rangeStart, rangeEnd]);
}
} else {
console.error('[ep_images_extended cut] Could not find placeholder sequence in line text');
}
}, 'cutImage', true);
// Clear selection state since image will be deleted
if (window.epImageInsertActiveImageId && $(outerSpan).attr('data-image-id') === window.epImageInsertActiveImageId) {
window.epImageInsertActiveImageId = null;
}
hideFormatMenu();
}
}
}
} catch (error) {
console.error('[ep_images_extended copy] Clipboard operation failed:', error);
showCopyFeedback('Copy operation failed');
}
};
// Handle format menu button clicks
$formatMenuRef.on('click', '.image-format-button', function(evt) {
evt.preventDefault();
evt.stopPropagation();
const $button = $(this);
const wrapType = $button.data('wrap');
const action = $button.data('action');
let currentElement = null;
if (window.epImageInsertActiveImageId) {
currentElement = $inner.find(`.inline-image.image-placeholder[data-image-id="${window.epImageInsertActiveImageId}"]`)[0];
}
if (!currentElement) {
console.warn('[ep_images_extended formatMenu] No active image found for action.');
return;
}
if (wrapType) {
// Handle wrap type buttons
$formatMenuRef.find('.image-format-button[data-wrap]').removeClass('active');
$button.addClass('active');
// Use the specific selected image element (like resize logic does)
// Check both the local reference and the global current reference (for DOM regeneration)
if (currentElement) {
const outerSpan = currentElement;
// Determine the float value to store as attribute
let floatValue;
switch (wrapType) {
case 'inline':
floatValue = 'none';
break;
case 'left':
floatValue = 'left';
break;
case 'right':
floatValue = 'right';
break;
default:
floatValue = 'none';
}
// Apply the attribute using the same method as resize functionality
const lineElement = $(outerSpan).closest('.ace-line')[0];
if (lineElement) {
const allImagePlaceholdersInLine = Array.from(lineElement.querySelectorAll('.inline-image.image-placeholder'));
const imageIndex = allImagePlaceholdersInLine.indexOf(outerSpan);
if (imageIndex !== -1) {
const targetLineNumber = _getLineNumberOfElement(lineElement);
_aceContext.callWithAce((ace) => {
const rep = ace.ace_getRep();
if (!rep.lines.atIndex(targetLineNumber)) {
console.error(`[ep_images_extended float] Line ${targetLineNumber} does not exist in rep.`);
return;
}
const lineText = rep.lines.atIndex(targetLineNumber).text;
// Use consolidated helper to build placeholder range
const placeholderRange = getPlaceholderRangeFromOuterSpan(outerSpan, ace, {wholePlaceholder: true});
if (placeholderRange) {
const rangeStart = placeholderRange[0];
const rangeEnd = placeholderRange[1];
try {
// Use helper function to validate ace operation
if (!validateAceOperation(ace, 'applyAttributes', rangeStart, rangeEnd, 'float')) {
return;
}
ace.ace_performDocumentApplyAttributesToRange(rangeStart, rangeEnd, [
['image-float', floatValue]
]);
// console.log('Applied float attribute:', floatValue, 'to image');
} catch (error) {
console.error('[ep_images_extended float] Error applying float attribute:', error);
console.error('[ep_images_extended float] Range was:', [rangeStart, rangeEnd]);
console.error('[ep_images_extended float] Float value was:', floatValue);
}
} else {
console.error('[ep_images_extended float] Could not find placeholder sequence in line text');
}
}, 'applyImageFloatAttribute', true);
}
}
}
} else if (action) {
// Handle action buttons
if (action === 'copy') {
// console.log('Copy image action triggered');
handleImageCopy(false); // false = copy (don't delete after copy)
} else if (action === 'cut') {
// console.log('Cut image action triggered');
handleImageCopy(true); // true = cut (delete after copy)
} else if (action === 'delete') {
// console.log('Delete image action triggered');
// Use the specific selected image element (like float and resize logic does)
if (currentElement) {
const outerSpan = currentElement;
_aceContext.callWithAce((ace) => {
const placeholderRange = getPlaceholderRangeFromOuterSpan(outerSpan, ace, {wholePlaceholder: true});
if (!placeholderRange) {
console.error('[ep_images_extended delete] Could not locate placeholder range for deletion.');
return;
}
const [rangeStart, rangeEnd] = placeholderRange;
if (!validateAceOperation(ace, 'replaceRange', rangeStart, rangeEnd, 'delete')) {
return;
}
try {
ace.ace_replaceRange(rangeStart, rangeEnd, '');
// console.log('[ep_images_extended delete] Successfully deleted image via helper');
} catch (err) {
console.error('[ep_images_extended delete] Error deleting image:', err);
}
}, 'deleteImage', true);
}
hideFormatMenu();
}
}
});
}, 'image_resize_listeners', true);
};
function _getLineNumberOfElement(element) {
let currentElement = element;
let count = 0;
while (currentElement = currentElement.previousElementSibling) {
count++;
}
return count;
}
exports.aceEditorCSS = (hookName, context) => {
// console.log('[ep_images_extended] aceEditorCSS called - loading CSS file');
return [
'ep_images_extended/static/css/ace.css'
];
};
exports.aceRegisterBlockElements = () => ['img'];
exports.aceCreateDomLine = (hookName, args, cb) => {
if (args.cls && args.cls.indexOf('image:') >= 0) {
const clss = [];
let imageId = null;
const argClss = args.cls.split(' ');
for (let i = 0; i < argClss.length; i++) {
const cls = argClss[i];
clss.push(cls);
if (cls.startsWith('image-id-')) {
imageId = cls.substring(9);
}
}
clss.push('inline-image', 'character', 'image-placeholder');
const handleHtml =
'<span class="image-resize-handle br" contenteditable="false"></span>';
// The 'cls' in the modifier will be applied to the *outermost* span ACE creates for the line segment.
// We will add data-image-id to this span in acePostWriteDomLineHTML.
const modifier = {
extraOpenTags: `<span class="image-inner"></span>${handleHtml}`,
extraCloseTags: '',
cls: clss.join(' '),
};
return cb([modifier]);
} else {
return cb();
}
};
const Changeset = require('ep_etherpad-lite/static/js/Changeset');
exports.acePostWriteDomLineHTML = (hookName, context) => {
const lineNode = context.node;
if (!lineNode) return;
const placeholders = lineNode.querySelectorAll('span.image-placeholder');
placeholders.forEach((placeholder, index) => {
const outerSpan = placeholder;
const innerSpan = outerSpan.querySelector('span.image-inner');
if (!innerSpan) return;
// Ensure both outer and inner spans are non-editable so cursor keys cannot land inside
if (!innerSpan.hasAttribute('contenteditable')) {
innerSpan.setAttribute('contenteditable', 'false');
}
if (!outerSpan.hasAttribute('contenteditable')) {
outerSpan.setAttribute('contenteditable', 'false');
}
let escapedSrc = null;
let imageWidth = null;
let imageCssAspectRatioVal = null;
let imageFloatVal = null;
let imageIdFromClass = null;
const classes = outerSpan.className.split(' ');
for (const cls of classes) {
if (cls.startsWith('image:')) {
escapedSrc = cls.substring(6);
} else if (cls.startsWith('image-width:')) {
const widthValue = cls.substring(12);
if (/\d+px$/.test(widthValue)) {
imageWidth = widthValue;
}
} else if (cls.startsWith('imageCssAspectRatio:')) {
imageCssAspectRatioVal = cls.substring(20);
} else if (cls.startsWith('image-float:')) {
imageFloatVal = cls.substring(12);
} else if (cls.startsWith('image-id-')) {
imageIdFromClass = cls.substring(9);
}
}
if (imageIdFromClass) {
outerSpan.setAttribute('data-image-id', imageIdFromClass);
} else {
// If it's an old image without an id class from a previous version, try to remove data-image-id if it exists
if (outerSpan.hasAttribute('data-image-id')) {
outerSpan.removeAttribute('data-image-id');
}
}
const currentDataImageId = outerSpan.getAttribute('data-image-id');
// acePostWriteDomLineHTML is part of the rendering pipeline, so inline styles should be safe here
if (imageWidth) {
innerSpan.style.width = imageWidth;
} // else { /* Optional: Apply a default width? */ }
if (imageCssAspectRatioVal) {
innerSpan.style.setProperty('--image-css-aspect-ratio', imageCssAspectRatioVal);
} else {
innerSpan.style.setProperty('--image-css-aspect-ratio', '1');
}
innerSpan.style.removeProperty('height');
if (escapedSrc) {
try {
const src = decodeURIComponent(escapedSrc);
if (src && (src.startsWith('data:') || src.startsWith('http') || src.startsWith('/'))) {
innerSpan.style.setProperty('--image-src', `url("${src}")`);
} // else { /* Invalid unescaped src warning removed */ }
} catch (e) {
console.error(`[ep_images_extended acePostWriteDomLineHTML] Error setting CSS var for placeholder #${index}:`, e);
}
} // else { /* Placeholder found, but no image:* class warning removed */ }
// Apply float style classes based on the attribute
outerSpan.classList.remove('image-float-left', 'image-float-right', 'image-float-none');
if (imageFloatVal) {
switch (imageFloatVal) {
case 'left':
outerSpan.classList.add('image-float-left');
break;
case 'right':
outerSpan.classList.add('image-float-right');
break;
case 'none':
case 'inline':
outerSpan.classList.add('image-float-none');
break;
}
} else {
// Default to inline/none if no float attribute
outerSpan.classList.add('image-float-none');
}
// Selection styling is now handled purely via CSS using CSS custom property
// No DOM modifications needed here to avoid triggering content collection
// Remove the old data-image-unique-id if it exists from previous versions
if (outerSpan.hasAttribute('data-image-unique-id')) {
outerSpan.removeAttribute('data-image-unique-id');
}
});
};
exports.aceAttribClasses = (hook, attr) => {
return [];
};
exports.collectContentPost = function(name, context) {
const node = context.node;
const state = context.state;
const tname = context.tname;
if (tname === 'span' && node && node.classList && node.classList.contains('image-inner')) {
const innerNode = node;
// let widthPx = null; // Not needed to initialize here
// let heightPx = null; // Not needed to initialize here
if (innerNode.style && innerNode.style.width) {
const widthMatch = innerNode.style.width.match(/^(\d+)(?:px)?$/);
if (widthMatch && widthMatch[1]) {
const widthVal = parseInt(widthMatch[1], 10);
if (!isNaN(widthVal) && widthVal > 0) {
let widthToAttrib = `${widthVal}px`;
if (innerNode.offsetWidth && innerNode.offsetWidth !== widthVal) {
widthToAttrib = `${innerNode.offsetWidth}px`;
}
state.attribs = state.attribs || {};
state.attribs['image-width'] = widthToAttrib;
} // else { /* Parsed width not positive warning removed */ }
} // else { /* Could not parse width warning removed */ }
} // else { /* style.width missing warning removed; decision not to delete attribute if style missing */ }
if (innerNode.style && innerNode.style.height) {
const heightMatch = innerNode.style.height.match(/^(\d+)(?:px)?$/);
if (heightMatch && heightMatch[1]) {
const heightVal = parseInt(heightMatch[1], 10);
if (!isNaN(heightVal) && heightVal > 0) {
state.attribs = state.attribs || {};
state.attribs['image-height'] = `${heightVal}px`;
} // else { /* Parsed height not positive warning removed */ }
} // else { /* Could not parse height warning removed */ }
} // else { /* style.height missing warning removed */ }
const computedStyle = window.getComputedStyle(innerNode);
const cssAspectRatioFromVar = computedStyle.getPropertyValue('--image-css-aspect-ratio');
if (cssAspectRatioFromVar && cssAspectRatioFromVar.trim() !== '') {
state.attribs = state.attribs || {};
state.attribs['imageCssAspectRatio'] = cssAspectRatioFromVar.trim();
} else {
if (innerNode.offsetWidth > 0 && innerNode.offsetHeight > 0) {
const calculatedCssAspectRatio = (innerNode.offsetWidth / innerNode.offsetHeight).toFixed(4);
state.attribs = state.attribs || {};
state.attribs['imageCssAspectRatio'] = calculatedCssAspectRatio;
}
}
// Preserve float attribute by checking the outer span's classes
const outerNode = innerNode.parentElement;
if (outerNode && outerNode.classList) {
let floatValue = null;
if (outerNode.classList.contains('image-float-left')) {
floatValue = 'left';
} else if (outerNode.classList.contains('image-float-right')) {
floatValue = 'right';
} else if (outerNode.classList.contains('image-float-none')) {
floatValue = 'none';
}
if (floatValue) {
state.attribs = state.attribs || {};
state.attribs['image-float'] = floatValue;
}
}
// NEW: Preserve image-id attribute
if (outerNode && outerNode.getAttribute('data-image-id')) {
const imageId = outerNode.getAttribute('data-image-id');
if (imageId) {
state.attribs = state.attribs || {};
state.attribs['image-id'] = imageId;
}
}
}
};
exports.aceKeyEvent = (hookName, context, cb) => {
const { evt, editorInfo } = context;
if (evt.type !== 'keydown') return cb(false);
const key = evt.key;
// Only special-casing Backspace, Delete, printable characters
if (!['Backspace', 'Delete'].includes(key) && key.length !== 1) return cb(false);
const ace = editorInfo;
const rep = ace.ace_getRep();
if (!rep || !rep.selStart || !rep.selEnd) return cb(false);
// Only handle collapsed selections on a single line
if (rep.selStart[0] !== rep.selEnd[0] || rep.selStart[1] !== rep.selEnd[1]) return cb(false);
const lineNumber = rep.selStart[0];
let col = rep.selStart[1];
const lineObj = rep.lines.atIndex(lineNumber);
if (!lineObj) return cb(false);
const lineText = lineObj.text;
const placeholders = getAllPlaceholderRanges(lineText);
if (placeholders.length === 0) return cb(false);
const hit = placeholders.find(r => col >= r.colStart && col <= r.colStart + r.patternLength);
const afterHit = placeholders.find(r => col === r.colStart + r.patternLength);
const beforeHit = placeholders.find(r => col === r.colStart);
// Case 1: cursor inside placeholder – move it to end of placeholder to keep typing out
if (hit && (col > hit.colStart && col < hit.colStart + hit.patternLength)) {
ace.ace_performSelectionChange([lineNumber, hit.colStart + hit.patternLength], [lineNumber, hit.colStart + hit.patternLength], false);
evt.preventDefault();
return cb(true);
}
// Case 2: Backspace immediately after placeholder – delete whole placeholder
if (key === 'Backspace' && afterHit) {
ace.ace_replaceRange([lineNumber, afterHit.colStart], [lineNumber, afterHit.colStart + afterHit.patternLength], '');
evt.preventDefault();
return cb(true);
}
// Case 3: Delete immediately before placeholder – delete whole placeholder
if (key === 'Delete' && beforeHit) {
ace.ace_replaceRange([lineNumber, beforeHit.colStart], [lineNumber, beforeHit.colStart + beforeHit.patternLength], '');
evt.preventDefault();
return cb(true);
}
return cb(false);
};
const doInsertImage = function (src, widthPx, heightPx) {
const ZWSP = '\u200B';
const PLACEHOLDER = '\u200B';
const editorInfo = this.editorInfo;
const rep = editorInfo.ace_getRep();
const docMan = this.documentAttributeManager;
if (!editorInfo || !rep || !rep.selStart || !docMan || !src) {
console.error('[ep_images_extended doInsertImage] Missing context or src');
return;
}
const cursorPos = rep.selStart;
const insertText = ZWSP + PLACEHOLDER + ZWSP; // REMOVED trailing space ' '
// Insert the image placeholder text with trailing space
editorInfo.ace_replaceRange(cursorPos, cursorPos, insertText);
const imageAttrStart = [cursorPos[0], cursorPos[1] + ZWSP.length];
const imageAttrEnd = [cursorPos[0], cursorPos[1] + ZWSP.length + PLACEHOLDER.length];
const escapedSrc = encodeURIComponent(src);
const attributesToSet = [['image', escapedSrc]];
if (widthPx && /^\d+px$/.test(widthPx)) {
attributesToSet.push(['image-width', widthPx]);
}
if (heightPx && /^\d+px$/.test(heightPx)) {
attributesToSet.push(['image-height', heightPx]);
}
if (widthPx && heightPx) {
const naturalWidthNum = parseInt(widthPx, 10);
const naturalHeightNum = parseInt(heightPx, 10);
if (naturalWidthNum > 0 && naturalHeightNum > 0) {
const cssAspectRatio = (naturalWidthNum / naturalHeightNum).toFixed(4);
attributesToSet.push(['imageCssAspectRatio', cssAspectRatio]);
}
}
// NEW: Add image-id attribute
const imageId = generateUUID();
attributesToSet.push(['image-id', imageId]);
// Apply the image attributes
docMan.setAttributesOnRange(imageAttrStart, imageAttrEnd, attributesToSet);
// CRITICAL FIX: Move cursor after the inserted image to prevent overlapping placeholders
// This ensures that if user inserts multiple images in sequence, they don't overlap
const newCursorPos = [cursorPos[0], cursorPos[1] + insertText.length];
editorInfo.ace_performSelectionChange(newCursorPos, newCursorPos, false);
};
// NEW helper: build a fresh document range for an image placeholder using the DOM element.
// Returns null if it cannot determine the range.
function getPlaceholderRangeFromOuterSpan(outerSpan, ace, opts = {wholePlaceholder: true}) {
try {
if (!outerSpan) return null;
const lineElement = $(outerSpan).closest('.ace-line')[0];
if (!lineElement) return null;
const imagePlaceholders = Array.from(lineElement.querySelectorAll('.inline-image.image-placeholder'));
const imageIndex = imagePlaceholders.indexOf(outerSpan);
if (imageIndex === -1) return null;
const lineNumber = _getLineNumberOfElement(lineElement);
const rep = ace.ace_getRep();
if (!rep.lines.atIndex(lineNumber)) return null;
const lineText = rep.lines.atIndex(lineNumber).text;
const placeholderInfo = findImagePlaceholderPosition(lineText, imageIndex, lineElement);
if (!placeholderInfo) return null;
if (opts.wholePlaceholder) {
return [
[lineNumber, placeholderInfo.colStart],
[lineNumber, placeholderInfo.colStart + placeholderInfo.patternLength]
];
}
// middle-character fallback
const mid = placeholderInfo.colStart + Math.floor(placeholderInfo.patternLength / 2);
return [[lineNumber, mid], [lineNumber, mid + 1]];
} catch (e) {
console.error('[ep_images_extended] getPlaceholderRangeFromOuterSpan error:', e);
return null;
}
}
// Helper: return all placeholder ranges (startCol,length) within a line
function getAllPlaceholderRanges(lineText) {
const placeholderPatterns = [
'\u200B\u200B\u200B', // 3 × ZWSP
'\u200B\u00A0\u200B', // ZWSP NBSP ZWSP
'\u200B\u200B', // 2 × ZWSP
'\u00A0', // single NBSP
'\u200B' // single ZWSP
].map(p => p.replace(/\\u200B/g, '\u200B').replace(/\\u00A0/g, '\u00A0')); // real chars
const ranges = [];
let idx = 0;
while (idx < lineText.length) {
let matched = false;
for (const pattern of placeholderPatterns) {
if (lineText.startsWith(pattern, idx)) {
ranges.push({colStart: idx, patternLength: pattern.length});
idx += pattern.length;
matched = true;
break;
}
}
if (!matched) idx += 1;
}
return ranges;
}
/* ------------------------------------------------------------------
* Image rendering support for the read-only time-slider view
* ------------------------------------------------------------------ */
/**
* Apply inline styles to the image placeholder `outerSpan` so that the image
* becomes visible in read-only contexts (timeslider or export preview).
*/
function _applyImageStylesForElement(outerSpan) {
if (!outerSpan) return;
const innerSpan = outerSpan.querySelector('span.image-inner');
if (!innerSpan) return;
// Recover attribute values from the CSS-classes that ACE placed on the span.
let escSrc = null, width = null, aspect = null, floatVal = null;
for (const cls of outerSpan.className.split(' ')) {
if (cls.startsWith('image:')) escSrc = cls.slice(6);
if (cls.startsWith('image-width:')) width = cls.slice(12);
if (cls.startsWith('imageCssAspectRatio:')) aspect = cls.slice(20);
if (cls.startsWith('image-float:')) floatVal = cls.slice(12);
}
// Set CSS custom properties / inline styles exactly like acePostWriteDomLineHTML.
if (escSrc) {
try {
const decoded = decodeURIComponent(escSrc);
innerSpan.style.setProperty('--image-src', `url("${decoded}")`);
} catch (_) { /* ignore */ }
}
if (width) innerSpan.style.width = width;
if (aspect) innerSpan.style.setProperty('--image-css-aspect-ratio', aspect);
// Float behaviour (left / right / inline)
outerSpan.classList.remove('image-float-left', 'image-float-right', 'image-float-none');
switch (floatVal) {
case 'left': outerSpan.classList.add('image-float-left'); break;
case 'right': outerSpan.classList.add('image-float-right'); break;
default: outerSpan.classList.add('image-float-none');
}
}
/**
* Client-side hook that runs in the time-slider once the UI is ready.
* It ensures all image placeholders are hydrated with the correct styles and
* repeats that every time the slider jumps to a different revision.
*/
exports.postTimesliderInit = () => {
// Helper that (re)applies styles to every image currently in the DOM.
const renderAllImages = () => {
const $placeholders = $('#innerdocbody').find('span.inline-image.image-placeholder');
$placeholders.each((_idx, el) => _applyImageStylesForElement(el));
};
// Initial render for the first revision shown.
renderAllImages();
// Re-render after every slider movement (revision change).
if (window.BroadcastSlider && typeof window.BroadcastSlider.onSlider === 'function') {
window.BroadcastSlider.onSlider(() => {
// Allow the DOM update from broadcast.js to finish first.
setTimeout(renderAllImages, 0);
});
}
};