mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-14 18:22:46 -06:00
Merge pull request #59 from screentinker/fix/ai-canvas-fit
fix(ai): keep generated designs inside the canvas (#41)
This commit is contained in:
commit
958f5683e4
|
|
@ -45,7 +45,7 @@ export function render(container) {
|
|||
<!-- Preview -->
|
||||
<div style="flex:1">
|
||||
<div id="previewWrap" style="position:relative;border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden;background:#000;aspect-ratio:16/9">
|
||||
<div id="designPreview" style="position:relative;width:100%;height:100%;overflow:hidden"></div>
|
||||
<div id="designPreview" style="position:relative;width:100%;height:100%;overflow:hidden;container-type:inline-size"></div>
|
||||
</div>
|
||||
<p style="font-size:11px;color:var(--text-muted);margin-top:8px">${t('designer.preview_hint')}</p>
|
||||
</div>
|
||||
|
|
@ -421,13 +421,13 @@ function redraw() {
|
|||
|
||||
switch (el.type) {
|
||||
case 'text':
|
||||
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;font-size:${el.fontSize / 10}vw;font-family:${el.fontFamily};color:${el.color};font-weight:${el.bold ? 'bold' : 'normal'};${el.shadow ? 'text-shadow:2px 2px 4px rgba(0,0,0,0.5);' : ''}white-space:nowrap;${border}${cursor}" data-idx="${i}">${el.text}</div>`;
|
||||
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;font-size:${el.fontSize / 10}cqw;font-family:${el.fontFamily};color:${el.color};font-weight:${el.bold ? 'bold' : 'normal'};${el.shadow ? 'text-shadow:2px 2px 4px rgba(0,0,0,0.5);' : ''}white-space:nowrap;${border}${cursor}" data-idx="${i}">${el.text}</div>`;
|
||||
break;
|
||||
case 'clock':
|
||||
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;font-size:${el.fontSize / 10}vw;font-family:${el.fontFamily};color:${el.color};font-weight:bold;${el.shadow ? 'text-shadow:2px 2px 4px rgba(0,0,0,0.5);' : ''}${border}${cursor}" data-idx="${i}" id="clock_${i}"></div>`;
|
||||
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;font-size:${el.fontSize / 10}cqw;font-family:${el.fontFamily};color:${el.color};font-weight:bold;${el.shadow ? 'text-shadow:2px 2px 4px rgba(0,0,0,0.5);' : ''}${border}${cursor}" data-idx="${i}" id="clock_${i}"></div>`;
|
||||
break;
|
||||
case 'date':
|
||||
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;font-size:${el.fontSize / 10}vw;font-family:${el.fontFamily};color:${el.color};${el.shadow ? 'text-shadow:2px 2px 4px rgba(0,0,0,0.5);' : ''}${border}${cursor}" data-idx="${i}" id="date_${i}"></div>`;
|
||||
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;font-size:${el.fontSize / 10}cqw;font-family:${el.fontFamily};color:${el.color};${el.shadow ? 'text-shadow:2px 2px 4px rgba(0,0,0,0.5);' : ''}${border}${cursor}" data-idx="${i}" id="date_${i}"></div>`;
|
||||
break;
|
||||
case 'image':
|
||||
html += `<img src="${el.src}" style="position:absolute;left:${el.x}%;top:${el.y}%;width:${el.width}%;height:${el.height}%;object-fit:contain;${border}${cursor}" data-idx="${i}" draggable="false">`;
|
||||
|
|
@ -439,11 +439,11 @@ function redraw() {
|
|||
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;width:${el.width}%;height:${el.height}%;background:${el.color};opacity:${el.opacity};border-radius:${el.radius || 0}px;${el.shape === 'circle' ? 'border-radius:50%;' : ''}${border}${cursor}" data-idx="${i}"></div>`;
|
||||
break;
|
||||
case 'weather':
|
||||
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;font-size:${el.fontSize / 10}vw;color:${el.color};${border}${cursor}" data-idx="${i}" id="weather_${i}">⛅ ${t('common.loading')}</div>`;
|
||||
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;font-size:${el.fontSize / 10}cqw;color:${el.color};${border}${cursor}" data-idx="${i}" id="weather_${i}">⛅ ${t('common.loading')}</div>`;
|
||||
break;
|
||||
case 'ticker':
|
||||
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;width:${el.width}%;height:${el.height}%;background:${el.bgColor};overflow:hidden;display:flex;align-items:center;${border}" data-idx="${i}">
|
||||
<div style="white-space:nowrap;animation:ticker ${el.speed || 30}s linear infinite;font-size:${el.fontSize / 10}vw;color:${el.color}" id="ticker_${i}">${t('designer.loading_news')}</div>
|
||||
<div style="white-space:nowrap;animation:ticker ${el.speed || 30}s linear infinite;font-size:${el.fontSize / 10}cqw;color:${el.color}" id="ticker_${i}">${t('designer.loading_news')}</div>
|
||||
</div>`;
|
||||
break;
|
||||
case 'qr':
|
||||
|
|
@ -454,8 +454,8 @@ function redraw() {
|
|||
break;
|
||||
case 'countdown':
|
||||
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;text-align:center;color:${el.color};${border}${cursor}" data-idx="${i}">
|
||||
<div style="font-size:${el.fontSize / 15}vw;opacity:0.8">${el.label || ''}</div>
|
||||
<div style="font-size:${el.fontSize / 10}vw;font-weight:bold" id="countdown_${i}"></div>
|
||||
<div style="font-size:${el.fontSize / 15}cqw;opacity:0.8">${el.label || ''}</div>
|
||||
<div style="font-size:${el.fontSize / 10}cqw;font-weight:bold" id="countdown_${i}"></div>
|
||||
</div>`;
|
||||
break;
|
||||
case 'webpage':
|
||||
|
|
|
|||
|
|
@ -42,8 +42,28 @@ const clampN = (n, lo, hi, d) => { n = Number(n); return Number.isFinite(n) ? Ma
|
|||
const hex = (c, d) => (typeof c === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(c.trim())) ? c.trim() : d;
|
||||
const cleanText = (s) => String(s == null ? '' : s).replace(/<[^>]*>/g, '').trim().slice(0, 200);
|
||||
|
||||
// Keep generated text on the canvas. The Designer renders text nowrap at
|
||||
// ~fontSize/10 % of the canvas width per em, so long/large text runs off the
|
||||
// edge. Estimate width = chars * fontSize * 0.06 (% of canvas width) and height
|
||||
// = fontSize * 0.18 (% of canvas height); shrink fontSize to fit within 4%
|
||||
// margins, then nudge x/y in-bounds. Deterministic, so it doesn't depend on the
|
||||
// model getting layout right.
|
||||
function fitText(el) {
|
||||
// CW: width-% per (char * fontSize). 0.075 ~ bold/uppercase headlines (wider
|
||||
// than mixed-case). CH: height-% per fontSize incl. line-height.
|
||||
const M = 4, CW = 0.075, CH = 0.22;
|
||||
const len = Math.max(1, el.text.length);
|
||||
const maxByW = (100 - 2 * M) / (len * CW);
|
||||
const maxByH = (100 - 2 * M) / CH;
|
||||
el.fontSize = Math.floor(Math.max(8, Math.min(el.fontSize, maxByW, maxByH)));
|
||||
const w = len * el.fontSize * CW;
|
||||
const h = el.fontSize * CH;
|
||||
el.x = Math.round(Math.min(Math.max(el.x, M), Math.max(M, 100 - M - w)) * 10) / 10;
|
||||
el.y = Math.round(Math.min(Math.max(el.y, M), Math.max(M, 100 - M - h)) * 10) / 10;
|
||||
}
|
||||
|
||||
// Never trust raw model output: cap count, clamp ranges, fix px-vs-% (models
|
||||
// often emit pixels), strip any HTML from text, validate colors.
|
||||
// often emit pixels), strip any HTML from text, validate colors, fit to canvas.
|
||||
function normalizeDesign(raw) {
|
||||
const out = { background: hex(raw && raw.background, '#111827'), elements: [] };
|
||||
const els = Array.isArray(raw && raw.elements) ? raw.elements.slice(0, 20) : [];
|
||||
|
|
@ -52,19 +72,25 @@ function normalizeDesign(raw) {
|
|||
if (e.type === 'text') {
|
||||
const text = cleanText(e.text);
|
||||
if (!text) continue;
|
||||
out.elements.push({
|
||||
const el = {
|
||||
type: 'text', x: clampN(e.x, 0, 95, 5), y: clampN(e.y, 0, 95, 5), text,
|
||||
fontSize: clampN(e.fontSize, 12, 200, 48), fontFamily: 'Arial',
|
||||
color: hex(e.color, '#FFFFFF'), bold: !!e.bold, shadow: !!e.shadow,
|
||||
});
|
||||
};
|
||||
fitText(el);
|
||||
out.elements.push(el);
|
||||
} else if (e.type === 'shape') {
|
||||
let w = Number(e.width), h = Number(e.height);
|
||||
if (w > 100) w = w / 19.2; // px of 1920 -> %
|
||||
if (h > 100) h = h / 10.8; // px of 1080 -> %
|
||||
w = clampN(w, 1, 100, 30);
|
||||
h = clampN(h, 1, 100, 20);
|
||||
out.elements.push({
|
||||
type: 'shape', shape: 'rect',
|
||||
x: clampN(e.x, 0, 100, 0), y: clampN(e.y, 0, 100, 0),
|
||||
width: clampN(w, 1, 100, 30), height: clampN(h, 1, 100, 20),
|
||||
// keep the shape on-canvas: x+width <= 100, y+height <= 100
|
||||
x: Math.min(clampN(e.x, 0, 100, 0), 100 - w),
|
||||
y: Math.min(clampN(e.y, 0, 100, 0), 100 - h),
|
||||
width: w, height: h,
|
||||
color: hex(e.color, '#3b82f6'), opacity: clampN(e.opacity, 0, 1, 0.85), radius: 0,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,8 +35,8 @@ test('normalizeDesign: converts pixel shape dims to %, clamps ranges', () => {
|
|||
{ type: 'shape', x: -10, y: 200, width: 1920, height: 1080, color: 'red', opacity: 5 },
|
||||
]});
|
||||
const s = d.elements[0];
|
||||
assert.equal(s.x, 0, 'x clamped to 0');
|
||||
assert.equal(s.y, 100, 'y clamped to 100');
|
||||
assert.equal(s.x, 0, 'x clamped so full-width shape fits');
|
||||
assert.equal(s.y, 0, 'y clamped so full-height shape fits (y+height<=100)');
|
||||
assert.ok(Math.abs(s.width - 100) < 0.01, '1920px -> 100%');
|
||||
assert.ok(Math.abs(s.height - 100) < 0.01, '1080px -> 100%');
|
||||
assert.equal(s.color, '#3b82f6', 'non-hex color -> default');
|
||||
|
|
@ -74,3 +74,15 @@ test('endpointAllowed: blocks private/internal when hosted, allows public https'
|
|||
assert.equal(endpointAllowed('ftp://example.com'), false, 'non-http blocked');
|
||||
assert.equal(endpointAllowed('not a url'), false);
|
||||
});
|
||||
|
||||
test('normalizeDesign: long/large text is shrunk + repositioned to fit canvas', () => {
|
||||
const d = normalizeDesign({ elements: [
|
||||
{ type: 'text', x: 30, y: 95, text: 'GRAND OPENING THIS FRIDAY EVERYONE WELCOME', fontSize: 160, color: '#fff' },
|
||||
]});
|
||||
const e = d.elements[0];
|
||||
const w = e.text.length * e.fontSize * 0.075;
|
||||
assert.ok(e.x + w <= 96.5, `fits horizontally (x=${e.x} w=${w.toFixed(1)})`);
|
||||
assert.ok(e.y + e.fontSize * 0.22 <= 96.5, 'fits vertically');
|
||||
assert.ok(e.fontSize < 160, 'fontSize was shrunk');
|
||||
assert.ok(e.x >= 4 && e.y >= 4, 'within margins');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue