From afa52bd5506e920133aebfa93eb077e60179a7d4 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 24 Mar 2026 17:46:48 +0000
Subject: [PATCH 01/10] Initial plan
From 4ee4d39e971d0b7b5e5aa1e1b0b05974a02e9eb7 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 24 Mar 2026 18:00:12 +0000
Subject: [PATCH 02/10] fix: keep asset urls user-visible in editor flows
Co-authored-by: erseco <1876752+erseco@users.noreply.github.com>
Agent-Logs-Url: https://github.com/ateeducacion/exelearning/sessions/5e9ed5fc-eda8-4d78-a513-52c712226203
---
public/app/editor/tinymce_5_settings.js | 53 ++++-----
public/app/editor/tinymce_5_settings.test.js | 108 +++++++++----------
public/app/yjs/AssetManager.js | 64 ++++++++---
public/app/yjs/AssetManager.test.js | 27 +++--
public/app/yjs/YjsTinyMCEBinding.js | 23 ++--
public/app/yjs/YjsTinyMCEBinding.test.js | 36 ++++++-
6 files changed, 180 insertions(+), 131 deletions(-)
diff --git a/public/app/editor/tinymce_5_settings.js b/public/app/editor/tinymce_5_settings.js
index b04849ac5..7ee0dec87 100644
--- a/public/app/editor/tinymce_5_settings.js
+++ b/public/app/editor/tinymce_5_settings.js
@@ -333,7 +333,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 +383,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 +409,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 +428,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'));
@@ -596,6 +572,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') {
diff --git a/public/app/editor/tinymce_5_settings.test.js b/public/app/editor/tinymce_5_settings.test.js
index 50c8f5e9a..ccd148c2d 100644
--- a/public/app/editor/tinymce_5_settings.test.js
+++ b/public/app/editor/tinymce_5_settings.test.js
@@ -262,6 +262,7 @@ describe('TinyMCE 5 Settings', () => {
// SetContent handler is now registered in setup callback (before content loads)
config.setup(mockEditor);
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 +477,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 +525,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 +545,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 +576,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 +584,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 +605,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 +642,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 +662,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 +683,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 +695,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 +746,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');
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 = '';
const result = assetManager.convertBlobURLsToAssetRefs(html);
- // Should be unchanged (with warning logged)
- expect(result).toContain('blob:');
+ expect(result).toBe('
');
});
});
@@ -2637,6 +2635,13 @@ describe('convertDataAssetUrlToSrc', () => {
expect(result).toContain('src="asset://audio-uuid"');
});
+ it('converts media data-asset-src back to src', () => {
+ const html = '';
+ const result = assetManager.convertDataAssetUrlToSrc(html);
+ expect(result).toContain('src="asset://iframe-uuid/document.pdf"');
+ expect(result).not.toContain('data-asset-src');
+ });
+
it('handles multiple elements', () => {
const html = '
';
const result = assetManager.convertDataAssetUrlToSrc(html);
@@ -11387,8 +11392,8 @@ describe('convertBlobURLsToAssetRefs strategy 2 and 3', () => {
const blobUrl = 'blob:http://localhost/unknown-blob';
const html = '
';
am.convertBlobURLsToAssetRefs(html);
- expect(console.warn).toHaveBeenCalledWith(
- expect.stringContaining('FAILED to convert blob URL')
+ expect(global.Logger.warn).toHaveBeenCalledWith(
+ expect.stringContaining('Cannot recover blob URL')
);
});
@@ -12376,6 +12381,13 @@ describe('convertDataAssetUrlToSrc branches', () => {
expect(result).toContain('src="asset://test-id.png"');
expect(result).not.toContain('data-asset-url');
});
+
+ it('converts data-asset-src to src', () => {
+ const html = '';
+ const result = am.convertDataAssetUrlToSrc(html);
+ expect(result).toContain('src="asset://audio-id.webm"');
+ expect(result).not.toContain('data-asset-src');
+ });
});
describe('prepareHtmlForSync branches', () => {
@@ -13565,9 +13577,8 @@ describe('convertBlobURLsToAssetRefs - branch coverage', () => {
const blobUrl = 'blob:http://localhost/unconvertible';
const html = `
`;
const result = am.convertBlobURLsToAssetRefs(html);
- // Should log warning and return original match
expect(console.warn).toHaveBeenCalled();
- expect(result).toContain(blobUrl);
+ expect(result).toBe('
');
});
it('converts via data-asset-id attribute', () => {
diff --git a/public/app/yjs/YjsTinyMCEBinding.js b/public/app/yjs/YjsTinyMCEBinding.js
index 22d3f80f2..6359aa067 100644
--- a/public/app/yjs/YjsTinyMCEBinding.js
+++ b/public/app/yjs/YjsTinyMCEBinding.js
@@ -253,14 +253,19 @@ class YjsTinyMCEBinding {
*/
syncFromEditor() {
let content = this.editor.getContent();
+ const assetManager = window.eXeLearning?.app?.project?._yjsBridge?.assetManager;
- // Step 1: Convert data-asset-url attributes to proper src values
- // This handles images inserted via file picker which use data: URLs with data-asset-url attr
- content = this.convertDataAssetUrlToSrc(content);
+ if (assetManager?.prepareHtmlForSync) {
+ content = assetManager.prepareHtmlForSync(content);
+ } else {
+ // Step 1: Convert data-asset-url attributes to proper src values
+ // This handles images inserted via file picker which use data: URLs with data-asset-url attr
+ content = this.convertDataAssetUrlToSrc(content);
- // Step 2: Convert blob: URLs to asset:// URLs before saving to Yjs
- // This handles images from drag & drop which use blob: URLs
- content = this.convertBlobUrlsToAssetUrls(content);
+ // Step 2: Convert blob: URLs to asset:// URLs before saving to Yjs
+ // This handles images from drag & drop which use blob: URLs
+ content = this.convertBlobUrlsToAssetUrls(content);
+ }
const currentYText = this.yText.toString();
@@ -355,9 +360,9 @@ class YjsTinyMCEBinding {
// Use simplified URL format: asset://uuid.ext
return assetManager.getAssetUrl(assetId, filename);
}
- // If not found in cache, keep original (may be external blob)
- console.warn('[YjsTinyMCEBinding] Blob URL not found in AssetManager cache:', blobUrl);
- return blobUrl;
+ // If not found in cache, clear it so the broken temporary URL is not persisted
+ console.warn('[YjsTinyMCEBinding] Blob URL not found in AssetManager cache, clearing:', blobUrl);
+ return '';
});
}
diff --git a/public/app/yjs/YjsTinyMCEBinding.test.js b/public/app/yjs/YjsTinyMCEBinding.test.js
index 752f6d9e2..dcecc6742 100644
--- a/public/app/yjs/YjsTinyMCEBinding.test.js
+++ b/public/app/yjs/YjsTinyMCEBinding.test.js
@@ -328,7 +328,7 @@ describe('YjsTinyMCEBinding', () => {
delete window.eXeLearning;
});
- it('preserves blob URL when not found in reverseBlobCache (warns but keeps content)', () => {
+ it('clears blob URL when not found in reverseBlobCache (warns and prevents persistence)', () => {
// Setup mock AssetManager with empty cache
const mockAssetManager = {
reverseBlobCache: new Map(), // Empty - no mapping
@@ -349,12 +349,38 @@ describe('YjsTinyMCEBinding', () => {
binding.syncFromEditor();
const yTextContent = mockYText.toString();
- // Should preserve unknown blob URL (and warn)
- expect(yTextContent).toContain('blob:http://localhost:8081/unknown');
+ expect(yTextContent).not.toContain('blob:http://localhost:8081/unknown');
+ expect(yTextContent).toContain('src=""');
expect(console.warn).toHaveBeenCalled();
delete window.eXeLearning;
});
+
+ it('uses AssetManager.prepareHtmlForSync when available', () => {
+ const mockPrepareHtmlForSync = vi.fn().mockReturnValue('
Hello
');
+ const mockAssetManager = {
+ prepareHtmlForSync: mockPrepareHtmlForSync,
+ };
+ window.eXeLearning = {
+ app: {
+ project: {
+ _yjsBridge: {
+ assetManager: mockAssetManager,
+ },
+ },
+ },
+ };
+
+ binding = new YjsTinyMCEBinding(mockEditor, mockYText);
+ mockEditor._content = 'Hello
Hello
Test
'; @@ -1166,6 +1187,29 @@ describe('IdeviceNode', () => { expect(result).toBe('Test
'); }); + it('normalizes blob urls in htmlView before opening html iDevice editors', () => { + const prepareHtmlForSync = vi.fn((html) => + html.replace( + 'blob:http://localhost/image-1', + 'asset://image-asset-1.png', + ), + ); + eXeLearning.app.project._yjsBridge = { + assetManager: { prepareHtmlForSync }, + }; + idevice.idevice = { componentType: 'html' }; + idevice.htmlView = + ''; + + const result = idevice.getSavedData(); + + expect(prepareHtmlForSync).toHaveBeenCalledWith( + '', + ); + expect(result).toContain('asset://image-asset-1.png'); + expect(result).not.toContain('blob:'); + }); + it('returns htmlView when componentType is undefined', () => { idevice.idevice = {}; idevice.htmlView = 'Default
'; From a3323ef0f970726756d1d53ffb6c5b7a720113b9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Mar 2026 18:38:59 +0000 Subject: [PATCH 04/10] chore: finalize idevice editor normalization validation Co-authored-by: erseco <1876752+erseco@users.noreply.github.com> Agent-Logs-Url: https://github.com/ateeducacion/exelearning/sessions/78181992-5256-440b-9611-bedd402a53b0 --- public/app/workarea/project/idevices/content/ideviceNode.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/public/app/workarea/project/idevices/content/ideviceNode.js b/public/app/workarea/project/idevices/content/ideviceNode.js index fd6891192..3bb2086cb 100644 --- a/public/app/workarea/project/idevices/content/ideviceNode.js +++ b/public/app/workarea/project/idevices/content/ideviceNode.js @@ -2928,7 +2928,11 @@ export default class IdeviceNode { try { return JSON.parse(normalized); - } catch { + } catch (error) { + console.warn( + '[IdeviceNode] Failed to parse normalized jsonProperties for editor load:', + error, + ); return data; } } From e893298ae7de32c26e2a65845cae02ef24e2fbb2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 02:02:14 +0000 Subject: [PATCH 05/10] fix: resolve TinyMCE image asset urls in edit mode Co-authored-by: erseco <1876752+erseco@users.noreply.github.com> Agent-Logs-Url: https://github.com/ateeducacion/exelearning/sessions/c0899424-193e-40ec-b9d2-426f0e814e68 --- public/app/editor/tinymce_5_settings.js | 29 ++++++--- public/app/editor/tinymce_5_settings.test.js | 62 +++++++++++++++++++ .../project/idevices/content/ideviceNode.js | 46 -------------- .../idevices/content/ideviceNode.test.js | 33 ++-------- 4 files changed, 85 insertions(+), 85 deletions(-) diff --git a/public/app/editor/tinymce_5_settings.js b/public/app/editor/tinymce_5_settings.js index 7ee0dec87..7140d59e7 100644 --- a/public/app/editor/tinymce_5_settings.js +++ b/public/app/editor/tinymce_5_settings.js @@ -594,7 +594,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) { @@ -603,8 +603,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?.('img[src^="asset://"], audio[src^="asset://"], video[src^="asset://"], iframe[src^="asset://"]') || + (node.matches?.('img[src^="asset://"], audio[src^="asset://"], video[src^="asset://"], iframe[src^="asset://"]')); if (hasAssetUrl) { hasNewMedia = true; break; @@ -688,8 +688,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 @@ -705,21 +706,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('img[src^="asset://"], audio[src^="asset://"], video[src^="asset://"], iframe[src^="asset://"]'); 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) { @@ -751,7 +753,14 @@ var $exeTinyMCE = { }); } } else { - // Resolve to blob URL asynchronously (for audio, video, PDF iframes) + if (isImage) { + media.setAttribute( + 'src', + 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', + ); + } + + // Resolve to blob URL asynchronously (for images, audio, video, PDF iframes) assetManager.resolveAssetURL(assetUrl).then(function(blobUrl) { if (blobUrl) { media.setAttribute('src', blobUrl); diff --git a/public/app/editor/tinymce_5_settings.test.js b/public/app/editor/tinymce_5_settings.test.js index ccd148c2d..9adeae205 100644 --- a/public/app/editor/tinymce_5_settings.test.js +++ b/public/app/editor/tinymce_5_settings.test.js @@ -827,6 +827,33 @@ 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'); + 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-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'); @@ -942,6 +969,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 diff --git a/public/app/workarea/project/idevices/content/ideviceNode.js b/public/app/workarea/project/idevices/content/ideviceNode.js index 3bb2086cb..5baa262b1 100644 --- a/public/app/workarea/project/idevices/content/ideviceNode.js +++ b/public/app/workarea/project/idevices/content/ideviceNode.js @@ -2895,52 +2895,6 @@ export default class IdeviceNode { break; } - return this.normalizeSavedDataForEdition(data, componentType); - } - - /** - * Normalize persisted iDevice data before passing it to legacy editors. - * This keeps asset:// as the user-visible reference format even if old - * content still contains temporary blob: URLs. - * - * @param {*} data - * @param {string|null} componentType - * @returns {*} - */ - normalizeSavedDataForEdition(data, componentType = null) { - const assetManager = - eXeLearning?.app?.project?._yjsBridge?.assetManager || null; - - if (!assetManager) return data; - - if (componentType === 'json') { - if (!data) return data; - - const originalWasString = typeof data === 'string'; - const serialized = originalWasString ? data : JSON.stringify(data); - const normalized = assetManager.prepareJsonForSync - ? assetManager.prepareJsonForSync(serialized) - : serialized; - - if (originalWasString) { - return normalized; - } - - try { - return JSON.parse(normalized); - } catch (error) { - console.warn( - '[IdeviceNode] Failed to parse normalized jsonProperties for editor load:', - error, - ); - return data; - } - } - - if (typeof data === 'string' && assetManager.prepareHtmlForSync) { - return assetManager.prepareHtmlForSync(data); - } - return data; } diff --git a/public/app/workarea/project/idevices/content/ideviceNode.test.js b/public/app/workarea/project/idevices/content/ideviceNode.test.js index 7687eba10..c39d67539 100644 --- a/public/app/workarea/project/idevices/content/ideviceNode.test.js +++ b/public/app/workarea/project/idevices/content/ideviceNode.test.js @@ -1158,25 +1158,13 @@ describe('IdeviceNode', () => { expect(result).toEqual({ key: 'value' }); }); - it('normalizes blob urls in jsonProperties before opening json iDevice editors', () => { - const prepareJsonForSync = vi.fn((json) => - json.replace( - 'blob:http://localhost/audio-1', - 'asset://audio-asset-1.webm', - ), - ); - eXeLearning.app.project._yjsBridge = { - assetManager: { prepareJsonForSync }, - }; + it('does not normalize jsonProperties before opening json iDevice editors', () => { idevice.idevice = { componentType: 'json' }; idevice.jsonProperties = { audio: 'blob:http://localhost/audio-1' }; const result = idevice.getSavedData(); - expect(prepareJsonForSync).toHaveBeenCalledWith( - '{"audio":"blob:http://localhost/audio-1"}', - ); - expect(result).toEqual({ audio: 'asset://audio-asset-1.webm' }); + expect(result).toEqual({ audio: 'blob:http://localhost/audio-1' }); }); it('returns htmlView for html type idevice', () => { @@ -1187,27 +1175,14 @@ describe('IdeviceNode', () => { expect(result).toBe('Test
'); }); - it('normalizes blob urls in htmlView before opening html iDevice editors', () => { - const prepareHtmlForSync = vi.fn((html) => - html.replace( - 'blob:http://localhost/image-1', - 'asset://image-asset-1.png', - ), - ); - eXeLearning.app.project._yjsBridge = { - assetManager: { prepareHtmlForSync }, - }; + it('does not normalize htmlView before opening html iDevice editors', () => { idevice.idevice = { componentType: 'html' }; idevice.htmlView = ''; const result = idevice.getSavedData(); - expect(prepareHtmlForSync).toHaveBeenCalledWith( - '', - ); - expect(result).toContain('asset://image-asset-1.png'); - expect(result).not.toContain('blob:'); + expect(result).toContain('blob:http://localhost/image-1'); }); it('returns htmlView when componentType is undefined', () => { From efc590233ecb0937084b05630b4874341851855e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 02:05:29 +0000 Subject: [PATCH 06/10] refactor: reuse TinyMCE asset media selector Co-authored-by: erseco <1876752+erseco@users.noreply.github.com> Agent-Logs-Url: https://github.com/ateeducacion/exelearning/sessions/c0899424-193e-40ec-b9d2-426f0e814e68 --- public/app/editor/tinymce_5_settings.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/public/app/editor/tinymce_5_settings.js b/public/app/editor/tinymce_5_settings.js index 7140d59e7..ce3183be8 100644 --- a/public/app/editor/tinymce_5_settings.js +++ b/public/app/editor/tinymce_5_settings.js @@ -1,4 +1,6 @@ var $exeTinyMCE = { + assetUrlMediaSelector: + 'img[src^="asset://"], audio[src^="asset://"], video[src^="asset://"], iframe[src^="asset://"]', // imagetools is disabled because it generates base64 images // colorpicker contextmenu textcolor . Añadidos al core, no hace falta añadir en plugins? plugins: @@ -603,8 +605,8 @@ var $exeTinyMCE = { if (mutation.type === 'childList') { for (const node of mutation.addedNodes) { if (node.nodeType === 1) { - const hasAssetUrl = node.querySelector?.('img[src^="asset://"], audio[src^="asset://"], video[src^="asset://"], iframe[src^="asset://"]') || - (node.matches?.('img[src^="asset://"], audio[src^="asset://"], video[src^="asset://"], iframe[src^="asset://"]')); + const hasAssetUrl = node.querySelector?.($exeTinyMCE.assetUrlMediaSelector) || + (node.matches?.($exeTinyMCE.assetUrlMediaSelector)); if (hasAssetUrl) { hasNewMedia = true; break; @@ -707,7 +709,7 @@ var $exeTinyMCE = { if (!body) return; // Find image, audio, video, and iframe elements with asset:// URLs - const mediaElements = body.querySelectorAll('img[src^="asset://"], audio[src^="asset://"], video[src^="asset://"], iframe[src^="asset://"]'); + const mediaElements = body.querySelectorAll(this.assetUrlMediaSelector); for (const media of mediaElements) { const assetUrl = media.getAttribute('src'); From 2ca3f941770eba58d678bc7cd5be3cc8919ec55e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 07:53:08 +0000 Subject: [PATCH 07/10] fix: pre-resolve asset urls before TinyMCE setContent Co-authored-by: erseco <1876752+erseco@users.noreply.github.com> Agent-Logs-Url: https://github.com/ateeducacion/exelearning/sessions/f7fbb101-6107-44c0-886f-88cbc57af6d8 --- public/app/editor/tinymce_5_settings.js | 22 +++++++++++++ public/app/editor/tinymce_5_settings.test.js | 33 +++++++++++++++++++ .../perm/idevices/base/text/edition/text.js | 5 +++ .../idevices/base/text/edition/text.test.js | 11 ++++++- 4 files changed, 70 insertions(+), 1 deletion(-) diff --git a/public/app/editor/tinymce_5_settings.js b/public/app/editor/tinymce_5_settings.js index ce3183be8..064f0e31e 100644 --- a/public/app/editor/tinymce_5_settings.js +++ b/public/app/editor/tinymce_5_settings.js @@ -548,6 +548,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) { @@ -631,6 +637,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; diff --git a/public/app/editor/tinymce_5_settings.test.js b/public/app/editor/tinymce_5_settings.test.js index 9adeae205..9ba39f76c 100644 --- a/public/app/editor/tinymce_5_settings.test.js +++ b/public/app/editor/tinymce_5_settings.test.js @@ -261,6 +261,7 @@ 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)); @@ -1288,6 +1289,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(() => '

Activity content
'); }); - }); + it('does not parse non-wrapper asset html when stripping legacy wrapper', () => { + const createElementSpy = vi.spyOn(document, 'createElement'); + const html = '