refactor(studio): update layout, config, and remove agent activity tracking (#64)

## Summary
- **NLELayout**: Add toolbar slot, composition breadcrumb navigation, improved responsive layout
- **Vite config**: Add full project API (preview, thumbnail, render, file CRUD) for standalone dev mode
- Remove AgentActivityTrack component (replaced by timeline clips)
- Add HTML editor utilities for composition source editing
- Guard setInterval cleanup to dev-only to prevent `vite build` from hanging in CI

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Miguel Ángel
2026-03-28 20:45:00 +01:00
committed by GitHub
parent 0769678f46
commit 8c1ae77697
12 changed files with 674 additions and 187 deletions
+110 -13
View File
@@ -1,7 +1,10 @@
import { useState, useCallback, useRef, useEffect } from "react";
import { useState, useCallback, useRef, useEffect, type ReactNode } from "react";
import { NLELayout } from "./components/nle/NLELayout";
import { SourceEditor } from "./components/editor/SourceEditor";
import { FileTree } from "./components/editor/FileTree";
import { CompositionThumbnail } from "./player/components/CompositionThumbnail";
import { VideoThumbnail } from "./player/components/VideoThumbnail";
import type { TimelineElement } from "./player/store/playerStore";
import {
XIcon,
CodeIcon,
@@ -80,6 +83,24 @@ function LintModal({ findings, onClose }: { findings: LintFinding[]; onClose: ()
const errors = findings.filter((f) => f.severity === "error");
const warnings = findings.filter((f) => f.severity === "warning");
const hasIssues = findings.length > 0;
const [copied, setCopied] = useState(false);
const handleCopyToAgent = async () => {
const lines = findings.map((f) => {
let line = `[${f.severity}] ${f.message}`;
if (f.file) line += `\n File: ${f.file}`;
if (f.fixHint) line += `\n Fix: ${f.fixHint}`;
return line;
});
const text = `Fix these HyperFrames lint issues:\n\n${lines.join("\n\n")}`;
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// ignore
}
};
return (
<div
@@ -98,8 +119,8 @@ function LintModal({ findings, onClose }: { findings: LintFinding[]; onClose: ()
<WarningIcon size={18} className="text-red-400" weight="fill" />
</div>
) : (
<div className="w-8 h-8 rounded-full bg-green-500/10 flex items-center justify-center">
<CheckCircleIcon size={18} className="text-green-400" weight="fill" />
<div className="w-8 h-8 rounded-full bg-[#3CE6AC]/10 flex items-center justify-center">
<CheckCircleIcon size={18} className="text-[#3CE6AC]" weight="fill" />
</div>
)}
<div>
@@ -119,7 +140,19 @@ function LintModal({ findings, onClose }: { findings: LintFinding[]; onClose: ()
</button>
</div>
{/* Findings */}
{/* Copy to agent + findings */}
{hasIssues && (
<div className="flex items-center justify-end px-5 py-2 border-b border-neutral-800/50">
<button
onClick={handleCopyToAgent}
className={`px-3 py-1 text-xs font-medium rounded-lg transition-colors ${
copied ? "bg-green-600 text-white" : "bg-blue-600 hover:bg-blue-500 text-white"
}`}
>
{copied ? "Copied!" : "Copy to Agent"}
</button>
</div>
)}
<div className="flex-1 overflow-y-auto px-5 py-3">
{!hasIssues && (
<div className="py-8 text-center text-neutral-500 text-sm">
@@ -139,8 +172,8 @@ function LintModal({ findings, onClose }: { findings: LintFinding[]; onClose: ()
{f.file && <p className="text-xs text-neutral-600 font-mono mt-0.5">{f.file}</p>}
{f.fixHint && (
<div className="flex items-start gap-1 mt-1.5">
<CaretRightIcon size={10} className="text-blue-400 flex-shrink-0 mt-0.5" />
<p className="text-xs text-blue-400">{f.fixHint}</p>
<CaretRightIcon size={10} className="text-[#3CE6AC] flex-shrink-0 mt-0.5" />
<p className="text-xs text-[#3CE6AC]">{f.fixHint}</p>
</div>
)}
</div>
@@ -156,8 +189,8 @@ function LintModal({ findings, onClose }: { findings: LintFinding[]; onClose: ()
{f.file && <p className="text-xs text-neutral-600 font-mono mt-0.5">{f.file}</p>}
{f.fixHint && (
<div className="flex items-start gap-1 mt-1.5">
<CaretRightIcon size={10} className="text-blue-400 flex-shrink-0 mt-0.5" />
<p className="text-xs text-blue-400">{f.fixHint}</p>
<CaretRightIcon size={10} className="text-[#3CE6AC] flex-shrink-0 mt-0.5" />
<p className="text-xs text-[#3CE6AC]">{f.fixHint}</p>
</div>
)}
</div>
@@ -202,6 +235,67 @@ export function StudioApp() {
const [editingFile, setEditingFile] = useState<EditingFile | null>(null);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [fileTree, setFileTree] = useState<string[]>([]);
const [compIdToSrc, setCompIdToSrc] = useState<Map<string, string>>(new Map());
const renderClipContent = useCallback(
(el: TimelineElement, style: { clip: string; label: string }): ReactNode => {
const pid = projectIdRef.current;
if (!pid) return null;
// Resolve composition source path using the compIdToSrc map
let compSrc = el.compositionSrc;
if (compSrc && compIdToSrc.size > 0) {
const resolved =
compIdToSrc.get(el.id) ||
compIdToSrc.get(compSrc.replace(/^compositions\//, "").replace(/\.html$/, ""));
if (resolved) compSrc = resolved;
}
if (compSrc) {
const previewUrl = `/api/projects/${pid}/preview/comp/${compSrc}`;
return (
<CompositionThumbnail
previewUrl={previewUrl}
label={el.id || el.tag}
labelColor={style.label}
seekTime={el.start}
duration={el.duration}
/>
);
}
if ((el.tag === "video" || el.tag === "img") && el.src) {
const mediaSrc = el.src.startsWith("http")
? el.src
: `/api/projects/${pid}/preview/${el.src}`;
return (
<VideoThumbnail
videoSrc={mediaSrc}
label={el.id || el.tag}
labelColor={style.label}
duration={el.duration}
/>
);
}
// HTML scene divs — render from index.html at the scene's time
if (el.tag === "div" && el.duration > 0) {
const previewUrl = `/api/projects/${pid}/preview`;
return (
<CompositionThumbnail
previewUrl={previewUrl}
label={el.id || el.tag}
labelColor={style.label}
seekTime={el.start}
duration={el.duration}
/>
);
}
return null;
},
[compIdToSrc],
);
const [lintModal, setLintModal] = useState<LintFinding[] | null>(null);
const [linting, setLinting] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
@@ -212,6 +306,7 @@ export function StudioApp() {
const [_renderError, setRenderError] = useState<string | null>(null);
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const projectIdRef = useRef(projectId);
const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
// Listen for external file changes (user editing HTML outside the editor).
// In dev: use Vite HMR. In embedded/production: use SSE from /api/events.
@@ -462,9 +557,11 @@ export function StudioApp() {
<NLELayout
projectId={projectId}
refreshKey={refreshKey}
activeCompositionPath={
editingFile?.path?.startsWith("compositions/") ? editingFile.path : null
}
renderClipContent={renderClipContent}
onCompIdToSrcChange={setCompIdToSrc}
onIframeRef={(iframe) => {
previewIframeRef.current = iframe;
}}
/>
</div>
@@ -488,7 +585,7 @@ export function StudioApp() {
<button
onClick={handleRender}
disabled={renderState === "rendering"}
className="h-8 px-3 rounded-lg bg-blue-600 border border-blue-500 text-xs font-semibold text-white hover:bg-blue-500 transition-colors disabled:opacity-60 tabular-nums"
className="h-8 px-3 rounded-lg text-xs font-semibold text-[#09090B] bg-gradient-to-br from-[#3CE6AC] to-[#2BBFA0] hover:brightness-110 active:scale-[0.97] transition-colors disabled:opacity-60 tabular-nums"
>
{renderState === "rendering"
? `${Math.round(renderProgress)}%`
@@ -517,7 +614,7 @@ export function StudioApp() {
<button
onClick={handleRender}
disabled={renderState === "rendering"}
className="px-2 py-1 rounded text-[11px] font-semibold text-blue-400 hover:text-blue-300 transition-colors disabled:opacity-60 tabular-nums"
className="px-2 py-1 rounded text-[11px] font-semibold text-[#3CE6AC] hover:text-[#5EEFC0] transition-colors disabled:opacity-60 tabular-nums"
>
{renderState === "rendering" ? `${Math.round(renderProgress)}%` : "Export MP4"}
</button>
@@ -26,7 +26,9 @@ function getLanguageExtension(language: string) {
case "js":
case "typescript":
case "ts":
return javascript({ typescript: language === "typescript" || language === "ts" });
return javascript({
typescript: language === "typescript" || language === "ts",
});
default:
return html();
}
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useRef, memo, type ReactNode } from "react";
import { useState, useCallback, useRef, memo, type ReactNode } from "react";
import { useMountEffect } from "../../hooks/useMountEffect";
import { useTimelinePlayer, PlayerControls, Timeline, usePlayerStore } from "../../player";
import type { TimelineElement } from "../../player";
import { NLEPreview } from "./NLEPreview";
@@ -9,7 +10,9 @@ interface NLELayoutProps {
portrait?: boolean;
/** Slot for overlays rendered on top of the preview (cursors, highlights, etc.) */
previewOverlay?: ReactNode;
/** Slot rendered below the timeline tracks (e.g., agent activity swim lanes) */
/** Slot rendered above the timeline tracks (toolbar with split, delete, zoom) */
timelineToolbar?: ReactNode;
/** Slot rendered below the timeline tracks */
timelineFooter?: ReactNode;
/** Increment to force the preview to reload (e.g., after file writes) */
refreshKey?: number;
@@ -17,6 +20,15 @@ interface NLELayoutProps {
activeCompositionPath?: string | null;
/** Callback to expose the iframe ref (for element picker, etc.) */
onIframeRef?: (iframe: HTMLIFrameElement | null) => void;
/** Callback when the viewed composition changes (drill-down/back) */
onCompositionChange?: (compositionPath: string | null) => void;
/** Custom clip content renderer for timeline (thumbnails, waveforms, etc.) */
renderClipContent?: (
element: TimelineElement,
style: { clip: string; label: string },
) => ReactNode;
/** Exposes the compIdToSrc map for parent components (e.g., useRenderClipContent) */
onCompIdToSrcChange?: (map: Map<string, string>) => void;
}
const MIN_TIMELINE_H = 100;
@@ -27,10 +39,14 @@ export const NLELayout = memo(function NLELayout({
projectId,
portrait,
previewOverlay,
timelineToolbar,
timelineFooter,
refreshKey,
activeCompositionPath,
onIframeRef,
onCompositionChange,
renderClipContent,
onCompIdToSrcChange,
}: NLELayoutProps) {
const {
iframeRef,
@@ -38,8 +54,17 @@ export const NLELayout = memo(function NLELayout({
seek,
onIframeLoad: baseOnIframeLoad,
saveSeekPosition,
resetPlayer,
} = useTimelinePlayer();
// Reset timeline state when the project changes to prevent stale data from a
// previous project leaking into the new one.
const prevProjectIdRef = useRef<string | null>(null);
if (prevProjectIdRef.current !== projectId) {
prevProjectIdRef.current = projectId;
resetPlayer();
}
// Preserve seek position when refreshKey changes (iframe will remount via key prop).
const prevRefreshKeyRef = useRef(refreshKey);
if (refreshKey !== prevRefreshKeyRef.current) {
@@ -55,7 +80,7 @@ export const NLELayout = memo(function NLELayout({
// Composition ID → actual file path mapping, built from the raw index.html
const [compIdToSrc, setCompIdToSrc] = useState<Map<string, string>>(new Map());
useEffect(() => {
useMountEffect(() => {
fetch(`/api/projects/${projectId}/files/index.html`)
.then((r) => r.json())
.then((data: { content?: string }) => {
@@ -70,15 +95,28 @@ export const NLELayout = memo(function NLELayout({
if (id && src) map.set(id, src);
}
setCompIdToSrc(map);
onCompIdToSrcChange?.(map);
})
.catch(() => {});
}, [projectId]);
});
// Composition drill-down stack
const [compositionStack, setCompositionStack] = useState<CompositionLevel[]>([
{ id: "master", label: "Master", previewUrl: `/api/projects/${projectId}/preview` },
]);
// Wrap setCompositionStack to auto-notify parent on composition change
const onCompositionChangeRef = useRef(onCompositionChange);
onCompositionChangeRef.current = onCompositionChange;
const updateCompositionStack: typeof setCompositionStack = useCallback((action) => {
setCompositionStack((prev) => {
const next = typeof action === "function" ? action(prev) : action;
const id = next[next.length - 1]?.id;
queueMicrotask(() => onCompositionChangeRef.current?.(id === "master" ? null : id));
return next;
});
}, []);
// Resizable timeline height
const [timelineH, setTimelineH] = useState(DEFAULT_TIMELINE_H);
const isDragging = useRef(false);
@@ -126,7 +164,7 @@ export const NLELayout = memo(function NLELayout({
usePlayerStore.getState().setElements([]);
// Toggle: if already viewing this composition, go back to parent (like Premiere)
setCompositionStack((prev) => {
updateCompositionStack((prev) => {
const currentId = prev[prev.length - 1].id;
if (currentId === resolvedPath && prev.length > 1) {
return prev.slice(0, -1);
@@ -141,14 +179,15 @@ export const NLELayout = memo(function NLELayout({
return [...prev, { id: resolvedPath, label, previewUrl }];
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- iframeRef_ is a stable ref; .current mutates and should not be a dep
// eslint-disable-next-line react-hooks/exhaustive-deps
[projectId, compIdToSrc],
);
// Navigate back to a specific breadcrumb level
const handleNavigateComposition = useCallback((index: number) => {
usePlayerStore.getState().setElements([]);
setCompositionStack((prev) => prev.slice(0, index + 1));
updateCompositionStack((prev) => prev.slice(0, index + 1));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Navigate to a composition when activeCompositionPath changes
@@ -157,11 +196,11 @@ export const NLELayout = memo(function NLELayout({
prevActiveCompRef.current = activeCompositionPath;
queueMicrotask(() => usePlayerStore.getState().setElements([]));
if (activeCompositionPath === "index.html") {
setCompositionStack((prev) => (prev.length > 1 ? [prev[0]] : prev));
updateCompositionStack((prev) => (prev.length > 1 ? [prev[0]] : prev));
} else if (activeCompositionPath.startsWith("compositions/")) {
const label = activeCompositionPath.replace(/^compositions\//, "").replace(/\.html$/, "");
const previewUrl = `/api/projects/${projectId}/preview/comp/${activeCompositionPath}`;
setCompositionStack((prev) => {
updateCompositionStack((prev) => {
if (prev[prev.length - 1].id === activeCompositionPath) return prev;
return [
{ id: "master", label: "Master", previewUrl: `/api/projects/${projectId}/preview` },
@@ -201,9 +240,10 @@ export const NLELayout = memo(function NLELayout({
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape" && compositionStack.length > 1) {
setCompositionStack((prev) => prev.slice(0, -1));
updateCompositionStack((prev) => prev.slice(0, -1));
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[compositionStack.length],
);
@@ -255,11 +295,16 @@ export const NLELayout = memo(function NLELayout({
onDoubleClick={(e) => {
if ((e.target as HTMLElement).closest("[data-clip]")) return;
if (compositionStack.length > 1) {
setCompositionStack((prev) => prev.slice(0, -1));
updateCompositionStack((prev) => prev.slice(0, -1));
}
}}
>
<Timeline onSeek={seek} onDrillDown={handleDrillDown} />
{timelineToolbar}
<Timeline
onSeek={seek}
onDrillDown={handleDrillDown}
renderClipContent={renderClipContent}
/>
{timelineFooter}
</div>
</div>
+1 -1
View File
@@ -16,7 +16,7 @@ export interface UseCodeEditorReturn {
setActiveFile: (path: string) => void;
updateContent: (content: string) => void;
markSaved: (path: string) => void;
/** External update from agent — updates saved content, shows reload indicator */
/** External update — updates saved content, shows reload indicator */
externalUpdate: (path: string, content: string) => void;
}
@@ -156,7 +156,11 @@ export function useElementPicker(
(
elementId: string,
selector: string,
op: { type: "inline-style" | "attribute" | "text-content"; property: string; value: string },
op: {
type: "inline-style" | "attribute" | "text-content";
property: string;
value: string;
},
) => {
const opts = optionsRef.current;
if (!opts?.workspaceFiles || !opts.onSyncFiles || !elementId) return;
+10 -1
View File
@@ -10,12 +10,14 @@ export {
PlayerControls,
Timeline,
PreviewPanel,
VideoThumbnail,
CompositionThumbnail,
useTimelinePlayer,
usePlayerStore,
liveTime,
formatTime,
} from "./player";
export type { TimelineElement, ZoomMode } from "./player";
export type { TimelineElement } from "./player";
// Editor
export { SourceEditor } from "./components/editor/SourceEditor";
@@ -27,4 +29,11 @@ export { StudioApp } from "./App";
// Hooks
export { useCodeEditor } from "./hooks/useCodeEditor";
export type { OpenFile, UseCodeEditorReturn } from "./hooks/useCodeEditor";
export { useElementPicker } from "./hooks/useElementPicker";
export type { PickedElement } from "./hooks/useElementPicker";
// Utilities
export { resolveSourceFile, applyPatch } from "./utils/sourcePatcher";
export type { PatchOperation } from "./utils/sourcePatcher";
export { parseStyleString, mergeStyleIntoTag, findElementBlock } from "./utils/htmlEditor";
@@ -1,93 +0,0 @@
import { memo } from "react";
const TRACK_H = 20;
const GUTTER = 32;
export interface AgentActivity {
agentId: string;
name: string;
color: string;
/** Active work periods mapped to VIDEO time (not wall clock) */
periods: Array<{ start: number; end: number }>;
/** Element creation events at specific video times */
events: Array<{ time: number; type: "create" | "modify" }>;
}
interface AgentActivityTrackProps {
agents: AgentActivity[];
duration: number;
}
export const AgentActivityTrack = memo(function AgentActivityTrack({
agents,
duration,
}: AgentActivityTrackProps) {
if (agents.length === 0 || duration <= 0) return null;
return (
<div className="border-t border-neutral-800/30">
{/* Section header */}
<div className="flex items-center gap-1.5 px-2 py-1 text-[9px] text-neutral-600 font-medium uppercase tracking-wider">
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="4" />
</svg>
Agent Activity
</div>
{agents.map((agent) => (
<div key={agent.agentId} className="relative flex" style={{ height: TRACK_H }}>
{/* Gutter: agent name */}
<div
className="flex-shrink-0 flex items-center justify-center"
style={{ width: GUTTER }}
title={agent.name}
>
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: agent.color }} />
</div>
{/* Lane */}
<div className="flex-1 relative" style={{ backgroundColor: `${agent.color}06` }}>
{/* Active work periods */}
{agent.periods.map((period, i) => {
const leftPct = (period.start / duration) * 100;
const widthPct = ((period.end - period.start) / duration) * 100;
return (
<div
key={`period-${i}`}
className="absolute top-1 bottom-1 rounded-sm"
style={{
left: `${leftPct}%`,
width: `${Math.max(widthPct, 0.5)}%`,
backgroundColor: `${agent.color}30`,
border: `1px solid ${agent.color}20`,
}}
/>
);
})}
{/* Events: diamonds for create, circles for modify */}
{agent.events.map((event, i) => {
const leftPct = (event.time / duration) * 100;
return (
<div
key={`event-${i}`}
className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2"
style={{ left: `${leftPct}%` }}
>
{event.type === "create" ? (
<div className="w-2 h-2 rotate-45" style={{ backgroundColor: agent.color }} />
) : (
<div
className="w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: agent.color }}
/>
)}
</div>
);
})}
</div>
</div>
))}
</div>
);
});
@@ -54,7 +54,7 @@ export const TimelineClip = memo(function TimelineClip({
? `repeating-linear-gradient(135deg, transparent, transparent 3px, rgba(255,255,255,0.08) 3px, rgba(255,255,255,0.08) 6px)`
: undefined,
border: isSelected
? "2px solid rgba(255,255,255,0.9)"
? `2px solid rgba(255,255,255,0.9)`
: `1px solid rgba(255,255,255,${isHovered ? 0.3 : 0.15})`,
boxShadow: isSelected
? `0 0 0 1px ${style.clip}, 0 2px 8px rgba(0,0,0,0.4)`
@@ -63,7 +63,6 @@ export const TimelineClip = memo(function TimelineClip({
: "none",
transition: "border-color 120ms, box-shadow 120ms",
zIndex: isSelected ? 10 : isHovered ? 5 : 1,
cursor: "pointer",
}}
title={
isComposition
+164
View File
@@ -0,0 +1,164 @@
/**
* HTML Editor — Utility functions for parsing and manipulating HyperFrame HTML source.
*/
/**
* Parse a CSS inline style string into a key-value map.
* e.g. "opacity: 0.5; transform: matrix(1,0,0,1,0,0)" →
* { opacity: "0.5", transform: "matrix(1,0,0,1,0,0)" }
*/
export function parseStyleString(style: string): Record<string, string> {
const result: Record<string, string> = {};
for (const decl of style.split(";")) {
const colonIdx = decl.indexOf(":");
if (colonIdx < 0) continue;
const key = decl.slice(0, colonIdx).trim();
const value = decl.slice(colonIdx + 1).trim();
if (key && value) result[key] = value;
}
return result;
}
/**
* Merge `newStyles` into an opening tag string's `style` attribute.
* - New values win over existing ones.
* - If no `style` attribute is present, one is added before the closing `>`.
*/
export function mergeStyleIntoTag(tag: string, newStyles: string): string {
if (!newStyles.trim()) return tag;
const incoming = parseStyleString(newStyles);
// Match style="..." or style='...' — handle multi-line attrs via dotall-like trick
const styleAttrRe = /style=(["'])([\s\S]*?)\1/;
const match = tag.match(styleAttrRe);
if (match) {
const quote = match[1];
const existing = parseStyleString(match[2]);
const merged = { ...existing, ...incoming };
const serialized = Object.entries(merged)
.map(([k, v]) => `${k}: ${v}`)
.join("; ");
return tag.replace(styleAttrRe, `style=${quote}${serialized}${quote}`);
}
// No style attribute — insert one before the closing `>`
const serialized = Object.entries(incoming)
.map(([k, v]) => `${k}: ${v}`)
.join("; ");
// Handle self-closing tags (`/>`) and regular closing (`>`)
return tag.replace(/(\/?>)$/, ` style="${serialized}"$1`);
}
/**
* Find the full element block (opening tag through closing tag) in the source.
* Uses quote-aware scanning to handle attributes containing >.
* Uses depth counting to handle nested same-name tags.
*/
export function findElementBlock(
html: string,
elementId: string,
): {
start: number;
end: number;
openTag: string;
tagName: string;
indent: string;
innerContent: string;
isSelfClosing: boolean;
} | null {
let idIdx = html.indexOf(`id="${elementId}"`);
if (idIdx < 0) idIdx = html.indexOf(`id='${elementId}'`);
if (idIdx < 0) return null;
// Walk backward to find < and capture indent
let tagStart = idIdx;
while (tagStart > 0 && html[tagStart] !== "<") tagStart--;
let indentStart = tagStart;
while (indentStart > 0 && html[indentStart - 1] !== "\n") indentStart--;
const indent = html.slice(indentStart, tagStart);
// Walk forward from id to find the closing > of the opening tag
let tagEnd = idIdx;
let inQuote: string | null = null;
while (tagEnd < html.length) {
const ch = html[tagEnd];
if (inQuote) {
if (ch === inQuote) inQuote = null;
} else {
if (ch === '"' || ch === "'") inQuote = ch;
if (ch === ">") {
tagEnd++;
break;
}
}
tagEnd++;
}
const openTag = html.slice(tagStart, tagEnd);
const tagNameMatch = openTag.match(/^<([a-z][a-z0-9]*)/i);
if (!tagNameMatch) return null;
const tagName = tagNameMatch[1];
const isSelfClosing =
openTag.trimEnd().endsWith("/>") ||
["img", "br", "hr", "input", "meta", "link", "source"].includes(tagName.toLowerCase());
if (isSelfClosing) {
return {
start: tagStart,
end: tagStart + openTag.length,
openTag,
tagName,
indent: /^[\t ]*$/.test(indent) ? indent : "",
innerContent: "",
isSelfClosing: true,
};
}
// Find matching closing tag using depth counting
const closeTag = `</${tagName.toLowerCase()}>`;
const openPattern = `<${tagName.toLowerCase()}`;
let depth = 0;
let pos = tagStart;
const lower = html.toLowerCase();
while (pos < html.length) {
if (lower.startsWith("<!--", pos)) {
const commentEnd = lower.indexOf("-->", pos + 4);
pos = commentEnd < 0 ? html.length : commentEnd + 3;
continue;
}
if (lower.startsWith(openPattern, pos) && /[\s>/]/.test(html[pos + openPattern.length] || "")) {
depth++;
pos += openPattern.length;
continue;
}
if (lower.startsWith(closeTag, pos)) {
depth--;
if (depth === 0) {
const end = pos + closeTag.length;
const innerContent = html.slice(tagStart + openTag.length, pos);
return {
start: tagStart,
end,
openTag,
tagName,
indent: /^[\t ]*$/.test(indent) ? indent : "",
innerContent,
isSelfClosing: false,
};
}
pos += closeTag.length;
continue;
}
pos++;
}
return null;
}