feat(scenes): rich scene descriptions and control viewer window

Add TipTap editing in the editor and an Electron window on the control panel to read the current scene description.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-15 09:49:40 +08:00
parent 35a6e979eb
commit cfa3959fb3
26 changed files with 1795 additions and 60 deletions
@@ -0,0 +1,94 @@
/** Detect empty TipTap / legacy plain description values. */
export function isSceneDescriptionEmpty(html: string | null | undefined): boolean {
if (html == null) return true;
const trimmed = html.trim();
if (trimmed === '') return true;
const text = trimmed
.replace(/<br\s*\/?>/gi, ' ')
.replace(/<\/(p|div|h[1-6]|li|blockquote)>/gi, ' ')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/gi, ' ')
.replace(/&#160;/g, ' ')
.replace(/\s+/g, ' ')
.trim();
return text.length === 0;
}
/** Persist empty editor as '' instead of empty paragraph markup. */
export function normalizeSceneDescriptionHtml(html: string): string {
return isSceneDescriptionEmpty(html) ? '' : html.trim();
}
const ALLOWED_TAGS = new Set([
'P',
'BR',
'STRONG',
'B',
'EM',
'I',
'U',
'S',
'H2',
'H3',
'UL',
'OL',
'LI',
'BLOCKQUOTE',
'A',
'CODE',
'SPAN',
]);
/** Sanitize TipTap HTML for safe preview rendering. */
export function sanitizeSceneDescriptionHtml(html: string): string {
if (typeof document === 'undefined') return html;
if (isSceneDescriptionEmpty(html)) return '';
if (!/<[a-z][\s\S]*>/i.test(html)) {
const esc = document.createElement('div');
esc.textContent = html;
return esc.innerHTML;
}
const template = document.createElement('template');
template.innerHTML = html.trim();
const container = document.createElement('div');
container.appendChild(template.content.cloneNode(true));
const walk = (node: Node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
const el = node as HTMLElement;
const tag = el.tagName;
if (!ALLOWED_TAGS.has(tag)) {
const parent = el.parentNode;
if (parent) {
while (el.firstChild) parent.insertBefore(el.firstChild, el);
parent.removeChild(el);
}
return;
}
for (const attr of [...el.attributes]) {
const name = attr.name.toLowerCase();
if (tag === 'A' && (name === 'href' || name === 'target' || name === 'rel')) {
if (name === 'href') {
const href = attr.value.trim();
if (!/^(https?:|mailto:)/i.test(href)) {
el.removeAttribute(attr.name);
}
}
continue;
}
el.removeAttribute(attr.name);
}
if (tag === 'A') {
el.setAttribute('rel', 'noopener noreferrer');
el.setAttribute('target', '_blank');
}
}
for (const child of [...node.childNodes]) walk(child);
};
// Walk children only — never treat the container as a removable tag
// (that would unwrap it and leave container.innerHTML empty).
for (const child of [...container.childNodes]) walk(child);
return container.innerHTML;
}