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 = '
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(['