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

'); + + 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/app/workarea/project/idevices/content/ideviceNode.test.js b/public/app/workarea/project/idevices/content/ideviceNode.test.js index 15bfd9aee..c39d67539 100644 --- a/public/app/workarea/project/idevices/content/ideviceNode.test.js +++ b/public/app/workarea/project/idevices/content/ideviceNode.test.js @@ -1158,6 +1158,15 @@ describe('IdeviceNode', () => { expect(result).toEqual({ key: 'value' }); }); + 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(result).toEqual({ audio: 'blob:http://localhost/audio-1' }); + }); + it('returns htmlView for html type idevice', () => { idevice.idevice = { componentType: 'html' }; idevice.htmlView = '

Test

'; @@ -1166,6 +1175,16 @@ describe('IdeviceNode', () => { expect(result).toBe('

Test

'); }); + it('does not normalize htmlView before opening html iDevice editors', () => { + idevice.idevice = { componentType: 'html' }; + idevice.htmlView = + '
0
'; + + const result = idevice.getSavedData(); + + expect(result).toContain('blob:http://localhost/image-1'); + }); + it('returns htmlView when componentType is undefined', () => { idevice.idevice = {}; idevice.htmlView = '

Default

'; diff --git a/public/app/yjs/AssetManager.js b/public/app/yjs/AssetManager.js index 058e3ca46..399e42e66 100644 --- a/public/app/yjs/AssetManager.js +++ b/public/app/yjs/AssetManager.js @@ -2497,6 +2497,23 @@ class AssetManager { let convertedHTML = html; let conversions = 0; + const logWarn = typeof Logger?.warn === 'function' ? Logger.warn.bind(Logger) : console.warn.bind(console); + const getCanonicalAssetUrl = (rawAssetId) => { + if (!rawAssetId) return ''; + + let assetId = rawAssetId; + if (assetId.startsWith('asset://')) { + assetId = this.extractAssetId(assetId); + } + if (!assetId) return ''; + + const metadata = this.getAssetMetadata?.(assetId); + const filename = metadata?.filename || metadata?.name; + if (typeof this.getAssetUrl === 'function') { + return this.getAssetUrl(assetId, filename); + } + return `asset://${assetId}`; + }; // Strategy 1: Find img/video/audio tags with blob: src and data-asset-id attribute // This is the RELIABLE way - data-asset-id is set when inserting from MediaLibrary @@ -2510,24 +2527,27 @@ class AssetManager { const assetIdMatch = fullAttrs.match(/data-asset-id=(["'])([^"']+)\1/i); if (assetIdMatch) { - const assetId = assetIdMatch[2]; - // Use 'file' as default filename - the actual filename will be resolved on load + const assetUrl = getCanonicalAssetUrl(assetIdMatch[2]); + if (!assetUrl) { + logWarn(`[AssetManager] Cannot recover blob URL from invalid data-asset-id, clearing: ${blobUrl.substring(0, 50)}...`); + return match.replace(blobUrl, ''); + } conversions++; - Logger.log(`[AssetManager] Converted blob→asset via data-asset-id: ${assetId.substring(0, 8)}...`); - return `<${tagName}${before} src=${quote}asset://${assetId}${quote}${after}>`; + Logger.log(`[AssetManager] Converted blob→asset via data-asset-id: ${assetUrl.substring(8, 16)}...`); + return `<${tagName}${before} src=${quote}${assetUrl}${quote}${after}>`; } // Strategy 2: Fall back to reverseBlobCache lookup - const assetId = this.reverseBlobCache.get(blobUrl); - if (assetId) { + const assetUrl = getCanonicalAssetUrl(this.reverseBlobCache.get(blobUrl)); + if (assetUrl) { conversions++; - Logger.log(`[AssetManager] Converted blob→asset via cache: ${assetId.substring(0, 8)}...`); - return match.replace(blobUrl, `asset://${assetId}`); + Logger.log(`[AssetManager] Converted blob→asset via cache: ${assetUrl.substring(8, 16)}...`); + return match.replace(blobUrl, assetUrl); } - // Could not convert - log warning - console.warn(`[AssetManager] FAILED to convert blob URL (no data-asset-id, not in cache): ${blobUrl.substring(0, 50)}...`); - return match; + // Could not convert - clear the invalid blob URL so it is not persisted + logWarn(`[AssetManager] Cannot recover blob URL, clearing: ${blobUrl.substring(0, 50)}...`); + return match.replace(blobUrl, ''); }; convertedHTML = convertedHTML.replace(tagRegex, replaceCallback); @@ -2537,11 +2557,19 @@ class AssetManager { for (const [blobURL, assetId] of this.reverseBlobCache.entries()) { if (convertedHTML.includes(blobURL)) { const escapedBlobURL = blobURL.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - convertedHTML = convertedHTML.replace(new RegExp(escapedBlobURL, 'g'), `asset://${assetId}`); - conversions++; + const assetUrl = getCanonicalAssetUrl(assetId); + if (assetUrl) { + convertedHTML = convertedHTML.replace(new RegExp(escapedBlobURL, 'g'), assetUrl); + conversions++; + } } } + convertedHTML = convertedHTML.replace(/blob:https?:\/\/[^"'\s)]+/g, (blobUrl) => { + logWarn(`[AssetManager] Cannot recover blob URL, clearing: ${blobUrl.substring(0, 50)}...`); + return ''; + }); + if (conversions > 0) { Logger.log(`[AssetManager] convertBlobURLsToAssetRefs: ${conversions} conversion(s) made`); } @@ -2565,10 +2593,18 @@ class AssetManager { // Handles img, video, audio, a tags const regex = /(<(?:img|video|audio|source)[^>]*?)(?:src|href)=(["'])([^"']*)\2([^>]*?)data-asset-url=(["'])([^"']+)\5([^>]*>)/gi; - return html.replace(regex, (match, beforeSrc, quote1, oldSrc, middle, quote2, assetUrl, afterAttr) => { + let result = html.replace(regex, (match, beforeSrc, quote1, oldSrc, middle, quote2, assetUrl, afterAttr) => { // Replace src with asset URL and remove data-asset-url attribute return `${beforeSrc}src=${quote1}${assetUrl}${quote1}${middle}${afterAttr}`; }); + + const assetSrcRegex = /(<(?:audio|video|iframe)[^>]*?)src=(["'])([^"']*)\2([^>]*?)data-asset-src=(["'])([^"']+)\5([^>]*>)/gi; + + result = result.replace(assetSrcRegex, (match, beforeSrc, quote1, oldSrc, middle, quote2, assetUrl, afterAttr) => { + return `${beforeSrc}src=${quote1}${assetUrl}${quote1}${middle}${afterAttr}`; + }); + + return result; } /** diff --git a/public/app/yjs/AssetManager.test.js b/public/app/yjs/AssetManager.test.js index 4a02b3141..63beac52d 100644 --- a/public/app/yjs/AssetManager.test.js +++ b/public/app/yjs/AssetManager.test.js @@ -1117,13 +1117,11 @@ describe('AssetManager', () => { expect(result).toBe(html); }); - it('leaves blob URL unchanged when neither data-asset-id nor cache match', () => { - // Blob URL not in cache, no data-asset-id + it('clears blob URL when neither data-asset-id nor cache match', () => { const html = ''; 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; diff --git a/public/files/perm/idevices/base/text/edition/text.js b/public/files/perm/idevices/base/text/edition/text.js index debad62c0..28d241ff8 100644 --- a/public/files/perm/idevices/base/text/edition/text.js +++ b/public/files/perm/idevices/base/text/edition/text.js @@ -413,6 +413,15 @@ 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, {});