import { renderImageJobs, imageJson, hydrateImage, imageDataUrl, captureImageOwner, assertImageOwner, transientImageUrl, revokeImageUrl } from '../generatedImages.js'; import { sanitizeHtml } from './sanitize.js'; import { validSharingOwner } from '../assistant/sharing.js'; export function generatedImageExtension(T) { // Reuse the Node class already shipped with StarterKit, without a second Tiptap bundle. const Node = T.StarterKit.config.addExtensions.call(T.StarterKit).find(e => e.type === 'node').constructor; return Node.create({ name: 'generatedImage', group: 'block', atom: true, draggable: true, addAttributes() { return { src: { default: '' }, alt: { default: 'Generated teaching visual' } }; }, parseHTML() { return [{ tag: 'img[src^="/api/generated-images/"]' }]; }, renderHTML({ HTMLAttributes }) { return ['img', HTMLAttributes]; }, addNodeView() { return ({ node }) => { const img = document.createElement('img'); img.alt = node.attrs.alt; img.style.maxWidth = '100%'; // NodeViews hydrate after attachment, leaving the editor's persistent attributes untouched. queueMicrotask(() => hydrateImage(img, node.attrs.src).catch(() => { img.alt = 'Private image unavailable'; })); return { dom: img, ignoreMutation: () => true, destroy() { if (img.src.startsWith('blob:')) revokeImageUrl(img.src); } }; }; } }); } export function createLearningImages(getEditor, getMarkdown) { let panel, list, draft; function mount() { if (panel?.isConnected) return; const parent = document.getElementById('lh-images-section'); if (!parent) return; panel = document.createElement('section'); panel.setAttribute('aria-label', 'Learning Hub images'); panel.innerHTML = '

Learning Hub images

Uses the separately administered Learning image model and instructions. Maximum assembled input: 32,000 UTF-16 code units (or the lower admin budget). The full image request is retained; whole recent authoring-context turns are selected to fit, with counts shown below. Existing content is unchanged. Images remain private until attached to saved content.

'; parent.append(panel); list = panel.querySelector('[data-jobs]'); const status = panel.querySelector('[role=status]'); panel.querySelector('[data-generate]').onclick = async function() { if (this.disabled) return; const prompt = panel.querySelector('textarea').value; const content = getMarkdown()?.value ?? getEditor()?.getHTML() ?? ''; if (!draft || draft.prompt !== prompt || draft.content !== content) draft = { prompt, content, idempotencyKey: crypto.randomUUID() }; this.disabled = true; try { show([await imageJson('/api/admin/learning/image/jobs', { method: 'POST', body: JSON.stringify(draft) })]); status.textContent = 'Image queued; your draft is preserved.'; } catch (e) { status.textContent = e.message; } finally { this.disabled = false; } }; panel.querySelector('[data-history]').onclick = async () => { try { list.replaceChildren(); show((await imageJson('/api/image-jobs/learning_hub')).jobs); } catch (e) { status.textContent = e.message; } }; panel.querySelector('[data-export]').onclick = async () => { let ticket; try { ticket = captureImageOwner(); const template = document.createElement('template'); const markdown = getMarkdown(); if (markdown) throw new Error('For presentations use the existing PowerPoint export, which includes attached generated images.'); template.innerHTML = sanitizeHtml(getEditor()?.getHTML() || ''); for (const img of template.content.querySelectorAll('img')) img.src = await imageDataUrl(img.getAttribute('src'), ticket); assertImageOwner(ticket); const url = transientImageUrl(new Blob(['Learning content' + template.innerHTML], { type: 'text/html' }), ticket); const a = document.createElement('a'); a.href = url; a.download = 'learning-content.html'; a.click(); setTimeout(() => revokeImageUrl(url), 60000); } catch (e) { if (validSharingOwner(ticket) && e.name !== 'AbortError') status.textContent = e.message; } }; } function show(jobs) { mount(); if (!list) return; renderImageJobs(list, jobs, 'learning_hub', (card, data) => { const img = document.createElement('img'); img.alt = 'Generated teaching visual'; img.style.maxWidth = '100%'; card.append(img); hydrateImage(img, data.imageUrl).catch(() => { img.alt = 'Private image unavailable'; }); const insert = document.createElement('button'); insert.type = 'button'; insert.textContent = 'Insert image at end of content'; insert.onclick = () => { captureImageOwner(); const markdown = getMarkdown(); if (markdown) markdown.value += '\n---\n# Generated teaching visual\n![Generated teaching visual](' + data.imageUrl + ')\n'; else { const editor = getEditor(); if (!editor || editor.isDestroyed) return; editor.commands.insertContentAt(editor.state.doc.content.size, { type: 'generatedImage', attrs: { src: data.imageUrl, alt: 'Generated teaching visual' } }); } insert.disabled = true; insert.textContent = 'Inserted — save content to attach'; }; card.append(insert); }); } return { mount, show }; }