mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-21 05:32:34 -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 -->
|
<!-- Preview -->
|
||||||
<div style="flex:1">
|
<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="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>
|
</div>
|
||||||
<p style="font-size:11px;color:var(--text-muted);margin-top:8px">${t('designer.preview_hint')}</p>
|
<p style="font-size:11px;color:var(--text-muted);margin-top:8px">${t('designer.preview_hint')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -421,13 +421,13 @@ function redraw() {
|
||||||
|
|
||||||
switch (el.type) {
|
switch (el.type) {
|
||||||
case 'text':
|
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;
|
break;
|
||||||
case 'clock':
|
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;
|
break;
|
||||||
case 'date':
|
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;
|
break;
|
||||||
case 'image':
|
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">`;
|
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>`;
|
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;
|
break;
|
||||||
case 'weather':
|
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;
|
break;
|
||||||
case 'ticker':
|
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}">
|
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>`;
|
</div>`;
|
||||||
break;
|
break;
|
||||||
case 'qr':
|
case 'qr':
|
||||||
|
|
@ -454,8 +454,8 @@ function redraw() {
|
||||||
break;
|
break;
|
||||||
case 'countdown':
|
case 'countdown':
|
||||||
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;text-align:center;color:${el.color};${border}${cursor}" data-idx="${i}">
|
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 / 15}cqw;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 / 10}cqw;font-weight:bold" id="countdown_${i}"></div>
|
||||||
</div>`;
|
</div>`;
|
||||||
break;
|
break;
|
||||||
case 'webpage':
|
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 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);
|
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
|
// 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) {
|
function normalizeDesign(raw) {
|
||||||
const out = { background: hex(raw && raw.background, '#111827'), elements: [] };
|
const out = { background: hex(raw && raw.background, '#111827'), elements: [] };
|
||||||
const els = Array.isArray(raw && raw.elements) ? raw.elements.slice(0, 20) : [];
|
const els = Array.isArray(raw && raw.elements) ? raw.elements.slice(0, 20) : [];
|
||||||
|
|
@ -52,19 +72,25 @@ function normalizeDesign(raw) {
|
||||||
if (e.type === 'text') {
|
if (e.type === 'text') {
|
||||||
const text = cleanText(e.text);
|
const text = cleanText(e.text);
|
||||||
if (!text) continue;
|
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,
|
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',
|
fontSize: clampN(e.fontSize, 12, 200, 48), fontFamily: 'Arial',
|
||||||
color: hex(e.color, '#FFFFFF'), bold: !!e.bold, shadow: !!e.shadow,
|
color: hex(e.color, '#FFFFFF'), bold: !!e.bold, shadow: !!e.shadow,
|
||||||
});
|
};
|
||||||
|
fitText(el);
|
||||||
|
out.elements.push(el);
|
||||||
} else if (e.type === 'shape') {
|
} else if (e.type === 'shape') {
|
||||||
let w = Number(e.width), h = Number(e.height);
|
let w = Number(e.width), h = Number(e.height);
|
||||||
if (w > 100) w = w / 19.2; // px of 1920 -> %
|
if (w > 100) w = w / 19.2; // px of 1920 -> %
|
||||||
if (h > 100) h = h / 10.8; // px of 1080 -> %
|
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({
|
out.elements.push({
|
||||||
type: 'shape', shape: 'rect',
|
type: 'shape', shape: 'rect',
|
||||||
x: clampN(e.x, 0, 100, 0), y: clampN(e.y, 0, 100, 0),
|
// keep the shape on-canvas: x+width <= 100, y+height <= 100
|
||||||
width: clampN(w, 1, 100, 30), height: clampN(h, 1, 100, 20),
|
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,
|
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 },
|
{ type: 'shape', x: -10, y: 200, width: 1920, height: 1080, color: 'red', opacity: 5 },
|
||||||
]});
|
]});
|
||||||
const s = d.elements[0];
|
const s = d.elements[0];
|
||||||
assert.equal(s.x, 0, 'x clamped to 0');
|
assert.equal(s.x, 0, 'x clamped so full-width shape fits');
|
||||||
assert.equal(s.y, 100, 'y clamped to 100');
|
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.width - 100) < 0.01, '1920px -> 100%');
|
||||||
assert.ok(Math.abs(s.height - 100) < 0.01, '1080px -> 100%');
|
assert.ok(Math.abs(s.height - 100) < 0.01, '1080px -> 100%');
|
||||||
assert.equal(s.color, '#3b82f6', 'non-hex color -> default');
|
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('ftp://example.com'), false, 'non-http blocked');
|
||||||
assert.equal(endpointAllowed('not a url'), false);
|
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