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 = `test`; const result = am.convertBlobURLsToAssetRefs(html); - // Should log warning and return original match expect(console.warn).toHaveBeenCalled(); - expect(result).toContain(blobUrl); + expect(result).toBe('test'); }); 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

'; + + binding.syncFromEditor(); + + expect(mockPrepareHtmlForSync).toHaveBeenCalledWith('

Hello

'); + expect(mockYText.toString()).toContain('asset://prepared-asset.png'); + + delete window.eXeLearning; + }); }); describe('computeDiff', () => { @@ -945,7 +971,7 @@ describe('YjsTinyMCEBinding', () => { delete window.eXeLearning; }); - it('keeps unknown blob URLs unchanged and warns', () => { + it('clears unknown blob URLs and warns', () => { const mockAssetManager = { reverseBlobCache: new Map(), // Empty cache }; @@ -956,7 +982,7 @@ describe('YjsTinyMCEBinding', () => { const html = ''; const result = binding.convertBlobUrlsToAssetUrls(html); - expect(result).toBe(html); // Unchanged + expect(result).toBe(''); expect(console.warn).toHaveBeenCalled(); delete window.eXeLearning; From b09b64130a46939532f2ca3c614703e55029bfec Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Mar 2026 18:32:15 +0000 Subject: [PATCH 03/10] fix: normalize idevice editor data back to asset urls Co-authored-by: erseco <1876752+erseco@users.noreply.github.com> Agent-Logs-Url: https://github.com/ateeducacion/exelearning/sessions/78181992-5256-440b-9611-bedd402a53b0 --- .../project/idevices/content/ideviceNode.js | 42 ++++++++++++++++++ .../idevices/content/ideviceNode.test.js | 44 +++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/public/app/workarea/project/idevices/content/ideviceNode.js b/public/app/workarea/project/idevices/content/ideviceNode.js index 5baa262b1..fd6891192 100644 --- a/public/app/workarea/project/idevices/content/ideviceNode.js +++ b/public/app/workarea/project/idevices/content/ideviceNode.js @@ -2895,6 +2895,48 @@ 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 { + 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 15bfd9aee..7687eba10 100644 --- a/public/app/workarea/project/idevices/content/ideviceNode.test.js +++ b/public/app/workarea/project/idevices/content/ideviceNode.test.js @@ -1158,6 +1158,27 @@ 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 }, + }; + 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' }); + }); + it('returns htmlView for html type idevice', () => { idevice.idevice = { componentType: 'html' }; idevice.htmlView = '

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 = + '
0
'; + + const result = idevice.getSavedData(); + + expect(prepareHtmlForSync).toHaveBeenCalledWith( + '
0
', + ); + 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 = '
0
'; const result = idevice.getSavedData(); - expect(prepareHtmlForSync).toHaveBeenCalledWith( - '
0
', - ); - 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(() => '

'); + + window.eXeLearning.app.project = { + _yjsBridge: { assetManager: { resolveHTMLAssetsSync } }, + }; + + const mockEditor = { + on: vi.fn(), + getBody: () => document.createElement('div'), + }; + + config.setup(mockEditor); + + const beforeSetContentCall = mockEditor.on.mock.calls.find((c) => c[0] === 'BeforeSetContent'); + const beforeSetContentHandler = beforeSetContentCall[1]; + const event = { content: '

' }; + + beforeSetContentHandler(event); + + expect(resolveHTMLAssetsSync).toHaveBeenCalledWith( + '

', + { + usePlaceholder: true, + addTracking: true, + }, + ); + expect(event.content).toBe('

'); + }); + it('removes Bun dev server scripts (/_bun/ path) from editor body', () => { globalThis.$exeTinyMCE.init('single', '#editor'); const config = globalThis.tinymce.init.mock.calls[0][0]; diff --git a/public/files/perm/idevices/base/text/edition/text.js b/public/files/perm/idevices/base/text/edition/text.js index debad62c0..00bb68c69 100644 --- a/public/files/perm/idevices/base/text/edition/text.js +++ b/public/files/perm/idevices/base/text/edition/text.js @@ -413,6 +413,11 @@ var $exeDevice = { stripLegacyExeTextWrapper: function (html) { if (!html || typeof html !== 'string') return html; + const trimmedHtml = html.trim(); + if (!/^ { const textarea = mockElement.querySelector('#textTextarea'); expect(textarea.value).toBe('

Activity content

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

'; + + const result = $exeDevice.stripLegacyExeTextWrapper(html); + + expect(result).toBe(html); + expect(createElementSpy).not.toHaveBeenCalled(); + }); + }); describe('checkFormValues', () => { it('returns false and shows alert when text is empty string', () => { $exeDevice.init(mockElement, {}); From 5cce4936754252cbd67c0171a839e3478f2a98b2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 07:58:10 +0000 Subject: [PATCH 08/10] fix: keep TinyMCE image data-mce-src on runtime urls 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 | 7 +++++++ public/app/editor/tinymce_5_settings.test.js | 2 ++ 2 files changed, 9 insertions(+) diff --git a/public/app/editor/tinymce_5_settings.js b/public/app/editor/tinymce_5_settings.js index 064f0e31e..a19131764 100644 --- a/public/app/editor/tinymce_5_settings.js +++ b/public/app/editor/tinymce_5_settings.js @@ -782,12 +782,19 @@ var $exeTinyMCE = { 'src', 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', ); + media.setAttribute( + 'data-mce-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); + 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 9ba39f76c..8e6eae90e 100644 --- a/public/app/editor/tinymce_5_settings.test.js +++ b/public/app/editor/tinymce_5_settings.test.js @@ -832,6 +832,7 @@ describe('TinyMCE 5 Settings', () => { 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'; @@ -852,6 +853,7 @@ describe('TinyMCE 5 Settings', () => { 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'); }); From d0f183e1a55815cde4c04f08ecba14df47153796 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 08:01:53 +0000 Subject: [PATCH 09/10] refactor: short-circuit legacy text wrapper detection 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/files/perm/idevices/base/text/edition/text.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/public/files/perm/idevices/base/text/edition/text.js b/public/files/perm/idevices/base/text/edition/text.js index 00bb68c69..28d241ff8 100644 --- a/public/files/perm/idevices/base/text/edition/text.js +++ b/public/files/perm/idevices/base/text/edition/text.js @@ -414,7 +414,11 @@ var $exeDevice = { if (!html || typeof html !== 'string') return html; const trimmedHtml = html.trim(); - if (!/^ Date: Wed, 25 Mar 2026 08:03:12 +0000 Subject: [PATCH 10/10] refactor: extract TinyMCE asset constants 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 | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/public/app/editor/tinymce_5_settings.js b/public/app/editor/tinymce_5_settings.js index a19131764..b58cef6f2 100644 --- a/public/app/editor/tinymce_5_settings.js +++ b/public/app/editor/tinymce_5_settings.js @@ -1,6 +1,9 @@ +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 = { - 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: @@ -611,8 +614,8 @@ var $exeTinyMCE = { if (mutation.type === 'childList') { for (const node of mutation.addedNodes) { if (node.nodeType === 1) { - const hasAssetUrl = node.querySelector?.($exeTinyMCE.assetUrlMediaSelector) || - (node.matches?.($exeTinyMCE.assetUrlMediaSelector)); + const hasAssetUrl = node.querySelector?.(ASSET_URL_MEDIA_SELECTOR) || + (node.matches?.(ASSET_URL_MEDIA_SELECTOR)); if (hasAssetUrl) { hasNewMedia = true; break; @@ -731,7 +734,7 @@ var $exeTinyMCE = { if (!body) return; // Find image, audio, video, and iframe elements with asset:// URLs - const mediaElements = body.querySelectorAll(this.assetUrlMediaSelector); + const mediaElements = body.querySelectorAll(ASSET_URL_MEDIA_SELECTOR); for (const media of mediaElements) { const assetUrl = media.getAttribute('src'); @@ -778,14 +781,8 @@ var $exeTinyMCE = { } } else { if (isImage) { - media.setAttribute( - 'src', - 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', - ); - media.setAttribute( - 'data-mce-src', - 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', - ); + 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)