Files
jaybe dc54226351
Auto PR / open-pr (push) Successful in 20s
Auto Label PR / add-automerge-label (pull_request) Successful in 6s
CI / test (pull_request) Successful in 1m28s
스타일 토큰 회귀 강화: focus-visible 링/오프셋, disabled/readonly 토큰 및 규칙 추가, 테스트 보강
2025-11-17 17:44:18 +09:00

156 lines
7.9 KiB
TypeScript

import type { Frame, WObject } from '@/lib/wysiwyg/schema'
export type Exported = { html: string; css: string; js: string }
function esc(s: string) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
function isImage(o: WObject): o is Extract<WObject, { type: 'image' }> { return o.type === 'image' }
function isText(o: WObject): o is Extract<WObject, { type: 'text' }> { return o.type === 'text' }
function isButton(o: WObject): o is Extract<WObject, { type: 'button' }> { return o.type === 'button' }
function isInput(o: WObject): o is Extract<WObject, { type: 'input' }> { return o.type === 'input' }
function isSelect(o: WObject): o is Extract<WObject, { type: 'select' }> { return o.type === 'select' }
function isTextarea(o: WObject): o is Extract<WObject, { type: 'textarea' }> { return o.type === 'textarea' }
function isRadio(o: WObject): o is Extract<WObject, { type: 'radio' }> { return o.type === 'radio' }
function isCheckbox(o: WObject): o is Extract<WObject, { type: 'checkbox' }> { return o.type === 'checkbox' }
function renderObject(o: WObject): { html: string; css: string } {
const baseCss = `#${o.id}{position:absolute;left:${o.x}px;top:${o.y}px;width:${o.width}px;height:${o.height}px;transform:rotate(${o.rotate}deg)}`
if (isImage(o)) {
const html = `<img id="${o.id}" src="${esc(o.props.src)}" alt="${esc(o.props.alt || '')}" />`
return { html, css: baseCss }
}
if (isText(o)) {
const html = `<p id="${o.id}">${esc(o.props.text)}</p>`
return { html, css: baseCss + `;font-size:${o.props.fontSize}px;color:${o.props.color}` }
}
if (isInput(o)) {
const cid = `${o.id}-control`
const req = o.props.required ? ' required' : ''
const ph = o.props.placeholder ? ` placeholder="${esc(o.props.placeholder)}"` : ''
const helpId = o.props.help ? `${cid}-help` : ''
const described = helpId ? ` aria-describedby="${helpId}"` : ''
const pat = o.props.pattern ? ` pattern="${esc(o.props.pattern)}"` : ''
const min = typeof o.props.minLength === 'number' ? ` minlength="${o.props.minLength}"` : ''
const max = typeof o.props.maxLength === 'number' ? ` maxlength="${o.props.maxLength}"` : ''
const html = [
`<div id="${o.id}">`,
`<label for="${cid}">${esc(o.props.label)}</label>`,
`<input id="${cid}" name="${esc(o.props.name)}"${ph}${req}${described}${pat}${min}${max}/>`,
o.props.help ? `<small id="${helpId}">${esc(o.props.help)}</small>` : '',
`</div>`,
].join('')
return { html, css: baseCss }
}
if (isSelect(o)) {
const cid = `${o.id}-control`
const opts = (o.props.options || [])
.map((opt) => `<option value="${esc(opt)}"${o.props.defaultValue === opt ? ' selected' : ''}>${esc(opt)}</option>`)
.join('')
const helpId = o.props.help ? `${cid}-help` : ''
const described = helpId ? ` aria-describedby="${helpId}"` : ''
const html = [
`<div id="${o.id}">`,
`<label for="${cid}">${esc(o.props.label)}</label>`,
`<select id="${cid}" name="${esc(o.props.name)}"${described}>${opts}</select>`,
o.props.help ? `<small id="${helpId}">${esc(o.props.help)}</small>` : '',
`</div>`,
].join('')
return { html, css: baseCss }
}
if (isTextarea(o)) {
const cid = `${o.id}-control`
const req = o.props.required ? ' required' : ''
const ph = o.props.placeholder ? ` placeholder="${esc(o.props.placeholder)}"` : ''
const rows = Number(o.props.rows ?? 3)
const helpId = o.props.help ? `${cid}-help` : ''
const described = helpId ? ` aria-describedby="${helpId}"` : ''
const min = typeof o.props.minLength === 'number' ? ` minlength="${o.props.minLength}"` : ''
const max = typeof o.props.maxLength === 'number' ? ` maxlength="${o.props.maxLength}"` : ''
const html = [
`<div id="${o.id}">`,
`<label for="${cid}">${esc(o.props.label)}</label>`,
`<textarea id="${cid}" name="${esc(o.props.name)}" rows="${rows}"${ph}${req}${described}${min}${max}></textarea>`,
o.props.help ? `<small id="${helpId}">${esc(o.props.help)}</small>` : '',
`</div>`,
].join('')
return { html, css: baseCss }
}
if (isRadio(o)) {
const req = o.props.required ? ' required' : ''
const items = (o.props.options || []).map((opt, idx) => {
const id = `${o.id}-${idx}`
const checked = o.props.defaultValue === opt ? ' checked' : ''
return `<div><input type="radio" id="${id}" name="${esc(o.props.name)}" value="${esc(opt)}"${checked}${req}/><label for="${id}">${esc(opt)}</label></div>`
}).join('')
const help = o.props.help ? `<small id="${o.id}-help">${esc(o.props.help)}</small>` : ''
const html = [`<fieldset id="${o.id}">`, `<legend>${esc(o.props.legend)}</legend>`, items, help, `</fieldset>`].join('')
return { html, css: baseCss }
}
if (isCheckbox(o)) {
const req = o.props.required ? ' required' : ''
const def = new Set(o.props.defaultValues || [])
const items = (o.props.options || []).map((opt, idx) => {
const id = `${o.id}-${idx}`
const checked = def.has(opt) ? ' checked' : ''
return `<div><input type="checkbox" id="${id}" name="${esc(o.props.name)}" value="${esc(opt)}"${checked}${req}/><label for="${id}">${esc(opt)}</label></div>`
}).join('')
const help = o.props.help ? `<small id="${o.id}-help">${esc(o.props.help)}</small>` : ''
const html = [`<fieldset id="${o.id}">`, `<legend>${esc(o.props.legend)}</legend>`, items, help, `</fieldset>`].join('')
return { html, css: baseCss }
}
// button minimal (fallback)
const label = isButton(o) ? o.props.label : 'Button'
const href = isButton(o) ? (o.props.href || '#') : '#'
const html = `<a id="${o.id}" href="${esc(href)}">${esc(label)}</a>`
return { html, css: baseCss }
}
export function exportFrame(frame: Frame): Exported {
const piecesHtml: string[] = []
const piecesCss: string[] = []
for (const layer of frame.layers) {
if (!layer.visible) continue
for (const o of layer.objects) {
const r = renderObject(o)
piecesHtml.push(r.html)
piecesCss.push(r.css)
}
}
const inner = `<div class="frame-scale"><div class="frame">${piecesHtml.join('')}</div></div>`
const css = [
`:root{--bg:${frame.background};--text:#f9fafb;--border:#94a3b8;--primary:#0ea5e9;--danger:#ef4444;--ring:#22c55e;--ring-offset:2px;--disabled-bg:#e5e7eb;--disabled-fg:#9ca3af}`,
`[data-theme="light"]{--bg:#ffffff;--text:#0f172a;--border:#cbd5e1;--primary:#0ea5e9;--danger:#ef4444;--ring:#22c55e;--ring-offset:2px;--disabled-bg:#e5e7eb;--disabled-fg:#6b7280}`,
`[data-theme="dark"]{--bg:#111111;--text:#f9fafb;--border:#94a3b8;--primary:#0ea5e9;--danger:#ef4444;--ring:#22c55e;--ring-offset:2px;--disabled-bg:#1f2937;--disabled-fg:#9ca3af}`,
`.frame{position:relative;width:${frame.width}px;height:${frame.height}px;background:var(--bg)}`,
`.frame-scale{transform-origin:0 0}`,
`a{color:var(--primary);text-decoration:none}`,
`input,select,textarea{color:var(--text);background:transparent;border:1px solid var(--border)}`,
`:focus-visible{outline:2px solid var(--ring);outline-offset:var(--ring-offset)}`,
`input:focus,select:focus,textarea:focus{outline:2px solid var(--primary);outline-offset:2px}`,
`input:invalid,select:invalid,textarea:invalid{border-color:var(--danger)}`,
`input:disabled,select:disabled,textarea:disabled{background:var(--disabled-bg);color:var(--disabled-fg)}`,
`input[readonly],select[readonly],textarea[readonly]{color:var(--disabled-fg)}`,
`fieldset{border:1px solid var(--border)}`,
`legend{color:var(--text)}`,
...piecesCss,
].join('\n')
const html = [
'<!doctype html>',
'<html lang="en">',
'<head>',
'<meta charset="utf-8" />',
'<meta name="viewport" content="width=device-width,initial-scale=1" />',
'<title>WYSIWYG Frame</title>',
'<link rel="stylesheet" href="styles.css" />',
'</head>',
'<body>',
inner,
'<script src="app.js"></script>',
'</body>',
'</html>',
].join('')
return { html, css, js: '' }
}