fix(web): correct srcdoc injection and deck bridge for JS strings con… (#938)

* fix(web): correct srcdoc injection and deck bridge for JS strings containing HTML tags

- Use indexOf for </head> (real tag precedes JS string occurrences)
- Use lastIndexOf for </body> (real tag follows JS string occurrences)
- Scope deck bridge slide selector to direct children of deck containers
  to avoid counting cloned overview thumbnails
- Update .slide-number data attributes and .progress-bar width from
  deck bridge's updateDeckChrome so page counter and progress track
  with deck navigation

* fix(web): move deck chrome sync into report() for all slide modes

.slide-number and .progress-bar updates were only in updateDeckChrome()
which is called exclusively from setActive(). Scroll decks go through
scrollGo() → report() without ever calling setActive(), so their page
counter and progress bar stayed frozen.

Move the sync logic into report() which is the convergence point for
all navigation paths (class-toggle, scroll, keyboard-dispatch).

* fix(web): use DOMParser for srcdoc injection instead of string matching

Replace all regex/indexOf/lastIndexOf based </head> and </body> matching
with DOM-based injection via domMutate helper. DOMParser correctly
separates raw-text element content from structural markup, so </head>/
</body> inside JavaScript strings no longer hijack injection points.

Also: scope deck bridge slide selector to direct children of deck
containers, and move .slide-number/.progress-bar sync into report()
so all navigation paths update page chrome.

* fix(web): add robust string fallback for srcdoc injection in non-browser environments

When DOMParser is unavailable (Node tests), fall back to structural
boundary matching: lastIndexOf('</head>', before <body) and
lastIndexOf('</body>', before </html>) to skip literal occurrences
inside script/style blocks. Annotate catch blocks.

---------

Co-authored-by: yangjingting <yangjingting@yxqiche.com>
This commit is contained in:
Morzorz 2026-05-09 02:41:49 +08:00 committed by GitHub
parent 34b5b85614
commit 83ddf7609c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -105,13 +105,39 @@ function injectManualEditBridge(doc: string): string {
} }
function injectBeforeHeadEnd(doc: string, payload: string): string { function injectBeforeHeadEnd(doc: string, payload: string): string {
if (/<\/head>/i.test(doc)) return doc.replace(/<\/head>/i, `${payload}</head>`); if (typeof DOMParser !== 'undefined') {
try {
const parsed = new DOMParser().parseFromString(doc, 'text/html');
if (parsed.head) parsed.head.insertAdjacentHTML('beforeend', payload);
return serializeHtmlDocument(parsed);
} catch { /* DOMParser failed; fall through to string path */ }
}
// String fallback: find the real </head> (last one before <body>)
// to skip </head> literals inside <script>/<style> in <head>.
const lower = doc.toLowerCase();
const bodyStart = lower.indexOf('<body');
const limit = bodyStart >= 0 ? bodyStart : lower.length;
const idx = lower.lastIndexOf('</head>', limit - 1);
if (idx >= 0) return doc.slice(0, idx) + payload + doc.slice(idx);
if (/<head[^>]*>/i.test(doc)) return doc.replace(/<head[^>]*>/i, (m) => `${m}${payload}`); if (/<head[^>]*>/i.test(doc)) return doc.replace(/<head[^>]*>/i, (m) => `${m}${payload}`);
return payload + doc; return payload + doc;
} }
function injectBeforeBodyEnd(doc: string, payload: string): string { function injectBeforeBodyEnd(doc: string, payload: string): string {
if (/<\/body>/i.test(doc)) return doc.replace(/<\/body>/i, `${payload}</body>`); if (typeof DOMParser !== 'undefined') {
try {
const parsed = new DOMParser().parseFromString(doc, 'text/html');
if (parsed.body) parsed.body.insertAdjacentHTML('beforeend', payload);
return serializeHtmlDocument(parsed);
} catch { /* DOMParser failed; fall through to string path */ }
}
// String fallback: find the real </body> (last one before </html>)
// to skip </body> literals inside <script>/<style> in <body>.
const lower = doc.toLowerCase();
const htmlEnd = lower.lastIndexOf('</html>');
const limit = htmlEnd >= 0 ? htmlEnd : lower.length;
const idx = lower.lastIndexOf('</body>', limit - 1);
if (idx >= 0) return doc.slice(0, idx) + payload + doc.slice(idx);
return doc + payload; return doc + payload;
} }
@ -631,13 +657,7 @@ html[data-od-comment-mode] body * { cursor: crosshair !important; }
html[data-od-inspect-mode] body * { cursor: crosshair !important; } html[data-od-inspect-mode] body * { cursor: crosshair !important; }
html[data-od-comment-mode][data-od-comment-mode-kind="pod"] body * { cursor: cell !important; } html[data-od-comment-mode][data-od-comment-mode-kind="pod"] body * { cursor: cell !important; }
</style>`; </style>`;
const withStyle = /<\/head>/i.test(doc) return injectBeforeBodyEnd(injectBeforeHeadEnd(doc, style), script);
? doc.replace(/<\/head>/i, style + '</head>')
: /<head[^>]*>/i.test(doc)
? doc.replace(/<head[^>]*>/i, (m) => m + style)
: style + doc;
if (/<\/body>/i.test(withStyle)) return withStyle.replace(/<\/body>/i, script + '</body>');
return withStyle + script;
} }
// The deck bridge supports three deck conventions found across our skills // The deck bridge supports three deck conventions found across our skills
@ -670,16 +690,10 @@ function injectDeckBridge(doc: string, initialSlideIndex = 0): string {
const styleFix = `<style data-od-deck-fix> const styleFix = `<style data-od-deck-fix>
.stage, .deck-stage, .deck-shell { place-content: center !important; } .stage, .deck-stage, .deck-shell { place-content: center !important; }
</style>`; </style>`;
const docWithStyle = /<\/head>/i.test(doc)
? doc.replace(/<\/head>/i, styleFix + "</head>")
: /<head[^>]*>/i.test(doc)
? doc.replace(/<head[^>]*>/i, (m) => m + styleFix)
: styleFix + doc;
doc = docWithStyle;
const script = `<script data-od-deck-bridge>(function(){ const script = `<script data-od-deck-bridge>(function(){
var initialSlideIndex = ${safeInitialSlideIndex}; var initialSlideIndex = ${safeInitialSlideIndex};
var didRestoreInitialSlide = initialSlideIndex <= 0; var didRestoreInitialSlide = initialSlideIndex <= 0;
function slides(){ return document.querySelectorAll('.slide'); } function slides(){ return document.querySelectorAll('.deck > .slide, .deck-stage > .slide, .deck-shell > .slide, body > .slide'); }
function scroller(){ function scroller(){
if (document.body && document.body.scrollWidth > document.body.clientWidth + 1) return document.body; if (document.body && document.body.scrollWidth > document.body.clientWidth + 1) return document.body;
return document.scrollingElement || document.documentElement; return document.scrollingElement || document.documentElement;
@ -834,11 +848,19 @@ function injectDeckBridge(doc: string, initialSlideIndex = 0): string {
function report(){ function report(){
try { try {
var list = slides(); var list = slides();
var i = activeIndex(list);
var count = list.length;
window.parent.postMessage({ window.parent.postMessage({
type: 'od:slide-state', type: 'od:slide-state',
active: activeIndex(list), active: i,
count: list.length, count: count,
}, '*'); }, '*');
document.querySelectorAll('.slide-number').forEach(function(el){
el.setAttribute('data-current',i+1); el.setAttribute('data-total',count);
});
document.querySelectorAll('.progress-bar>span').forEach(function(el){
el.style.width=(count?((i+1)/count*100)+'%':'0');
});
} catch (e) {} } catch (e) {}
} }
function restoreInitialSlide(){ function restoreInitialSlide(){
@ -925,7 +947,5 @@ function injectDeckBridge(doc: string, initialSlideIndex = 0): string {
} }
observeSlides(); observeSlides();
})();</script>`; })();</script>`;
if (/<\/body>/i.test(doc)) return injectBeforeBodyEnd(injectBeforeHeadEnd(doc, styleFix), script);
return doc.replace(/<\/body>/i, `${script}</body>`);
return doc + script;
} }