690 lines
29 KiB
TypeScript
690 lines
29 KiB
TypeScript
/**
|
|
* rDesign module — collaborative DTP workspace via Scribus + noVNC.
|
|
*
|
|
* Embeds Scribus running in a Docker container with noVNC for browser access.
|
|
* Includes a generative design agent powered by Gemini tool-calling.
|
|
*/
|
|
|
|
import { Hono } from "hono";
|
|
import { renderShell } from "../../server/shell";
|
|
import { getModuleInfoList } from "../../shared/module";
|
|
import type { RSpaceModule } from "../../shared/module";
|
|
import { designAgentRoutes } from "./design-agent-route";
|
|
|
|
const routes = new Hono();
|
|
|
|
const SCRIBUS_NOVNC_URL = process.env.SCRIBUS_NOVNC_URL || "https://design.rspace.online";
|
|
const SCRIBUS_BRIDGE_URL = process.env.SCRIBUS_BRIDGE_URL || "http://scribus-novnc:8765";
|
|
|
|
// Mount design agent API routes
|
|
routes.route("/", designAgentRoutes);
|
|
|
|
routes.get("/api/health", (c) => {
|
|
return c.json({ ok: true, module: "rdesign" });
|
|
});
|
|
|
|
// Proxy bridge API calls from rspace to the Scribus container
|
|
routes.all("/api/bridge/*", async (c) => {
|
|
const path = c.req.path.replace(/^.*\/api\/bridge/, "/api/scribus");
|
|
const bridgeSecret = process.env.SCRIBUS_BRIDGE_SECRET || "";
|
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
if (bridgeSecret) headers["X-Bridge-Secret"] = bridgeSecret;
|
|
|
|
try {
|
|
const url = `${SCRIBUS_BRIDGE_URL}${path}`;
|
|
const res = await fetch(url, {
|
|
method: c.req.method,
|
|
headers,
|
|
body: c.req.method !== "GET" ? await c.req.text() : undefined,
|
|
signal: AbortSignal.timeout(30_000),
|
|
});
|
|
const data = await res.json();
|
|
return c.json(data, res.status as any);
|
|
} catch (e: any) {
|
|
return c.json({ error: `Bridge proxy failed: ${e.message}` }, 502);
|
|
}
|
|
});
|
|
|
|
routes.get("/", (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const view = c.req.query("view");
|
|
|
|
if (view === "demo") {
|
|
return c.html(renderShell({
|
|
title: `${space} — Design | rSpace`,
|
|
moduleId: "rdesign",
|
|
spaceSlug: space,
|
|
modules: getModuleInfoList(),
|
|
body: renderDesignLanding(),
|
|
}));
|
|
}
|
|
|
|
// Default: show the design agent UI (text-driven, no iframe)
|
|
return c.html(renderShell({
|
|
title: `${space} — rDesign | rSpace`,
|
|
moduleId: "rdesign",
|
|
spaceSlug: space,
|
|
modules: getModuleInfoList(),
|
|
body: renderDesignApp(space, SCRIBUS_NOVNC_URL),
|
|
styles: `<style>${RDESIGN_CSS}</style>`,
|
|
scripts: `<script>${RDESIGN_JS}</script>`,
|
|
}));
|
|
});
|
|
|
|
const RDESIGN_CSS = `
|
|
/* Layout — prevent page scroll */
|
|
html:has(.rd-app), html:has(.rd-app) body { overflow:hidden; height:100vh; }
|
|
.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; flex-shrink:0; }
|
|
.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); }
|
|
.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; }
|
|
.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); } }
|
|
|
|
/* 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 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 docState = { pages: [], frames: [] };
|
|
var selectedId = null;
|
|
var dragState = null; // { frameIdx, startX, startY, origX, origY, mode:'move'|'resize', corner? }
|
|
|
|
// ── UI State ──
|
|
function setState(s) {
|
|
uiState = s;
|
|
var working = s === 'generating';
|
|
inputEl.disabled = working;
|
|
sendBtn.disabled = working;
|
|
sendBtn.innerHTML = working ? '<span class="rd-spinner"></span>' : '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' : '');
|
|
}
|
|
|
|
// ── 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 += '<div class="' + cls + '">' + m.html + '</div>';
|
|
}
|
|
messagesEl.innerHTML = h;
|
|
messagesEl.scrollTop = messagesEl.scrollHeight;
|
|
}
|
|
|
|
// ── SSE Event Processing ──
|
|
function processEvent(data) {
|
|
switch (data.action) {
|
|
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':
|
|
var desc = data.description || data.status || data.tool;
|
|
var argsJson = data.args ? JSON.stringify(data.args, null, 2) : '';
|
|
var toolHtml = '<div class="rd-msg-tool"><details><summary>\\u{1f527} ' + esc(desc) + '</summary>';
|
|
if (argsJson) toolHtml += '<pre>' + esc(argsJson) + '</pre>';
|
|
toolHtml += '</details></div>';
|
|
appendToLastAgent(toolHtml);
|
|
break;
|
|
case 'tool_result':
|
|
// Update the last <details> 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 ? ' <span class="rd-tool-ok">\\u2713</span>' : ' <span class="rd-tool-fail">\\u2717 ' + esc(data.result.error || '') + '</span>';
|
|
// Append indicator before last </summary>
|
|
var h = messages[i].html;
|
|
var lastSummary = h.lastIndexOf('</summary>');
|
|
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':
|
|
addMessage('system', 'Verifying final state...');
|
|
break;
|
|
case 'complete':
|
|
addMessage('agent', esc(data.message || 'Design complete.'));
|
|
break;
|
|
case 'done':
|
|
addMessage('system', 'Design complete');
|
|
if (data.state) {
|
|
docState = { pages: data.state.pages || [], frames: data.state.frames || [] };
|
|
renderEditor();
|
|
}
|
|
setState('done');
|
|
break;
|
|
case 'error':
|
|
addMessage('system', '<span style="color:#f87171">Error: ' + esc(data.error || 'Unknown error') + '</span>');
|
|
setState('error');
|
|
break;
|
|
}
|
|
}
|
|
|
|
// ── 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 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(/\\/$/, '');
|
|
fetch(basePath + '/api/design-agent', {
|
|
method: 'POST',
|
|
headers: fetchHeaders,
|
|
body: JSON.stringify({ brief: text, space: space }),
|
|
signal: abortController.signal,
|
|
}).then(function(res) {
|
|
if (!res.ok || !res.body) { addMessage('system', '<span style="color:#f87171">Request failed: ' + res.status + '</span>'); setState('error'); return; }
|
|
var reader = res.body.getReader();
|
|
var decoder = new TextDecoder();
|
|
var buf = '';
|
|
function read() {
|
|
reader.read().then(function(result) {
|
|
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++) {
|
|
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();
|
|
}).catch(function(e) {
|
|
// Stream closed (Cloudflare QUIC reset or normal close)
|
|
if (uiState !== 'error' && uiState !== 'done') setState('done');
|
|
abortController = null;
|
|
});
|
|
}
|
|
read();
|
|
}).catch(function(e) {
|
|
if (e.name !== 'AbortError') { addMessage('system', '<span style="color:#f87171">Error: ' + esc(e.message) + '</span>'); setState('error'); }
|
|
abortController = null;
|
|
});
|
|
}
|
|
|
|
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 = '<div class="rd-empty-editor">Preview appears here after generation</div>';
|
|
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 = '<svg xmlns="http://www.w3.org/2000/svg" width="' + svgW + '" height="' + svgH + '" viewBox="0 0 ' + pw + ' ' + ph + '" style="filter:drop-shadow(0 2px 8px rgba(0,0,0,0.25));cursor:default">';
|
|
// Page background
|
|
svg += '<rect width="' + pw + '" height="' + ph + '" fill="white" rx="1"/>';
|
|
|
|
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 += '<g data-idx="' + i + '" style="cursor:move">';
|
|
|
|
if (fType === 'textframe' || fType === 'text') {
|
|
svg += '<rect x="'+fx+'" y="'+fy+'" width="'+fw+'" height="'+fh+'" fill="rgba(124,58,237,0.04)" stroke="#7c3aed" stroke-width="' + (isSel ? 1 : 0.5) + '" stroke-dasharray="' + (isSel ? '' : '2,1') + '" rx="0.5"/>';
|
|
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(/</g, '<');
|
|
svg += '<text x="'+(fx+2)+'" y="'+(fy+fs+1)+'" font-size="'+fs+'" fill="#334155" font-family="sans-serif">'+txt+'</text>';
|
|
} else if (fType === 'imageframe' || fType === 'image') {
|
|
svg += '<rect x="'+fx+'" y="'+fy+'" width="'+fw+'" height="'+fh+'" fill="#f1f5f9" stroke="' + (isSel ? '#3b82f6' : '#94a3b8') + '" stroke-width="' + (isSel ? 1 : 0.5) + '" rx="0.5"/>';
|
|
svg += '<line x1="'+fx+'" y1="'+fy+'" x2="'+(fx+fw)+'" y2="'+(fy+fh)+'" stroke="#cbd5e1" stroke-width="0.3"/>';
|
|
svg += '<line x1="'+(fx+fw)+'" y1="'+fy+'" x2="'+fx+'" y2="'+(fy+fh)+'" stroke="#cbd5e1" stroke-width="0.3"/>';
|
|
svg += '<text x="'+(fx+fw/2)+'" y="'+(fy+fh/2+2)+'" font-size="4" fill="#94a3b8" text-anchor="middle" font-family="sans-serif">IMAGE</text>';
|
|
} else {
|
|
svg += '<rect x="'+fx+'" y="'+fy+'" width="'+fw+'" height="'+fh+'" fill="'+(f.fill||'#e2e8f0')+'" stroke="' + (isSel ? '#3b82f6' : '#94a3b8') + '" stroke-width="' + (isSel ? 1 : 0.3) + '" rx="0.5"/>';
|
|
}
|
|
|
|
// Selection handles
|
|
if (isSel) {
|
|
svg += '<rect x="'+fx+'" y="'+fy+'" width="'+fw+'" height="'+fh+'" fill="none" stroke="#3b82f6" stroke-width="0.8"/>';
|
|
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 += '<rect class="rd-handle" data-corner="' + corners[ci][2] + '" x="'+corners[ci][0]+'" y="'+corners[ci][1]+'" width="'+hs+'" height="'+hs+'" fill="white" stroke="#3b82f6" stroke-width="0.5" style="cursor:nwse-resize"/>';
|
|
}
|
|
}
|
|
|
|
svg += '</g>';
|
|
}
|
|
svg += '</svg>';
|
|
|
|
canvasEl.innerHTML = '<div class="rd-svg-wrap">' + svg + '</div>';
|
|
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 `<div id="rdesign-app" data-space="${space}" class="rd-app">
|
|
<div class="rd-toolbar">
|
|
<span class="rd-badge" id="rd-badge">Idle</span>
|
|
<div style="flex:1"></div>
|
|
<a href="${novncUrl}" target="_blank" rel="noopener" class="rd-btn rd-btn-secondary rd-btn-sm">Open Scribus</a>
|
|
</div>
|
|
<div class="rd-split">
|
|
<div class="rd-chat">
|
|
<div class="rd-chat-messages" id="rd-messages">
|
|
<div class="rd-msg-system">Enter a design brief to get started.</div>
|
|
</div>
|
|
<div class="rd-chat-input">
|
|
<textarea id="rd-input" rows="2" placeholder="Describe your design..."></textarea>
|
|
<button id="rd-send" class="rd-btn rd-btn-primary">Send</button>
|
|
</div>
|
|
</div>
|
|
<div class="rd-editor">
|
|
<div class="rd-canvas" id="rd-canvas">
|
|
<div class="rd-empty-editor">Preview appears here after generation</div>
|
|
</div>
|
|
<div class="rd-statusbar" id="rd-status">No document</div>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function renderDesignLanding(): string {
|
|
return `
|
|
<!-- Hero -->
|
|
<div class="rl-hero">
|
|
<span class="rl-tagline">rDesign</span>
|
|
<h1 class="rl-heading" style="background:linear-gradient(135deg,#7c3aed,#a78bfa);-webkit-background-clip:text;background-clip:text">(You)rDesign, text in — design out.</h1>
|
|
<p class="rl-subtitle" style="background:linear-gradient(135deg,#7c3aed,#a78bfa);-webkit-background-clip:text;background-clip:text">AI-Powered Desktop Publishing with Scribus</p>
|
|
<p class="rl-subtext">
|
|
Describe what you want and the design agent builds it — posters, flyers, brochures, zines, and print-ready documents.
|
|
No mouse interaction needed. Powered by Gemini tool-calling and Scribus under the hood.
|
|
</p>
|
|
<div class="rl-cta-row">
|
|
<a href="https://demo.rspace.online/rdesign" class="rl-cta-primary">Open rDesign</a>
|
|
<a href="#how-it-works" class="rl-cta-secondary">How It Works</a>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- What It Handles -->
|
|
<section class="rl-section">
|
|
<div class="rl-container">
|
|
<h2 class="rl-heading" style="text-align:center">What rDesign Handles</h2>
|
|
<div class="rl-grid-3">
|
|
<div class="rl-card rl-card--center">
|
|
<div class="rl-icon-box">📄</div>
|
|
<h3>Posters & Flyers</h3>
|
|
<p>Describe your event, campaign, or announcement and get a print-ready poster with proper typography, layout, and image placement.</p>
|
|
</div>
|
|
<div class="rl-card rl-card--center">
|
|
<div class="rl-icon-box">📚</div>
|
|
<h3>Brochures & Zines</h3>
|
|
<p>Multi-page layouts with text flow, columns, and image frames. Perfect for newsletters, pamphlets, and small publications.</p>
|
|
</div>
|
|
<div class="rl-card rl-card--center">
|
|
<div class="rl-icon-box">🎨</div>
|
|
<h3>Brand Assets</h3>
|
|
<p>Business cards, letterheads, social media graphics. Consistent typography and color from a single design brief.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- How It Works -->
|
|
<section class="rl-section rl-section--alt" id="how-it-works">
|
|
<div class="rl-container">
|
|
<h2 class="rl-heading" style="text-align:center">How It Works</h2>
|
|
<div class="rl-grid-4" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1.5rem">
|
|
<div class="rl-step">
|
|
<div class="rl-step__num">1</div>
|
|
<h3>Describe</h3>
|
|
<p>Write a natural-language brief — “A3 poster for a jazz night, deep blue background, gold accents, vintage feel.”</p>
|
|
</div>
|
|
<div class="rl-step">
|
|
<div class="rl-step__num">2</div>
|
|
<h3>Agent Plans</h3>
|
|
<p>The AI agent decomposes your brief into Scribus operations: document size, text frames, image placement, colors, and fonts.</p>
|
|
</div>
|
|
<div class="rl-step">
|
|
<div class="rl-step__num">3</div>
|
|
<h3>Live Preview</h3>
|
|
<p>Watch the design take shape in real time. Each tool call updates the SVG preview so you see progress as it happens.</p>
|
|
</div>
|
|
<div class="rl-step">
|
|
<div class="rl-step__num">4</div>
|
|
<h3>Export</h3>
|
|
<p>Open the finished document in Scribus for fine-tuning, or export directly as PDF for print.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Capabilities -->
|
|
<section class="rl-section">
|
|
<div class="rl-container">
|
|
<h2 class="rl-heading" style="text-align:center">Design Agent Capabilities</h2>
|
|
<div class="rl-grid-3">
|
|
<div class="rl-card rl-card--center">
|
|
<div class="rl-icon-box">🗎</div>
|
|
<h3>Text Frames</h3>
|
|
<p>Add and position text with control over font, size, color, alignment, and line spacing. The agent handles typography best practices.</p>
|
|
</div>
|
|
<div class="rl-card rl-card--center">
|
|
<div class="rl-icon-box">🖼</div>
|
|
<h3>Image Frames</h3>
|
|
<p>Place images from URLs or generate them inline with AI. Automatic scaling and positioning within the layout.</p>
|
|
</div>
|
|
<div class="rl-card rl-card--center">
|
|
<div class="rl-icon-box">■</div>
|
|
<h3>Shapes & Backgrounds</h3>
|
|
<p>Rectangles, ellipses, decorative elements, and full-page backgrounds. Set colors, borders, and opacity per element.</p>
|
|
</div>
|
|
</div>
|
|
<div style="display:flex;flex-wrap:wrap;gap:0.5rem;justify-content:center;margin-top:1.5rem">
|
|
<span class="rl-badge" style="background:rgba(124,58,237,0.15);color:#a78bfa">A4/A3/Letter/Custom</span>
|
|
<span class="rl-badge" style="background:rgba(124,58,237,0.15);color:#a78bfa">PDF Export</span>
|
|
<span class="rl-badge" style="background:rgba(124,58,237,0.15);color:#a78bfa">AI Image Generation</span>
|
|
<span class="rl-badge" style="background:rgba(124,58,237,0.15);color:#a78bfa">Multi-page</span>
|
|
<span class="rl-badge" style="background:rgba(124,58,237,0.15);color:#a78bfa">Print-ready</span>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Built on Open Source -->
|
|
<section class="rl-section rl-section--alt">
|
|
<div class="rl-container">
|
|
<h2 class="rl-heading" style="text-align:center">Built on Open Source</h2>
|
|
<p class="rl-subtext" style="text-align:center">The tools that power rDesign.</p>
|
|
<div class="rl-grid-4" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1.5rem">
|
|
<div class="rl-card rl-card--center">
|
|
<h3>Scribus</h3>
|
|
<p>Professional desktop publishing — the open-source alternative to InDesign. Runs headless in Docker via Xvfb + noVNC.</p>
|
|
</div>
|
|
<div class="rl-card rl-card--center">
|
|
<h3>Gemini</h3>
|
|
<p>Google’s multimodal AI with native tool-calling. Powers the design agent’s planning and execution loop.</p>
|
|
</div>
|
|
<div class="rl-card rl-card--center">
|
|
<h3>noVNC</h3>
|
|
<p>Browser-based VNC client for direct Scribus access. Open the full desktop when you need pixel-perfect manual edits.</p>
|
|
</div>
|
|
<div class="rl-card rl-card--center">
|
|
<h3>Hono</h3>
|
|
<p>Ultra-fast API framework powering the bridge between the design agent and Scribus.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Your Data, Protected -->
|
|
<section class="rl-section">
|
|
<div class="rl-container" style="text-align:center">
|
|
<h2 class="rl-heading">Your Designs, Protected</h2>
|
|
<p class="rl-subtext">All processing happens on your server — designs never leave your infrastructure.</p>
|
|
<div class="rl-grid-3">
|
|
<div class="rl-card rl-card--center">
|
|
<div class="rl-icon-box">🏠</div>
|
|
<h3>Self-Hosted</h3>
|
|
<p>Scribus runs in a Docker container on your own server. No cloud DTP service, no vendor lock-in.</p>
|
|
</div>
|
|
<div class="rl-card rl-card--center">
|
|
<div class="rl-icon-box">💾</div>
|
|
<h3>Persistent Storage</h3>
|
|
<p>Design files are stored in a Docker volume and persist across container restarts and updates.</p>
|
|
</div>
|
|
<div class="rl-card rl-card--center">
|
|
<div class="rl-icon-box">🔧</div>
|
|
<h3>Full Source Access</h3>
|
|
<p>Open the .sla file in Scribus anytime. Native format, no proprietary lock-in. Export to PDF, SVG, or PNG.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- CTA -->
|
|
<section class="rl-section rl-section--alt">
|
|
<div class="rl-container" style="text-align:center">
|
|
<h2 class="rl-heading">(You)rDesign, text in — design out.</h2>
|
|
<p class="rl-subtext">Describe it and the agent builds it. Try the demo or create a space to get started.</p>
|
|
<div class="rl-cta-row">
|
|
<a href="https://demo.rspace.online/rdesign" class="rl-cta-primary">Open rDesign</a>
|
|
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<div class="rl-back">
|
|
<a href="/">← Back to rSpace</a>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
export const designModule: RSpaceModule = {
|
|
id: "rdesign",
|
|
name: "rDesign",
|
|
icon: "🎨",
|
|
description: "AI-powered DTP workspace — text in, design out",
|
|
scoping: { defaultScope: 'global', userConfigurable: false },
|
|
publicWrite: true,
|
|
routes,
|
|
landingPage: renderDesignLanding,
|
|
feeds: [
|
|
{ id: "design-assets", name: "Design Assets", kind: "resource", description: "Design files, layouts, and print-ready exports" },
|
|
],
|
|
acceptsFeeds: ["data", "resource"],
|
|
outputPaths: [
|
|
{ path: "designs", name: "Designs", icon: "🎨", description: "Design files and layouts" },
|
|
{ path: "templates", name: "Templates", icon: "📐", description: "Reusable design templates" },
|
|
],
|
|
};
|