diff --git a/public/app/editor/tinymce_5_settings.js b/public/app/editor/tinymce_5_settings.js
index b04849ac5..b58cef6f2 100644
--- a/public/app/editor/tinymce_5_settings.js
+++ b/public/app/editor/tinymce_5_settings.js
@@ -1,3 +1,8 @@
+const ASSET_URL_MEDIA_SELECTOR =
+ 'img[src^="asset://"], audio[src^="asset://"], video[src^="asset://"], iframe[src^="asset://"]';
+const PLACEHOLDER_IMAGE_DATA_URL =
+ 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
+
var $exeTinyMCE = {
// imagetools is disabled because it generates base64 images
// colorpicker contextmenu textcolor . Añadidos al core, no hace falta añadir en plugins?
@@ -333,7 +338,7 @@ var $exeTinyMCE = {
// result = { assetUrl, blobUrl, asset }
const field = document.getElementById(field_name);
if (field) {
- field.value = result.blobUrl;
+ field.value = result.assetUrl || result.blobUrl || '';
// Trigger change event for TinyMCE to pick up
field.dispatchEvent(new Event('change'));
}
@@ -383,26 +388,12 @@ var $exeTinyMCE = {
return;
}
- // Use blob URL directly - it's already in AssetManager cache
- // When images_upload_handler is triggered by TinyMCE (automatic_uploads: true),
- // it will find this blob URL in reverseBlobCache and return immediately
- // without re-processing. Later, convertBlobUrlsToAssetUrls() will convert
- // blob:// to asset:// for persistence.
- const assetManager = window.eXeLearning?.app?.project?._yjsBridge?.assetManager;
-
- // Ensure blob URL is in cache (it should be, but verify)
- if (assetManager && result.blobUrl && result.asset?.id) {
- if (!assetManager.reverseBlobCache.has(result.blobUrl)) {
- assetManager.reverseBlobCache.set(result.blobUrl, result.asset.id);
- assetManager.blobURLCache.set(result.asset.id, result.blobUrl);
- }
- }
-
- cb(result.blobUrl, {
+ // Keep asset:// in the editor model; rendering resolves it later.
+ cb(result.assetUrl || result.blobUrl || '', {
title: result.asset.filename || '',
text: result.asset.filename || '',
alt: '',
- 'data-asset-id': result.asset.id // CRITICAL: Used by convertBlobURLsToAssetRefs
+ 'data-asset-id': result.asset.id
});
}
});
@@ -423,8 +414,11 @@ var $exeTinyMCE = {
if (assetManager && blobUri && blobUri.startsWith('blob:')) {
// Check if this blob URL is in our cache (meaning it's from AssetManager)
if (assetManager.reverseBlobCache.has(blobUri)) {
- // Already an asset, no upload needed - just return the blob URL
- success(blobUri);
+ const assetId = assetManager.reverseBlobCache.get(blobUri);
+ const asset = assetManager.getAssetById?.(assetId) || assetManager.getAssetMetadata?.(assetId);
+ const assetUrl = assetManager.getAssetUrl?.(assetId, asset?.filename || asset?.name) || `asset://${assetId}`;
+ // Return the canonical asset:// URL so users never see the temporary blob URL.
+ success(assetUrl, { 'data-asset-id': assetId });
return;
}
}
@@ -439,21 +433,8 @@ var $exeTinyMCE = {
// Extract UUID from asset:// URL (insertImage returns "asset://uuid/filename")
const assetId = assetManager.extractAssetId(assetUrl);
-
- // Get or create blob URL for the asset (using synced method to ensure reverseBlobCache consistency)
- let newBlobUrl = assetManager.getBlobURLSynced?.(assetId) ?? assetManager.blobURLCache.get(assetId);
- if (!newBlobUrl) {
- // Use the original blob directly (works for both new and deduplicated assets)
- // since we already have it in memory
- newBlobUrl = URL.createObjectURL(blob);
- assetManager.blobURLCache.set(assetId, newBlobUrl);
- assetManager.reverseBlobCache.set(newBlobUrl, assetId);
- } else if (!assetManager.reverseBlobCache.has(newBlobUrl)) {
- // CRITICAL: Ensure reverseBlobCache is synced - this is required for convertBlobUrlsToAssetUrls
- assetManager.reverseBlobCache.set(newBlobUrl, assetId);
- }
- // CRITICAL: Pass data-asset-id so convertBlobURLsToAssetRefs can convert even if blob URL changes
- success(newBlobUrl, { 'data-asset-id': assetId });
+ // Persist the canonical asset:// URL and let the editor resolve it for display.
+ success(assetUrl, { 'data-asset-id': assetId });
} catch (err) {
console.error('[TinyMCE] Failed to store in AssetManager:', err);
failure(_('Error storing image'));
@@ -570,6 +551,12 @@ var $exeTinyMCE = {
this.buttons3,
],
setup: function (ed) {
+ ed.on('BeforeSetContent', function(e) {
+ if (typeof e.content !== 'string') return;
+
+ e.content = $exeTinyMCE.prepareContentForEditorLoad(e.content);
+ });
+
// Register SetContent handler BEFORE content is loaded
// This is critical for resolving asset:// URLs in the initial content
ed.on('SetContent', function(e) {
@@ -596,6 +583,13 @@ var $exeTinyMCE = {
$exeTinyMCE.resolveAssetUrlsInEditor(ed);
});
+
+ ed.on('GetContent', function(e) {
+ const assetManager = window.eXeLearning?.app?.project?._yjsBridge?.assetManager;
+ if (assetManager?.prepareHtmlForSync && typeof e.content === 'string') {
+ e.content = assetManager.prepareHtmlForSync(e.content);
+ }
+ });
},
init_instance_callback: function (ed) {
if (mode == 'multiple') {
@@ -611,7 +605,7 @@ var $exeTinyMCE = {
// Note: SetContent handler is now registered in setup() callback
// to catch the initial content load before init_instance_callback runs
- // Also observe DOM changes for dynamically inserted media (e.g., audio recorder, PDF embed)
+ // Also observe DOM changes for dynamically inserted media (e.g., images, audio recorder, PDF embed)
const editorBody = ed.getBody();
if (editorBody) {
const observer = new MutationObserver(function(mutations) {
@@ -620,8 +614,8 @@ var $exeTinyMCE = {
if (mutation.type === 'childList') {
for (const node of mutation.addedNodes) {
if (node.nodeType === 1) {
- const hasAssetUrl = node.querySelector?.('audio[src^="asset://"], video[src^="asset://"], iframe[src^="asset://"]') ||
- (node.matches?.('audio[src^="asset://"], video[src^="asset://"], iframe[src^="asset://"]'));
+ const hasAssetUrl = node.querySelector?.(ASSET_URL_MEDIA_SELECTOR) ||
+ (node.matches?.(ASSET_URL_MEDIA_SELECTOR));
if (hasAssetUrl) {
hasNewMedia = true;
break;
@@ -646,6 +640,22 @@ var $exeTinyMCE = {
}); //End tinymce
},
+ prepareContentForEditorLoad: function (content) {
+ if (typeof content !== 'string' || !content.includes('asset://')) {
+ return content;
+ }
+
+ const assetManager = window.eXeLearning?.app?.project?._yjsBridge?.assetManager;
+ if (!assetManager?.resolveHTMLAssetsSync) {
+ return content;
+ }
+
+ return assetManager.resolveHTMLAssetsSync(content, {
+ usePlaceholder: true,
+ addTracking: true,
+ });
+ },
+
getSchema: function () {
var s = 'html5';
return s;
@@ -705,8 +715,9 @@ var $exeTinyMCE = {
},
/**
- * Resolve asset:// URLs to blob:// URLs for audio/video elements in TinyMCE editor
- * This allows media to play within the editor while keeping asset:// URLs for persistence
+ * Resolve asset:// URLs to blob:// URLs for editor-rendered media in TinyMCE.
+ * This allows images and media to render within the editor while keeping asset://
+ * URLs as the persisted format.
*
* NOTE: We intentionally DO NOT resolve iframes (PDFs) because:
* 1. TinyMCE strips custom attributes like data-asset-src when processing media elements
@@ -722,21 +733,22 @@ var $exeTinyMCE = {
const body = ed.getBody();
if (!body) return;
- // Find audio, video, and iframe elements with asset:// URLs
- const mediaElements = body.querySelectorAll('audio[src^="asset://"], video[src^="asset://"], iframe[src^="asset://"]');
+ // Find image, audio, video, and iframe elements with asset:// URLs
+ const mediaElements = body.querySelectorAll(ASSET_URL_MEDIA_SELECTOR);
for (const media of mediaElements) {
const assetUrl = media.getAttribute('src');
if (!assetUrl || !assetUrl.startsWith('asset://')) continue;
const isIframe = media.tagName.toLowerCase() === 'iframe';
+ const isImage = media.tagName.toLowerCase() === 'img';
- // For audio/video: Skip if already resolved (has data-asset-src)
+ // For images/audio/video: Skip if already resolved (has data-asset-src)
// For iframes: Skip if src is already a blob URL (already resolved)
if (!isIframe && media.getAttribute('data-asset-src')) continue;
if (isIframe && media.getAttribute('src').startsWith('blob:')) continue;
- // For audio/video: Store the original asset URL in data-asset-src
+ // For images/audio/video: Store the original asset URL in data-asset-src
// For iframes: DON'T add data-asset-src - TinyMCE preserves the URL via data-mce-p-src
// on the parent span.mce-preview-object
if (!isIframe) {
@@ -768,10 +780,18 @@ var $exeTinyMCE = {
});
}
} else {
- // Resolve to blob URL asynchronously (for audio, video, PDF iframes)
+ if (isImage) {
+ media.setAttribute('src', PLACEHOLDER_IMAGE_DATA_URL);
+ media.setAttribute('data-mce-src', PLACEHOLDER_IMAGE_DATA_URL);
+ }
+
+ // Resolve to blob URL asynchronously (for images, audio, video, PDF iframes)
assetManager.resolveAssetURL(assetUrl).then(function(blobUrl) {
if (blobUrl) {
media.setAttribute('src', blobUrl);
+ if (isImage) {
+ media.setAttribute('data-mce-src', blobUrl);
+ }
}
}).catch(function(err) {
console.warn('[TinyMCE] Failed to resolve asset URL:', assetUrl, err);
diff --git a/public/app/editor/tinymce_5_settings.test.js b/public/app/editor/tinymce_5_settings.test.js
index 50c8f5e9a..8e6eae90e 100644
--- a/public/app/editor/tinymce_5_settings.test.js
+++ b/public/app/editor/tinymce_5_settings.test.js
@@ -261,7 +261,9 @@ describe('TinyMCE 5 Settings', () => {
// SetContent handler is now registered in setup callback (before content loads)
config.setup(mockEditor);
+ expect(mockEditor.on).toHaveBeenCalledWith('BeforeSetContent', expect.any(Function));
expect(mockEditor.on).toHaveBeenCalledWith('SetContent', expect.any(Function));
+ expect(mockEditor.on).toHaveBeenCalledWith('GetContent', expect.any(Function));
// init_instance_callback runs after content loads
mockEditor.on.mockClear();
@@ -476,14 +478,14 @@ describe('TinyMCE 5 Settings', () => {
window.eXeLearning.app.modals = {
filemanager: {
show: ({ onSelect }) => {
- onSelect({ blobUrl: 'blob://file' });
+ onSelect({ assetUrl: 'asset://file.pdf', blobUrl: 'blob://file' });
},
},
};
config.file_browser_callback('field-1');
- expect(input.value).toBe('blob://file');
+ expect(input.value).toBe('asset://file.pdf');
expect(dispatchSpy).toHaveBeenCalled();
});
@@ -524,20 +526,11 @@ describe('TinyMCE 5 Settings', () => {
});
});
- it('file_picker_callback uses blob url directly for images', async () => {
+ it('file_picker_callback uses asset url directly for images', async () => {
globalThis.$exeTinyMCE.init('single', '#editor');
const config = globalThis.tinymce.init.mock.calls[0][0];
const cb = vi.fn();
- // Setup mock AssetManager with caches
- const mockAssetManager = {
- reverseBlobCache: new Map(),
- blobURLCache: new Map(),
- };
- window.eXeLearning.app.project = {
- _yjsBridge: { assetManager: mockAssetManager },
- };
-
window.eXeLearning.app.modals = {
filemanager: {
show: ({ onSelect }) => {
@@ -553,20 +546,12 @@ describe('TinyMCE 5 Settings', () => {
await config.file_picker_callback(cb);
await new Promise((resolve) => setTimeout(resolve, 0));
- // Should use blob URL directly (not convert to data:URL)
- // CRITICAL: Must include data-asset-id for reliable blob→asset conversion
- // alt is empty by default - user should provide their own description
- // (exeimage plugin shows warning if alt is empty when saving)
- expect(cb).toHaveBeenCalledWith('blob://file', {
+ expect(cb).toHaveBeenCalledWith('asset://abc123/file.png', {
title: 'file.png',
text: 'file.png',
alt: '',
- 'data-asset-id': 'abc123', // CRITICAL: Used by convertBlobURLsToAssetRefs
+ 'data-asset-id': 'abc123',
});
-
- // Should ensure blob URL is in cache for later conversion
- expect(mockAssetManager.reverseBlobCache.has('blob://file')).toBe(true);
- expect(mockAssetManager.reverseBlobCache.get('blob://file')).toBe('abc123');
});
it('file_picker_callback works without AssetManager', async () => {
@@ -592,10 +577,7 @@ describe('TinyMCE 5 Settings', () => {
await config.file_picker_callback(cb);
await new Promise((resolve) => setTimeout(resolve, 0));
- // Should still work with blob URL even without AssetManager
- // data-asset-id is always included for reliable conversion
- // alt is empty by default - exeimage plugin warns if empty when saving
- expect(cb).toHaveBeenCalledWith('blob://file', {
+ expect(cb).toHaveBeenCalledWith('asset://file.png', {
title: 'file.png',
text: 'file.png',
alt: '',
@@ -603,21 +585,11 @@ describe('TinyMCE 5 Settings', () => {
});
});
- it('file_picker_callback registers UUID (not asset URL) in reverseBlobCache', async () => {
- // This is the CRITICAL test for the bug fix
- // The bug was that asset:// URLs were stored in reverseBlobCache instead of UUIDs
+ it('file_picker_callback keeps asset urls user-visible for source editing', async () => {
globalThis.$exeTinyMCE.init('single', '#editor');
const config = globalThis.tinymce.init.mock.calls[0][0];
const cb = vi.fn();
- const mockAssetManager = {
- reverseBlobCache: new Map(),
- blobURLCache: new Map(),
- };
- window.eXeLearning.app.project = {
- _yjsBridge: { assetManager: mockAssetManager },
- };
-
const assetUUID = '0b034dc2-1fcb-2be8-5fbd-e49a05d9bac0';
window.eXeLearning.app.modals = {
filemanager: {
@@ -634,13 +606,12 @@ describe('TinyMCE 5 Settings', () => {
await config.file_picker_callback(cb);
await new Promise((resolve) => setTimeout(resolve, 0));
- // CRITICAL: reverseBlobCache should contain the UUID, NOT the asset:// URL
- const cachedValue = mockAssetManager.reverseBlobCache.get('blob:http://localhost:8081/test-blob-url');
- expect(cachedValue).toBe(assetUUID);
- // Should NOT be an asset:// URL
- expect(cachedValue).not.toContain('asset://');
- // Should be a valid UUID
- expect(cachedValue).toMatch(/^[a-f0-9-]+$/i);
+ expect(cb).toHaveBeenCalledWith(`asset://${assetUUID}/image.jpg`, {
+ title: 'image.jpg',
+ text: 'image.jpg',
+ alt: '',
+ 'data-asset-id': assetUUID,
+ });
});
it('file_picker_callback handles video files with correct cache registration', async () => {
@@ -672,19 +643,15 @@ describe('TinyMCE 5 Settings', () => {
await config.file_picker_callback(cb);
await new Promise((resolve) => setTimeout(resolve, 0));
- // Videos should also use blob URL directly with data-asset-id
- // alt is empty by default (videos don't require alt text, only images do)
- expect(cb).toHaveBeenCalledWith('blob:http://localhost:8081/video-blob', {
+ expect(cb).toHaveBeenCalledWith(`asset://${assetUUID}/video.mp4`, {
title: 'video.mp4',
text: 'video.mp4',
alt: '',
- 'data-asset-id': assetUUID, // CRITICAL: Used by convertBlobURLsToAssetRefs
+ 'data-asset-id': assetUUID,
});
- // And should register UUID (not asset:// URL) in cache
- expect(mockAssetManager.reverseBlobCache.get('blob:http://localhost:8081/video-blob')).toBe(assetUUID);
});
- it('images_upload_handler reuses existing blob urls', async () => {
+ it('images_upload_handler rewrites existing blob urls to asset urls', async () => {
globalThis.$exeTinyMCE.init('single', '#editor');
const config = globalThis.tinymce.init.mock.calls[0][0];
const success = vi.fn();
@@ -696,13 +663,14 @@ describe('TinyMCE 5 Settings', () => {
_yjsBridge: {
assetManager: {
reverseBlobCache: new Map([['blob:1', 'asset-1']]),
+ getAssetUrl: vi.fn().mockReturnValue('asset://asset-1.png'),
},
},
};
await config.images_upload_handler(blobInfo, success, failure);
- expect(success).toHaveBeenCalledWith('blob:1');
+ expect(success).toHaveBeenCalledWith('asset://asset-1.png', { 'data-asset-id': 'asset-1' });
expect(failure).not.toHaveBeenCalled();
});
@@ -716,13 +684,11 @@ describe('TinyMCE 5 Settings', () => {
blob: () => new Blob(['data'], { type: 'image/png' }),
filename: () => 'image.png',
};
- const blobURLCache = new Map();
- const reverseBlobCache = new Map();
window.eXeLearning.app.project = {
_yjsBridge: {
assetManager: {
- reverseBlobCache,
- blobURLCache,
+ reverseBlobCache: new Map(),
+ blobURLCache: new Map(),
// insertImage returns full asset:// URL
insertImage: vi.fn().mockResolvedValue('asset://asset-2/image.png'),
// extractAssetId extracts the UUID from the URL
@@ -730,13 +696,10 @@ describe('TinyMCE 5 Settings', () => {
},
},
};
- globalThis.URL.createObjectURL = vi.fn(() => 'blob:created');
await config.images_upload_handler(blobInfo, success, failure);
- // CRITICAL: Must include data-asset-id for reliable blob→asset conversion
- expect(success).toHaveBeenCalledWith('blob:created', { 'data-asset-id': 'asset-2' });
- expect(blobURLCache.get('asset-2')).toBe('blob:created');
+ expect(success).toHaveBeenCalledWith('asset://asset-2/image.png', { 'data-asset-id': 'asset-2' });
});
it('images_upload_handler reports insert errors', async () => {
@@ -784,6 +747,32 @@ describe('TinyMCE 5 Settings', () => {
expect(failure).toHaveBeenCalledWith('Media library not available');
});
+ it('GetContent normalizes editor html back to asset urls', () => {
+ globalThis.$exeTinyMCE.init('single', '#editor');
+ const config = globalThis.tinymce.init.mock.calls[0][0];
+ const mockPrepareHtmlForSync = vi.fn().mockReturnValue('
');
+ const mockEditor = {
+ on: vi.fn(),
+ getBody: () => document.createElement('div'),
+ };
+ window.eXeLearning.app.project = {
+ _yjsBridge: {
+ assetManager: {
+ prepareHtmlForSync: mockPrepareHtmlForSync,
+ },
+ },
+ };
+
+ config.setup(mockEditor);
+ const getContentCall = mockEditor.on.mock.calls.find((call) => call[0] === 'GetContent');
+ const event = { content: '' };
+
+ getContentCall[1](event);
+
+ expect(mockPrepareHtmlForSync).toHaveBeenCalledWith('
');
+ expect(event.content).toBe('
');
+ });
+
describe('resolveAssetUrlsInEditor', () => {
it('exists as a function', () => {
expect(typeof globalThis.$exeTinyMCE.resolveAssetUrlsInEditor).toBe('function');
@@ -839,6 +828,35 @@ describe('TinyMCE 5 Settings', () => {
expect(audio.getAttribute('data-asset-src')).toBe('asset://test-uuid-1234/audio.webm');
});
+ it('resolves image element asset:// URLs to blob:// URLs', async () => {
+ const body = document.createElement('div');
+ const image = document.createElement('img');
+ image.setAttribute('src', 'asset://image-uuid/file.png');
+ image.setAttribute('data-mce-src', 'asset://image-uuid/file.png');
+ body.appendChild(image);
+
+ const mockBlobUrl = 'blob:http://localhost/image-blob';
+ const mockAssetManager = {
+ resolveAssetURL: vi.fn().mockResolvedValue(mockBlobUrl),
+ };
+ window.eXeLearning.app.project = {
+ _yjsBridge: { assetManager: mockAssetManager },
+ };
+
+ const mockEditor = {
+ getBody: () => body,
+ };
+
+ globalThis.$exeTinyMCE.resolveAssetUrlsInEditor(mockEditor);
+
+ await new Promise((resolve) => setTimeout(resolve, 10));
+
+ expect(mockAssetManager.resolveAssetURL).toHaveBeenCalledWith('asset://image-uuid/file.png');
+ expect(image.getAttribute('src')).toBe(mockBlobUrl);
+ expect(image.getAttribute('data-mce-src')).toBe(mockBlobUrl);
+ expect(image.getAttribute('data-asset-src')).toBe('asset://image-uuid/file.png');
+ });
+
it('resolves video element asset:// URLs to blob:// URLs', async () => {
const body = document.createElement('div');
const video = document.createElement('video');
@@ -954,6 +972,41 @@ describe('TinyMCE 5 Settings', () => {
warnSpy.mockRestore();
});
+ it('observes dynamically inserted asset:// images in the editor body', async () => {
+ globalThis.$exeTinyMCE.init('single', '#editor');
+ const config = globalThis.tinymce.init.mock.calls[0][0];
+ const body = document.createElement('div');
+ const mockBlobUrl = 'blob:http://localhost/dynamic-image';
+ const mockAssetManager = {
+ resolveAssetURL: vi.fn().mockResolvedValue(mockBlobUrl),
+ };
+ window.eXeLearning.app.project = {
+ _yjsBridge: { assetManager: mockAssetManager },
+ };
+
+ const listeners = new Map();
+ const mockEditor = {
+ id: 'editor',
+ on: vi.fn((event, handler) => {
+ listeners.set(event, handler);
+ }),
+ getBody: () => body,
+ };
+
+ config.setup(mockEditor);
+ config.init_instance_callback(mockEditor);
+
+ const image = document.createElement('img');
+ image.setAttribute('src', 'asset://dynamic-image/file.png');
+ body.appendChild(image);
+
+ await new Promise((resolve) => setTimeout(resolve, 10));
+
+ expect(mockAssetManager.resolveAssetURL).toHaveBeenCalledWith('asset://dynamic-image/file.png');
+ expect(image.getAttribute('src')).toBe(mockBlobUrl);
+ expect(listeners.get('remove')).toEqual(expect.any(Function));
+ });
+
it('resolves iframe element asset:// URLs to blob:// URLs but does NOT add data-asset-src', async () => {
// Iframes (PDFs) ARE resolved to blob URLs for display in the editor.
// However, we do NOT add data-asset-src because TinyMCE preserves the
@@ -1238,6 +1291,38 @@ describe('TinyMCE 5 Settings', () => {
});
describe('SetContent handler - Bun script stripping', () => {
+ it('rewrites asset urls to runtime urls before TinyMCE parses content', () => {
+ globalThis.$exeTinyMCE.init('single', '#editor');
+ const config = globalThis.tinymce.init.mock.calls[0][0];
+ const resolveHTMLAssetsSync = vi.fn(() => '


Test
'; @@ -1166,6 +1175,16 @@ describe('IdeviceNode', () => { expect(result).toBe('Test
'); }); + it('does not normalize htmlView before opening html iDevice editors', () => { + idevice.idevice = { componentType: 'html' }; + idevice.htmlView = + ''; + + const result = idevice.getSavedData(); + + expect(result).toContain('blob:http://localhost/image-1'); + }); + it('returns htmlView when componentType is undefined', () => { idevice.idevice = {}; idevice.htmlView = 'Default
'; diff --git a/public/app/yjs/AssetManager.js b/public/app/yjs/AssetManager.js index 058e3ca46..399e42e66 100644 --- a/public/app/yjs/AssetManager.js +++ b/public/app/yjs/AssetManager.js @@ -2497,6 +2497,23 @@ class AssetManager { let convertedHTML = html; let conversions = 0; + const logWarn = typeof Logger?.warn === 'function' ? Logger.warn.bind(Logger) : console.warn.bind(console); + const getCanonicalAssetUrl = (rawAssetId) => { + if (!rawAssetId) return ''; + + let assetId = rawAssetId; + if (assetId.startsWith('asset://')) { + assetId = this.extractAssetId(assetId); + } + if (!assetId) return ''; + + const metadata = this.getAssetMetadata?.(assetId); + const filename = metadata?.filename || metadata?.name; + if (typeof this.getAssetUrl === 'function') { + return this.getAssetUrl(assetId, filename); + } + return `asset://${assetId}`; + }; // Strategy 1: Find img/video/audio tags with blob: src and data-asset-id attribute // This is the RELIABLE way - data-asset-id is set when inserting from MediaLibrary @@ -2510,24 +2527,27 @@ class AssetManager { const assetIdMatch = fullAttrs.match(/data-asset-id=(["'])([^"']+)\1/i); if (assetIdMatch) { - const assetId = assetIdMatch[2]; - // Use 'file' as default filename - the actual filename will be resolved on load + const assetUrl = getCanonicalAssetUrl(assetIdMatch[2]); + if (!assetUrl) { + logWarn(`[AssetManager] Cannot recover blob URL from invalid data-asset-id, clearing: ${blobUrl.substring(0, 50)}...`); + return match.replace(blobUrl, ''); + } conversions++; - Logger.log(`[AssetManager] Converted blob→asset via data-asset-id: ${assetId.substring(0, 8)}...`); - return `<${tagName}${before} src=${quote}asset://${assetId}${quote}${after}>`; + Logger.log(`[AssetManager] Converted blob→asset via data-asset-id: ${assetUrl.substring(8, 16)}...`); + return `<${tagName}${before} src=${quote}${assetUrl}${quote}${after}>`; } // Strategy 2: Fall back to reverseBlobCache lookup - const assetId = this.reverseBlobCache.get(blobUrl); - if (assetId) { + const assetUrl = getCanonicalAssetUrl(this.reverseBlobCache.get(blobUrl)); + if (assetUrl) { conversions++; - Logger.log(`[AssetManager] Converted blob→asset via cache: ${assetId.substring(0, 8)}...`); - return match.replace(blobUrl, `asset://${assetId}`); + Logger.log(`[AssetManager] Converted blob→asset via cache: ${assetUrl.substring(8, 16)}...`); + return match.replace(blobUrl, assetUrl); } - // Could not convert - log warning - console.warn(`[AssetManager] FAILED to convert blob URL (no data-asset-id, not in cache): ${blobUrl.substring(0, 50)}...`); - return match; + // Could not convert - clear the invalid blob URL so it is not persisted + logWarn(`[AssetManager] Cannot recover blob URL, clearing: ${blobUrl.substring(0, 50)}...`); + return match.replace(blobUrl, ''); }; convertedHTML = convertedHTML.replace(tagRegex, replaceCallback); @@ -2537,11 +2557,19 @@ class AssetManager { for (const [blobURL, assetId] of this.reverseBlobCache.entries()) { if (convertedHTML.includes(blobURL)) { const escapedBlobURL = blobURL.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - convertedHTML = convertedHTML.replace(new RegExp(escapedBlobURL, 'g'), `asset://${assetId}`); - conversions++; + const assetUrl = getCanonicalAssetUrl(assetId); + if (assetUrl) { + convertedHTML = convertedHTML.replace(new RegExp(escapedBlobURL, 'g'), assetUrl); + conversions++; + } } } + convertedHTML = convertedHTML.replace(/blob:https?:\/\/[^"'\s)]+/g, (blobUrl) => { + logWarn(`[AssetManager] Cannot recover blob URL, clearing: ${blobUrl.substring(0, 50)}...`); + return ''; + }); + if (conversions > 0) { Logger.log(`[AssetManager] convertBlobURLsToAssetRefs: ${conversions} conversion(s) made`); } @@ -2565,10 +2593,18 @@ class AssetManager { // Handles img, video, audio, a tags const regex = /(<(?:img|video|audio|source)[^>]*?)(?:src|href)=(["'])([^"']*)\2([^>]*?)data-asset-url=(["'])([^"']+)\5([^>]*>)/gi; - return html.replace(regex, (match, beforeSrc, quote1, oldSrc, middle, quote2, assetUrl, afterAttr) => { + let result = html.replace(regex, (match, beforeSrc, quote1, oldSrc, middle, quote2, assetUrl, afterAttr) => { // Replace src with asset URL and remove data-asset-url attribute return `${beforeSrc}src=${quote1}${assetUrl}${quote1}${middle}${afterAttr}`; }); + + const assetSrcRegex = /(<(?:audio|video|iframe)[^>]*?)src=(["'])([^"']*)\2([^>]*?)data-asset-src=(["'])([^"']+)\5([^>]*>)/gi; + + result = result.replace(assetSrcRegex, (match, beforeSrc, quote1, oldSrc, middle, quote2, assetUrl, afterAttr) => { + return `${beforeSrc}src=${quote1}${assetUrl}${quote1}${middle}${afterAttr}`; + }); + + return result; } /** diff --git a/public/app/yjs/AssetManager.test.js b/public/app/yjs/AssetManager.test.js index 4a02b3141..63beac52d 100644 --- a/public/app/yjs/AssetManager.test.js +++ b/public/app/yjs/AssetManager.test.js @@ -1117,13 +1117,11 @@ describe('AssetManager', () => { expect(result).toBe(html); }); - it('leaves blob URL unchanged when neither data-asset-id nor cache match', () => { - // Blob URL not in cache, no data-asset-id + it('clears blob URL when neither data-asset-id nor cache match', () => { const html = 'Hello
');
+ const mockAssetManager = {
+ prepareHtmlForSync: mockPrepareHtmlForSync,
+ };
+ window.eXeLearning = {
+ app: {
+ project: {
+ _yjsBridge: {
+ assetManager: mockAssetManager,
+ },
+ },
+ },
+ };
+
+ binding = new YjsTinyMCEBinding(mockEditor, mockYText);
+ mockEditor._content = 'Hello
Hello
Activity content
'); }); - }); + it('does not parse non-wrapper asset html when stripping legacy wrapper', () => { + const createElementSpy = vi.spyOn(document, 'createElement'); + const html = '