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:
@@ -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(/ /gi, ' ')
|
||||
.replace(/ /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;
|
||||
}
|
||||
Reference in New Issue
Block a user