76 lines
5.6 KiB
JavaScript
76 lines
5.6 KiB
JavaScript
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 = '<h3>Learning Hub images</h3><p>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.</p><label>Image prompt<textarea rows="4"></textarea></label><button type="button" data-generate>Generate image</button><button type="button" data-history>Reopen image history</button><button type="button" data-export>Export content with images (HTML)</button><p role="status"></p><div data-jobs></div>';
|
|
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(['<!doctype html><meta charset="utf-8"><title>Learning content</title>' + 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\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 };
|
|
}
|