diff --git a/modules/rdesign/design-agent-route.ts b/modules/rdesign/design-agent-route.ts index 2027259..6b566e6 100644 --- a/modules/rdesign/design-agent-route.ts +++ b/modules/rdesign/design-agent-route.ts @@ -15,6 +15,21 @@ const MAX_TURNS = 10; export const designAgentRoutes = new Hono(); +/** Human-readable description of a tool call. */ +function describeToolCall(name: string, args: Record): string { + switch (name) { + case "new_document": return `Creating ${args.width || 210}\u00d7${args.height || 297}mm document (${args.pages || 1} page${(args.pages || 1) > 1 ? "s" : ""})`; + case "add_text_frame": return `Adding text frame: "${(args.text || "").slice(0, 40)}${(args.text || "").length > 40 ? "\u2026" : ""}" at ${args.x},${args.y}mm (${args.width}\u00d7${args.height})`; + case "add_image_frame": return `Adding image frame at ${args.x},${args.y}mm (${args.width}\u00d7${args.height})`; + case "add_shape": return `Adding ${args.shapeType || "rect"} shape at ${args.x},${args.y}mm (${args.width}\u00d7${args.height}${args.fill ? ", fill:" + args.fill : ""})`; + case "set_background_color": return `Setting background color to ${args.color}`; + case "get_state": return "Checking current document state"; + case "save_document": return `Saving document as ${args.filename || "design.sla"}`; + case "generate_image": return `Generating AI image: "${(args.prompt || "").slice(0, 50)}${(args.prompt || "").length > 50 ? "\u2026" : ""}"`; + default: return `Executing ${name}`; + } +} + /** Forward a command to the Scribus bridge. */ async function bridgeCommand(action: string, args: Record = {}): Promise { const headers: Record = { "Content-Type": "application/json" }; @@ -169,6 +184,11 @@ designAgentRoutes.post("/api/design-agent", async (c) => { await stream.writeSSE({ data: JSON.stringify(data), event: "step", id: String(++eventId) }); }; + // Keepalive to prevent Cloudflare QUIC/HTTP2 timeout (drops idle streams ~100s) + const keepalive = setInterval(() => { + stream.writeSSE({ data: "", event: "keepalive", id: String(++eventId) }).catch(() => {}); + }, 15_000); + try { // Step 1: Ensure Scribus is running await sendEvent({ step: 1, action: "starting_scribus", status: "Starting Scribus..." }); @@ -185,8 +205,6 @@ designAgentRoutes.post("/api/design-agent", async (c) => { ]; for (let turn = 0; turn < MAX_TURNS; turn++) { - await sendEvent({ step: turn + 2, action: "thinking", status: `Turn ${turn + 1}: Asking Gemini...` }); - const response = await callGemini(messages, model); const candidate = response?.candidates?.[0]; if (!candidate) { @@ -198,12 +216,21 @@ designAgentRoutes.post("/api/design-agent", async (c) => { const textParts = parts.filter((p: any) => p.text); const toolCalls = parts.filter((p: any) => p.functionCall); + // Send thinking event with Gemini's reasoning text (if any) + const thinkingText = textParts.map((p: any) => p.text).join("\n").trim(); + await sendEvent({ + step: turn + 2, + action: "thinking", + status: thinkingText || `Planning turn ${turn + 1}...`, + text: thinkingText || null, + }); + // If Gemini returned text without tool calls, we're done if (textParts.length > 0 && toolCalls.length === 0) { await sendEvent({ step: turn + 2, action: "complete", - message: textParts.map((p: any) => p.text).join("\n"), + message: thinkingText, }); break; } @@ -217,7 +244,8 @@ designAgentRoutes.post("/api/design-agent", async (c) => { action: "executing", tool: name, args, - status: `Executing: ${name}`, + description: describeToolCall(name, args || {}), + status: describeToolCall(name, args || {}), }); const result = await executeToolCall(name, args || {}, space); @@ -252,6 +280,8 @@ designAgentRoutes.post("/api/design-agent", async (c) => { }); } catch (e: any) { await sendEvent({ step: 0, action: "error", error: e.message }); + } finally { + clearInterval(keepalive); } }); }); diff --git a/modules/rdesign/mod.ts b/modules/rdesign/mod.ts index 2826787..b01214f 100644 --- a/modules/rdesign/mod.ts +++ b/modules/rdesign/mod.ts @@ -72,147 +72,201 @@ routes.get("/", (c) => { }); const RDESIGN_CSS = ` -#rdesign-app { max-width:900px; margin:0 auto; padding:0.5rem 0; } -.rd-panel { background:var(--rs-bg-surface); border:1px solid var(--rs-border); border-radius:12px; overflow:hidden; } -.rd-prompt { padding:16px; border-bottom:1px solid var(--rs-border); } -.rd-prompt textarea { width:100%; padding:12px; border:2px solid var(--rs-border); border-radius:8px; font-size:14px; resize:none; outline:none; font-family:inherit; background:var(--rs-bg-page); color:var(--rs-text-primary); box-sizing:border-box; } -.rd-prompt textarea:focus { border-color:#7c3aed; } -.rd-prompt textarea::placeholder { color:var(--rs-text-secondary); } -.rd-btn-row { display:flex; gap:8px; margin-top:10px; align-items:center; } -.rd-btn { padding:8px 18px; border-radius:8px; border:none; font-size:13px; font-weight:600; cursor:pointer; transition:all 0.15s; } +/* Layout */ +.rd-app { display:flex; flex-direction:column; height:calc(100vh - 56px); overflow:hidden; } +.rd-toolbar { display:flex; align-items:center; gap:8px; padding:6px 12px; border-bottom:1px solid var(--rs-border); background:var(--rs-bg-surface); flex-shrink:0; } +.rd-split { display:flex; flex:1; overflow:hidden; } +.rd-chat { flex:1; display:flex; flex-direction:column; min-width:300px; border-right:1px solid var(--rs-border); background:var(--rs-bg-page); } +.rd-chat-messages { flex:1; overflow-y:auto; padding:12px; display:flex; flex-direction:column; gap:8px; } +.rd-chat-input { display:flex; gap:8px; padding:8px 12px; border-top:1px solid var(--rs-border); background:var(--rs-bg-surface); align-items:flex-end; } +.rd-chat-input textarea { flex:1; padding:8px 12px; border:2px solid var(--rs-border); border-radius:8px; font-size:13px; resize:none; outline:none; font-family:inherit; background:var(--rs-bg-page); color:var(--rs-text-primary); box-sizing:border-box; max-height:120px; } +.rd-chat-input textarea:focus { border-color:#7c3aed; } +.rd-chat-input textarea::placeholder { color:var(--rs-text-secondary); } +.rd-editor { flex:1; display:flex; flex-direction:column; min-width:300px; background:var(--rs-bg-page); } +.rd-canvas { flex:1; overflow:auto; display:flex; align-items:center; justify-content:center; background:var(--rs-bg-surface-sunken); } +.rd-statusbar { padding:4px 12px; font-size:11px; color:var(--rs-text-secondary); border-top:1px solid var(--rs-border); background:var(--rs-bg-surface); text-align:center; flex-shrink:0; } + +/* Buttons & badges */ +.rd-btn { padding:6px 14px; border-radius:8px; border:none; font-size:13px; font-weight:600; cursor:pointer; transition:all 0.15s; text-decoration:none; display:inline-flex; align-items:center; } .rd-btn-primary { background:linear-gradient(135deg,#7c3aed,#a78bfa); color:white; } -.rd-btn-primary:hover { background:linear-gradient(135deg,#6d28d9,#8b5cf6); transform:translateY(-1px); } -.rd-btn-primary:disabled { opacity:0.5; cursor:default; transform:none; } +.rd-btn-primary:hover { background:linear-gradient(135deg,#6d28d9,#8b5cf6); } +.rd-btn-primary:disabled { opacity:0.5; cursor:default; } +.rd-btn-sm { padding:4px 10px; font-size:11px; } .rd-btn-secondary { background:var(--rs-bg-surface-raised); color:var(--rs-text-secondary); border:1px solid var(--rs-border); } .rd-btn-secondary:hover { background:var(--rs-bg-hover); color:var(--rs-text-primary); } -.rd-badge { font-size:10px; padding:3px 8px; border-radius:10px; text-transform:uppercase; letter-spacing:0.5px; background:rgba(124,58,237,0.2); color:#a78bfa; margin-left:auto; } -.rd-body { display:flex; min-height:400px; } -.rd-steps { flex:1; overflow-y:auto; padding:12px; font-size:12px; border-right:1px solid var(--rs-border); max-height:500px; } -.rd-preview { flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center; padding:16px; min-width:300px; } -.rd-preview img { max-width:100%; max-height:400px; border-radius:8px; border:1px solid var(--rs-border); } -.rd-empty { color:var(--rs-text-secondary); text-align:center; font-size:13px; padding:3rem 1rem; } -.rd-step { padding:6px 0; border-bottom:1px solid var(--rs-border-subtle); display:flex; align-items:flex-start; gap:8px; } -.rd-step-icon { flex-shrink:0; width:18px; height:18px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:10px; margin-top:1px; } -.rd-step-icon.thinking { background:rgba(96,165,250,0.15); color:#60a5fa; } -.rd-step-icon.executing { background:rgba(251,191,36,0.15); color:#fbbf24; } -.rd-step-icon.done { background:rgba(52,211,153,0.15); color:#34d399; } -.rd-step-icon.error { background:rgba(248,113,113,0.15); color:#f87171; } -.rd-step-content { flex:1; line-height:1.4; color:var(--rs-text-secondary); } -.rd-step-tool { font-family:monospace; background:var(--rs-bg-surface-sunken); padding:1px 4px; border-radius:3px; font-size:11px; color:#a78bfa; } -.rd-export { display:flex; gap:8px; padding:12px 16px; border-top:1px solid var(--rs-border); justify-content:center; } +.rd-badge { font-size:10px; padding:3px 8px; border-radius:10px; text-transform:uppercase; letter-spacing:0.5px; background:rgba(124,58,237,0.2); color:#a78bfa; } +.rd-badge.rd-badge-error { background:rgba(248,113,113,0.2); color:#f87171; } +.rd-badge.rd-badge-done { background:rgba(52,211,153,0.2); color:#34d399; } + +/* Message bubbles */ +.rd-msg-user { align-self:flex-end; background:linear-gradient(135deg,#7c3aed,#a78bfa); color:white; border-radius:12px 12px 4px 12px; max-width:80%; padding:8px 12px; font-size:13px; line-height:1.4; white-space:pre-wrap; word-break:break-word; } +.rd-msg-agent { align-self:flex-start; background:var(--rs-bg-surface-raised); color:var(--rs-text-primary); border-radius:12px 12px 12px 4px; max-width:85%; padding:8px 12px; font-size:13px; line-height:1.4; white-space:pre-wrap; word-break:break-word; } +.rd-msg-system { align-self:center; color:var(--rs-text-secondary); font-size:11px; padding:2px 8px; } +.rd-msg-system.rd-msg-error { color:#f87171; } +.rd-msg-tool { margin-top:6px; font-size:12px; } +.rd-msg-tool summary { cursor:pointer; color:var(--rs-text-secondary); padding:2px 0; } +.rd-msg-tool summary:hover { color:var(--rs-text-primary); } +.rd-msg-tool .rd-tool-ok { color:#34d399; } +.rd-msg-tool .rd-tool-fail { color:#f87171; } +.rd-msg-tool pre { margin:4px 0 0; padding:6px 8px; background:var(--rs-bg-surface-sunken); border-radius:6px; font-size:11px; overflow-x:auto; white-space:pre-wrap; word-break:break-all; color:var(--rs-text-secondary); } + +/* Empty editor placeholder */ +.rd-empty-editor { color:var(--rs-text-secondary); text-align:center; font-size:13px; padding:3rem 1rem; } + +/* SVG editor */ +.rd-svg-wrap { position:relative; } +.rd-svg-wrap svg { display:block; } + +/* Spinner */ .rd-spinner { display:inline-block; width:12px; height:12px; border:2px solid rgba(124,58,237,0.3); border-top-color:#a78bfa; border-radius:50%; animation:rd-spin 0.8s linear infinite; vertical-align:middle; margin-right:4px; } @keyframes rd-spin { to { transform:rotate(360deg); } } -@media (max-width:700px) { - .rd-body { flex-direction:column; } - .rd-steps { border-right:none; border-bottom:1px solid var(--rs-border); max-height:250px; } + +/* Mobile */ +@media (max-width:800px) { + .rd-split { flex-direction:column; } + .rd-chat { min-height:50vh; max-height:50vh; border-right:none; border-bottom:1px solid var(--rs-border); } + .rd-editor { min-height:50vh; } } `; const RDESIGN_JS = ` (function() { - var brief = document.getElementById('rdesign-brief'); - var generateBtn = document.getElementById('rdesign-generate'); - var stopBtn = document.getElementById('rdesign-stop'); - var badge = document.getElementById('rdesign-badge'); - var stepsEl = document.getElementById('rdesign-steps'); - var previewEl = document.getElementById('rdesign-preview'); - var exportRow = document.getElementById('rdesign-export'); - var refineBtn = document.getElementById('rdesign-refine'); + var app = document.getElementById('rdesign-app'); + if (!app) return; + var space = app.dataset.space || 'demo'; + var messagesEl = document.getElementById('rd-messages'); + var inputEl = document.getElementById('rd-input'); + var sendBtn = document.getElementById('rd-send'); + var badge = document.getElementById('rd-badge'); + var canvasEl = document.getElementById('rd-canvas'); + var statusEl = document.getElementById('rd-status'); + + // ── State ── + var messages = []; + var msgId = 0; + var uiState = 'idle'; // idle | generating | done | error var abortController = null; - var state = 'idle'; - if (!brief) return; + var docState = { pages: [], frames: [] }; + var selectedId = null; + var dragState = null; // { frameIdx, startX, startY, origX, origY, mode:'move'|'resize', corner? } + // ── UI State ── function setState(s) { - state = s; - badge.textContent = s.charAt(0).toUpperCase() + s.slice(1); - var working = s !== 'idle' && s !== 'done' && s !== 'error'; - generateBtn.disabled = working; - generateBtn.innerHTML = working ? ' Working...' : 'Generate Design'; - stopBtn.style.display = working ? '' : 'none'; - exportRow.style.display = s === 'done' ? '' : 'none'; - brief.disabled = working; + uiState = s; + var working = s === 'generating'; + inputEl.disabled = working; + sendBtn.disabled = working; + sendBtn.innerHTML = working ? '' : 'Send'; + badge.textContent = { idle:'Idle', generating:'Working', done:'Done', error:'Error' }[s] || s; + badge.className = 'rd-badge' + (s === 'error' ? ' rd-badge-error' : s === 'done' ? ' rd-badge-done' : ''); } - function addStep(icon, cls, text) { - var ph = stepsEl.querySelector('.rd-empty'); - if (ph) ph.remove(); - var div = document.createElement('div'); - div.className = 'rd-step'; - div.innerHTML = '
' + icon + '
' + text + '
'; - stepsEl.appendChild(div); - stepsEl.scrollTop = stepsEl.scrollHeight; + // ── Chat Messages ── + function esc(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; } + + function addMessage(role, html) { + var id = ++msgId; + messages.push({ role: role, html: html, id: id }); + renderMessages(); + return id; } + function appendToLastAgent(html) { + for (var i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === 'agent') { + messages[i].html += html; + renderMessages(); + return; + } + } + addMessage('agent', html); + } + + function renderMessages() { + var h = ''; + for (var i = 0; i < messages.length; i++) { + var m = messages[i]; + var cls = m.role === 'user' ? 'rd-msg-user' : m.role === 'agent' ? 'rd-msg-agent' : 'rd-msg-system'; + if (m.role === 'system' && m.html.indexOf('error') > -1) cls += ' rd-msg-error'; + h += '
' + m.html + '
'; + } + messagesEl.innerHTML = h; + messagesEl.scrollTop = messagesEl.scrollHeight; + } + + // ── SSE Event Processing ── function processEvent(data) { switch (data.action) { - case 'starting_scribus': addStep('~', 'thinking', data.status || 'Starting Scribus...'); break; - case 'scribus_ready': addStep('\\u2713', 'done', 'Scribus ready'); break; - case 'thinking': setState('planning'); addStep('~', 'thinking', data.status || 'Thinking...'); break; + case 'starting_scribus': + addMessage('system', 'Starting Scribus...'); + break; + case 'scribus_ready': + addMessage('system', 'Scribus ready'); + break; + case 'thinking': + var text = data.text || data.status || 'Planning...'; + addMessage('agent', esc(text)); + setState('generating'); + break; case 'executing': - setState('executing'); - addStep('\\u25B6', 'executing', (data.status || 'Executing') + ': ' + data.tool + ''); + var desc = data.description || data.status || data.tool; + var argsJson = data.args ? JSON.stringify(data.args, null, 2) : ''; + var toolHtml = '
\\u{1f527} ' + esc(desc) + ''; + if (argsJson) toolHtml += '
' + esc(argsJson) + '
'; + toolHtml += '
'; + appendToLastAgent(toolHtml); break; case 'tool_result': - if (data.result && data.result.error) { - addStep('!', 'error', data.tool + ' failed: ' + data.result.error); - } else { - addStep('\\u2713', 'done', data.tool + ' completed'); + // Update the last
summary with result indicator + for (var i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === 'agent') { + var ok = !(data.result && data.result.error); + var indicator = ok ? ' \\u2713' : ' \\u2717 ' + esc(data.result.error || '') + ''; + // Append indicator before last + var h = messages[i].html; + var lastSummary = h.lastIndexOf(''); + if (lastSummary > -1) { + messages[i].html = h.slice(0, lastSummary) + indicator + h.slice(lastSummary); + } + renderMessages(); + break; + } + } + // Update doc state from tool_result if it contains frame/page data + if (data.result && !data.result.error) { + if (data.result.frames) docState.frames = data.result.frames; + if (data.result.pages) docState.pages = data.result.pages; } break; - case 'verifying': setState('verifying'); addStep('~', 'thinking', data.status || 'Verifying...'); break; - case 'complete': addStep('\\u2713', 'done', data.message || 'Design complete'); break; + case 'verifying': + addMessage('system', 'Verifying final state...'); + break; + case 'complete': + addMessage('agent', esc(data.message || 'Design complete.')); + break; case 'done': - addStep('\\u2713', 'done', data.status || 'Done!'); - if (data.state && data.state.frames) { - addStep('\\u2713', 'done', data.state.frames.length + ' frame(s) in document'); - } - if (data.state && data.state.frames && data.state.frames.length > 0) { - renderLayoutPreview(data.state); + addMessage('system', 'Design complete'); + if (data.state) { + docState = { pages: data.state.pages || [], frames: data.state.frames || [] }; + renderEditor(); } + setState('done'); break; case 'error': - addStep('!', 'error', data.error || 'Unknown error'); + addMessage('system', 'Error: ' + esc(data.error || 'Unknown error') + ''); setState('error'); break; } } - function renderLayoutPreview(docState) { - var pages = docState.pages || []; - var frames = docState.frames || []; - var page = pages[0] || { width: 210, height: 297 }; - var maxW = 350, maxH = 400; - var scale = Math.min(maxW / page.width, maxH / page.height); - var pw = Math.round(page.width * scale); - var pht = Math.round(page.height * scale); - var svg = ''; - for (var i = 0; i < frames.length; i++) { - var f = frames[i]; - var fx = f.x || 0, fy = f.y || 0, fw = f.width || 50, fh = f.height || 20; - if (f.type === 'TextFrame' || f.type === 'text') { - svg += ''; - var fs = Math.min((f.fontSize || 12) * 0.35, fh * 0.7); - var txt = (f.text || '').substring(0, 40).replace(/'+txt+''; - } else if (f.type === 'ImageFrame' || f.type === 'image') { - svg += ''; - svg += 'IMAGE'; - } else { - svg += ''; - } - } - svg += ''; - previewEl.innerHTML = svg + '
' + frames.length + ' frames on ' + page.width + '\\u00d7' + page.height + 'mm page
'; - } - - function generate() { - var text = brief.value.trim(); - if (!text || (state !== 'idle' && state !== 'done' && state !== 'error')) return; - stepsEl.innerHTML = ''; - previewEl.innerHTML = '
\\u{1f4d0}
Generating...
'; - setState('planning'); + // ── Send ── + function send() { + var text = inputEl.value.trim(); + if (!text || uiState === 'generating') return; + addMessage('user', esc(text)); + inputEl.value = ''; + setState('generating'); abortController = new AbortController(); - var space = document.getElementById('rdesign-app').dataset.space || 'demo'; var fetchHeaders = { 'Content-Type': 'application/json' }; try { var sess = JSON.parse(localStorage.getItem('encryptid_session') || '{}'); if (sess.accessToken) fetchHeaders['Authorization'] = 'Bearer ' + sess.accessToken; } catch(e) {} var basePath = window.location.pathname.replace(/\\/$/, ''); @@ -222,19 +276,22 @@ const RDESIGN_JS = ` body: JSON.stringify({ brief: text, space: space }), signal: abortController.signal, }).then(function(res) { - if (!res.ok || !res.body) { addStep('!', 'error', 'Request failed: ' + res.status); setState('error'); return; } + if (!res.ok || !res.body) { addMessage('system', 'Request failed: ' + res.status + ''); setState('error'); return; } var reader = res.body.getReader(); var decoder = new TextDecoder(); - var buffer = ''; + var buf = ''; function read() { reader.read().then(function(result) { - if (result.done) { if (state !== 'error') setState('done'); abortController = null; return; } - buffer += decoder.decode(result.value, { stream: true }); - var lines = buffer.split('\\n'); - buffer = lines.pop() || ''; + if (result.done) { if (uiState !== 'error' && uiState !== 'done') setState('done'); abortController = null; return; } + buf += decoder.decode(result.value, { stream: true }); + var lines = buf.split('\\n'); + buf = lines.pop() || ''; for (var j = 0; j < lines.length; j++) { - if (lines[j].indexOf('data:') === 0) { - try { processEvent(JSON.parse(lines[j].substring(5).trim())); } catch(e) {} + var line = lines[j].trim(); + if (line.indexOf('data:') === 0) { + var payload = line.substring(5).trim(); + if (!payload) continue; // keepalive + try { processEvent(JSON.parse(payload)); } catch(e) {} } } read(); @@ -242,46 +299,196 @@ const RDESIGN_JS = ` } read(); }).catch(function(e) { - if (e.name !== 'AbortError') { addStep('!', 'error', 'Error: ' + e.message); setState('error'); } + if (e.name !== 'AbortError') { addMessage('system', 'Error: ' + esc(e.message) + ''); setState('error'); } abortController = null; }); } - generateBtn.addEventListener('click', generate); - stopBtn.addEventListener('click', function() { if (abortController) abortController.abort(); setState('idle'); addStep('!', 'error', 'Stopped by user'); }); - brief.addEventListener('keydown', function(e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); generate(); } }); - refineBtn.addEventListener('click', function() { brief.focus(); brief.select(); }); + sendBtn.addEventListener('click', send); + inputEl.addEventListener('keydown', function(e) { + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } + }); + + // ── SVG Editor ── + function renderEditor() { + var pages = docState.pages || []; + var frames = docState.frames || []; + if (frames.length === 0 && pages.length === 0) { + canvasEl.innerHTML = '
Preview appears here after generation
'; + statusEl.textContent = 'No document'; + return; + } + + var page = pages[0] || { width: 210, height: 297 }; + var pw = page.width, ph = page.height; + + // Scale SVG to fit canvas (leave 24px padding) + var cw = canvasEl.clientWidth - 48 || 400; + var ch = canvasEl.clientHeight - 48 || 500; + var scale = Math.min(cw / pw, ch / ph); + var svgW = Math.round(pw * scale); + var svgH = Math.round(ph * scale); + + var svg = ''; + // Page background + svg += ''; + + for (var i = 0; i < frames.length; i++) { + var f = frames[i]; + var fx = f.x || 0, fy = f.y || 0, fw = f.width || 50, fh = f.height || 20; + var isSel = (selectedId === i); + var fType = (f.type || '').toLowerCase(); + + svg += ''; + + if (fType === 'textframe' || fType === 'text') { + svg += ''; + var fs = Math.min((f.fontSize || 12) * 0.35, fh * 0.7); + if (fs < 2) fs = 2; + var txt = (f.text || '').substring(0, 60).replace(/'+txt+''; + } else if (fType === 'imageframe' || fType === 'image') { + svg += ''; + svg += ''; + svg += ''; + svg += 'IMAGE'; + } else { + svg += ''; + } + + // Selection handles + if (isSel) { + svg += ''; + var hs = 2.5; // handle size in doc coords + var corners = [ + [fx - hs/2, fy - hs/2, 'nw'], + [fx + fw - hs/2, fy - hs/2, 'ne'], + [fx - hs/2, fy + fh - hs/2, 'sw'], + [fx + fw - hs/2, fy + fh - hs/2, 'se'], + ]; + for (var ci = 0; ci < corners.length; ci++) { + svg += ''; + } + } + + svg += ''; + } + svg += ''; + + canvasEl.innerHTML = '
' + svg + '
'; + statusEl.textContent = frames.length + ' frame' + (frames.length !== 1 ? 's' : '') + ' \\u00b7 ' + pw + '\\u00d7' + ph + 'mm'; + + // Attach pointer events + attachEditorEvents(scale, pw, ph); + } + + function attachEditorEvents(scale, pw, ph) { + var svgEl = canvasEl.querySelector('svg'); + if (!svgEl) return; + + svgEl.addEventListener('pointerdown', function(e) { + var target = e.target; + + // Handle resize + if (target.classList.contains('rd-handle')) { + e.preventDefault(); + e.stopPropagation(); + var corner = target.getAttribute('data-corner'); + var f = docState.frames[selectedId]; + if (!f) return; + dragState = { frameIdx: selectedId, startX: e.clientX, startY: e.clientY, origX: f.x, origY: f.y, origW: f.width, origH: f.height, mode: 'resize', corner: corner, scale: scale }; + return; + } + + // Find clicked frame group + var g = target.closest('g[data-idx]'); + if (g) { + e.preventDefault(); + var idx = parseInt(g.getAttribute('data-idx')); + selectedId = idx; + var f = docState.frames[idx]; + if (f) { + dragState = { frameIdx: idx, startX: e.clientX, startY: e.clientY, origX: f.x || 0, origY: f.y || 0, origW: f.width, origH: f.height, mode: 'move', scale: scale }; + } + renderEditor(); + } else { + // Deselect + if (selectedId !== null) { selectedId = null; renderEditor(); } + } + }); + + document.addEventListener('pointermove', onPointerMove); + document.addEventListener('pointerup', onPointerUp); + } + + function onPointerMove(e) { + if (!dragState) return; + var dx = (e.clientX - dragState.startX) / dragState.scale; + var dy = (e.clientY - dragState.startY) / dragState.scale; + var f = docState.frames[dragState.frameIdx]; + if (!f) return; + + if (dragState.mode === 'move') { + f.x = Math.round((dragState.origX + dx) * 10) / 10; + f.y = Math.round((dragState.origY + dy) * 10) / 10; + } else if (dragState.mode === 'resize') { + var c = dragState.corner; + if (c === 'se') { + f.width = Math.max(5, Math.round((dragState.origW + dx) * 10) / 10); + f.height = Math.max(5, Math.round((dragState.origH + dy) * 10) / 10); + } else if (c === 'ne') { + f.width = Math.max(5, Math.round((dragState.origW + dx) * 10) / 10); + var newH = Math.max(5, Math.round((dragState.origH - dy) * 10) / 10); + f.y = Math.round((dragState.origY + dragState.origH - newH) * 10) / 10; + f.height = newH; + } else if (c === 'sw') { + var newW = Math.max(5, Math.round((dragState.origW - dx) * 10) / 10); + f.x = Math.round((dragState.origX + dragState.origW - newW) * 10) / 10; + f.width = newW; + f.height = Math.max(5, Math.round((dragState.origH + dy) * 10) / 10); + } else if (c === 'nw') { + var newW = Math.max(5, Math.round((dragState.origW - dx) * 10) / 10); + var newH = Math.max(5, Math.round((dragState.origH - dy) * 10) / 10); + f.x = Math.round((dragState.origX + dragState.origW - newW) * 10) / 10; + f.y = Math.round((dragState.origY + dragState.origH - newH) * 10) / 10; + f.width = newW; + f.height = newH; + } + } + renderEditor(); + } + + function onPointerUp() { + dragState = null; + } + + // Initial render + renderEditor(); })(); `; function renderDesignApp(space: string, novncUrl: string): string { - return `
-
-
- -
- - - Idle + return `
+
+ Idle +
+ Open Scribus +
+
+
+
+
Enter a design brief to get started.
+
+
+ +
-
-
-
- Enter a design brief above and click Generate Design.
- The agent will create frames, text, and shapes in Scribus step by step. -
+
+
+
Preview appears here after generation
-
-
-
📐
- Preview will appear here
after generation completes -
-
-
-
`;