cfa3959fb3
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>
95 lines
2.7 KiB
TypeScript
95 lines
2.7 KiB
TypeScript
/** 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;
|
|
}
|