From 274b7d0f18a6f96428d425814f83c67b019bde7b Mon Sep 17 00:00:00 2001 From: func25 Date: Mon, 18 May 2026 20:15:47 +0700 Subject: [PATCH 1/2] feat(studio): add multi-tab right inspector with CSS panel and state guardrails --- packages/studio/src/App.tsx | 26 +- .../studio/src/components/StudioHeader.tsx | 4 +- .../src/components/StudioRightPanel.test.tsx | 147 +++++++ .../src/components/StudioRightPanel.tsx | 189 +++++---- .../components/editor/LayerCssRulesPanel.tsx | 360 ++++++++++++++++++ .../studio/src/contexts/DomEditContext.tsx | 3 + .../src/contexts/PanelLayoutContext.tsx | 21 +- .../studio/src/hooks/useDomEditCommits.ts | 66 +++- .../studio/src/hooks/useDomEditSession.ts | 15 +- .../studio/src/hooks/useDomSelection.test.ts | 63 +++ packages/studio/src/hooks/useDomSelection.ts | 20 +- .../studio/src/hooks/usePanelLayout.test.ts | 83 ++++ packages/studio/src/hooks/usePanelLayout.ts | 71 +++- .../studio/src/hooks/useServerConnection.ts | 4 +- .../studio/src/hooks/useStudioUrlState.ts | 12 +- .../studio/src/utils/authoredCssRules.test.ts | 178 +++++++++ packages/studio/src/utils/authoredCssRules.ts | 206 ++++++++++ packages/studio/src/utils/studioHelpers.ts | 9 +- .../studio/src/utils/studioUrlState.test.ts | 10 +- packages/studio/src/utils/studioUrlState.ts | 22 +- 20 files changed, 1383 insertions(+), 126 deletions(-) create mode 100644 packages/studio/src/components/StudioRightPanel.test.tsx create mode 100644 packages/studio/src/components/editor/LayerCssRulesPanel.tsx create mode 100644 packages/studio/src/hooks/useDomSelection.test.ts create mode 100644 packages/studio/src/hooks/usePanelLayout.test.ts create mode 100644 packages/studio/src/utils/authoredCssRules.test.ts create mode 100644 packages/studio/src/utils/authoredCssRules.ts diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 7a0360b60..aede6c21a 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -114,6 +114,7 @@ export function StudioApp() { const panelLayout = usePanelLayout({ rightCollapsed: initialUrlStateRef.current.rightCollapsed, rightPanelTab: initialUrlStateRef.current.rightPanelTab, + rightPanelTabs: initialUrlStateRef.current.rightPanelTabs, }); const editHistory = usePersistentEditHistory({ projectId }); const domEditSaveTimestampRef = useRef(0); @@ -194,7 +195,7 @@ export function StudioApp() { compositionPath: result!.compositionPath, }); panelLayout.setRightCollapsed(false); - panelLayout.setRightPanelTab("block-params"); + panelLayout.focusRightPanelTab("block-params"); } })(); }, @@ -294,7 +295,7 @@ export function StudioApp() { currentTime, setSelectedTimelineElementId, setRightCollapsed: panelLayout.setRightCollapsed, - setRightPanelTab: panelLayout.setRightPanelTab, + focusRightPanelTab: panelLayout.focusRightPanelTab, showToast, refreshPreviewDocumentVersion, queueDomEditSave: manifestPersistence.queueDomEditSave, @@ -308,7 +309,7 @@ export function StudioApp() { projectIdRef: fileManager.projectIdRef, previewIframe, refreshKey, - rightPanelTab: panelLayout.rightPanelTab, + rightPanelTabs: panelLayout.rightPanelTabs, applyStudioManualEditsToPreviewRef: manifestPersistence.applyStudioManualEditsToPreviewRef, syncPreviewHistoryHotkey: appHotkeys.syncPreviewHistoryHotkey, reloadPreview, @@ -391,14 +392,17 @@ export function StudioApp() { ? readStudioMotionFromElement(domEditSession.domEditSelection.element) : null; const layersPanelActive = - STUDIO_INSPECTOR_PANELS_ENABLED && panelLayout.rightPanelTab === "layers"; + STUDIO_INSPECTOR_PANELS_ENABLED && panelLayout.rightPanelTabs.includes("layers"); const designPanelActive = - STUDIO_INSPECTOR_PANELS_ENABLED && panelLayout.rightPanelTab === "design"; + STUDIO_INSPECTOR_PANELS_ENABLED && panelLayout.rightPanelTabs.includes("design"); const motionPanelActive = STUDIO_INSPECTOR_PANELS_ENABLED && STUDIO_MOTION_PANEL_ENABLED && - panelLayout.rightPanelTab === "motion"; - const inspectorPanelActive = layersPanelActive || designPanelActive || motionPanelActive; + panelLayout.rightPanelTabs.includes("motion"); + const cssPanelActive = + STUDIO_INSPECTOR_PANELS_ENABLED && panelLayout.rightPanelTabs.includes("css"); + const inspectorPanelActive = + layersPanelActive || designPanelActive || motionPanelActive || cssPanelActive; const shouldShowSelectedDomBounds = inspectorPanelActive && !panelLayout.rightCollapsed && !isPlaying; const inspectorButtonActive = @@ -413,7 +417,8 @@ export function StudioApp() { compositionLoading, refreshKey, previewIframeRef, - rightPanelTab: panelLayout.rightPanelTab, + rightPanelTab: panelLayout.rightPanelFocusTab, + rightPanelTabs: panelLayout.rightPanelTabs, rightCollapsed: panelLayout.rightCollapsed, timelineVisible, activeCompPathHydrated, @@ -526,16 +531,13 @@ export function StudioApp() { setCompositionLoading={setCompositionLoading} shouldShowSelectedDomBounds={shouldShowSelectedDomBounds} /> - {!panelLayout.rightCollapsed && ( { setActiveBlockParams(null); - panelLayout.setRightPanelTab("design"); + panelLayout.focusRightPanelTab("design"); }} /> )} diff --git a/packages/studio/src/components/StudioHeader.tsx b/packages/studio/src/components/StudioHeader.tsx index ee4c5ea66..8acba182b 100644 --- a/packages/studio/src/components/StudioHeader.tsx +++ b/packages/studio/src/components/StudioHeader.tsx @@ -148,7 +148,7 @@ export function StudioHeader({ inspectorPanelActive, }: StudioHeaderProps) { const { projectId, editHistory, handleUndo, handleRedo } = useStudioContext(); - const { rightCollapsed, setRightCollapsed, setRightPanelTab } = usePanelLayoutContext(); + const { rightCollapsed, setRightCollapsed, ensureDesignVisible } = usePanelLayoutContext(); const { clearDomSelection } = useDomEditContext(); return ( @@ -217,7 +217,7 @@ export function StudioHeader({ onClick={() => { if (!STUDIO_INSPECTOR_PANELS_ENABLED) return; if (rightCollapsed || !inspectorPanelActive) { - setRightPanelTab("design"); + ensureDesignVisible(); setRightCollapsed(false); return; } diff --git a/packages/studio/src/components/StudioRightPanel.test.tsx b/packages/studio/src/components/StudioRightPanel.test.tsx new file mode 100644 index 000000000..51177db69 --- /dev/null +++ b/packages/studio/src/components/StudioRightPanel.test.tsx @@ -0,0 +1,147 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { StudioRightPanel } from "./StudioRightPanel"; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const mockState = vi.hoisted(() => ({ + inspectorEnabled: true, + motionEnabled: false, + rightPanelTabs: ["design", "renders"] as Array< + "design" | "renders" | "layers" | "css" | "motion" + >, +})); + +vi.mock("./editor/manualEditingAvailability", () => ({ + get STUDIO_INSPECTOR_PANELS_ENABLED() { + return mockState.inspectorEnabled; + }, + get STUDIO_MOTION_PANEL_ENABLED() { + return mockState.motionEnabled; + }, +})); + +vi.mock("../contexts/PanelLayoutContext", () => ({ + usePanelLayoutContext: () => ({ + rightWidth: 420, + rightPanelTabs: mockState.rightPanelTabs, + rightPanelFocusTab: "design", + focusRightPanelTab: () => {}, + toggleRightPanelTab: () => {}, + handlePanelResizeStart: () => {}, + handlePanelResizeMove: () => {}, + handlePanelResizeEnd: () => {}, + }), +})); + +vi.mock("../contexts/StudioContext", () => ({ + useStudioContext: () => ({ + captionEditMode: false, + previewIframeRef: { current: null }, + projectId: "demo", + activeCompPath: null, + compositionDimensions: { width: 1920, height: 1080 }, + waitForPendingDomEditSaves: async () => {}, + renderQueue: { + jobs: [], + deleteRender: () => {}, + clearCompleted: () => {}, + startRender: async () => {}, + isRendering: false, + }, + }), +})); + +vi.mock("../contexts/DomEditContext", () => ({ + useDomEditContext: () => ({ + domEditSelection: null, + domEditGroupSelections: [], + copiedAgentPrompt: null, + clearDomSelection: () => {}, + handleDomStyleCommit: () => {}, + handleDomAttributeCommit: () => {}, + handleDomPathOffsetCommit: () => {}, + handleDomBoxSizeCommit: () => {}, + handleDomRotationCommit: () => {}, + handleDomTextCommit: () => {}, + handleDomTextFieldStyleCommit: () => {}, + handleDomAddTextField: () => {}, + handleDomRemoveTextField: () => {}, + handleAskAgent: () => {}, + handleDomMotionCommit: () => {}, + handleDomMotionClear: () => {}, + applyDomSelection: () => {}, + }), +})); + +vi.mock("../contexts/FileManagerContext", () => ({ + useFileManagerContext: () => ({ + assets: [], + fontAssets: [], + handleImportFiles: () => {}, + handleImportFonts: () => {}, + }), +})); + +vi.mock("./editor/PropertyPanel", () => ({ + PropertyPanel: () => React.createElement("div", {}, "PropertyPanel"), +})); +vi.mock("./editor/MotionPanel", () => ({ + MotionPanel: () => React.createElement("div", {}, "MotionPanel"), +})); +vi.mock("./editor/LayersPanel", () => ({ + LayersPanel: () => React.createElement("div", {}, "LayersPanel"), +})); +vi.mock("./editor/LayerCssRulesPanel", () => ({ + LayerCssRulesPanel: () => React.createElement("div", {}, "LayerCssRulesPanel"), +})); +vi.mock("./renders/RenderQueue", () => ({ + RenderQueue: () => React.createElement("div", {}, "RenderQueue"), +})); +vi.mock("../captions/components/CaptionPropertyPanel", () => ({ + CaptionPropertyPanel: () => React.createElement("div", {}, "CaptionPropertyPanel"), +})); + +function renderPanel() { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render(React.createElement(StudioRightPanel, { selectedStudioMotion: null })); + }); + return { + host, + cleanup: () => + act(() => { + root.unmount(); + host.remove(); + }), + }; +} + +afterEach(() => { + document.body.innerHTML = ""; + mockState.inspectorEnabled = true; + mockState.motionEnabled = false; + mockState.rightPanelTabs = ["design", "renders"]; +}); + +describe("StudioRightPanel", () => { + it("renders inspector content and renders queue when both tabs are open", () => { + const { host, cleanup } = renderPanel(); + expect(host.textContent).toContain("PropertyPanel"); + expect(host.textContent).toContain("RenderQueue"); + cleanup(); + }); + + it("hides inspector content when inspector panels are disabled", () => { + mockState.inspectorEnabled = false; + const { host, cleanup } = renderPanel(); + expect(host.textContent).not.toContain("PropertyPanel"); + expect(host.textContent).toContain("RenderQueue"); + cleanup(); + }); +}); diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index 4f36db440..c55213430 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -1,29 +1,28 @@ -import { PropertyPanel } from "./editor/PropertyPanel"; -import { MotionPanel } from "./editor/MotionPanel"; -import { LayersPanel } from "./editor/LayersPanel"; +import type { BlockParam } from "@hyperframes/core/registry"; import { CaptionPropertyPanel } from "../captions/components/CaptionPropertyPanel"; +import { useDomEditContext } from "../contexts/DomEditContext"; +import { useFileManagerContext } from "../contexts/FileManagerContext"; +import { usePanelLayoutContext } from "../contexts/PanelLayoutContext"; +import { useStudioContext } from "../contexts/StudioContext"; +import { INSPECTOR_PANEL_TABS } from "../utils/studioHelpers"; import { BlockParamsPanel } from "./editor/BlockParamsPanel"; -import { RenderQueue } from "./renders/RenderQueue"; -import type { RenderJob } from "./renders/useRenderQueue"; -import type { StudioGsapMotion } from "./editor/studioMotion"; -import type { BlockParam } from "@hyperframes/core/registry"; +import { LayerCssRulesPanel } from "./editor/LayerCssRulesPanel"; +import { LayersPanel } from "./editor/LayersPanel"; import { STUDIO_INSPECTOR_PANELS_ENABLED, STUDIO_MOTION_PANEL_ENABLED, } from "./editor/manualEditingAvailability"; +import { MotionPanel } from "./editor/MotionPanel"; +import { PropertyPanel } from "./editor/PropertyPanel"; +import type { StudioGsapMotion } from "./editor/studioMotion"; +import { RenderQueue } from "./renders/RenderQueue"; +import type { RenderJob } from "./renders/useRenderQueue"; /** Motion data without targeting metadata. */ type StudioMotionData = Omit; -import { useStudioContext } from "../contexts/StudioContext"; -import { usePanelLayoutContext } from "../contexts/PanelLayoutContext"; -import { useFileManagerContext } from "../contexts/FileManagerContext"; -import { useDomEditContext } from "../contexts/DomEditContext"; - export interface StudioRightPanelProps { selectedStudioMotion: StudioMotionData | null; - designPanelActive: boolean; - motionPanelActive: boolean; activeBlockParams?: { blockName: string; blockTitle: string; @@ -35,15 +34,15 @@ export interface StudioRightPanelProps { export function StudioRightPanel({ selectedStudioMotion, - designPanelActive, - motionPanelActive, activeBlockParams, onCloseBlockParams, }: StudioRightPanelProps) { const { rightWidth, - rightPanelTab, - setRightPanelTab, + rightPanelTabs, + rightPanelFocusTab, + focusRightPanelTab, + toggleRightPanelTab, handlePanelResizeStart, handlePanelResizeMove, handlePanelResizeEnd, @@ -83,6 +82,18 @@ export function StudioRightPanel({ useFileManagerContext(); const renderJobs = renderQueue.jobs as RenderJob[]; + const isTabOpen = (tab: (typeof INSPECTOR_PANEL_TABS)[number] | "renders") => + rightPanelTabs.includes(tab); + const inspectorTabsInOrder = STUDIO_INSPECTOR_PANELS_ENABLED + ? INSPECTOR_PANEL_TABS.filter((tab) => { + if (tab === "motion" && !STUDIO_MOTION_PANEL_ENABLED) return false; + return isTabOpen(tab); + }) + : []; + const showRenders = isTabOpen("renders"); + const showAnyInspector = inspectorTabsInOrder.length > 0; + const singleInspectorTab = inspectorTabsInOrder.length === 1; + const showBlockParams = rightPanelFocusTab === "block-params" && Boolean(activeBlockParams); return ( <> @@ -108,9 +119,9 @@ export function StudioRightPanel({ <> )} + )} -
- {rightPanelTab === "block-params" && activeBlockParams ? ( +
+ {showBlockParams && activeBlockParams ? ( {})} /> - ) : rightPanelTab === "layers" ? ( - - ) : designPanelActive ? ( - 1 ? null : domEditSelection} - multiSelectCount={domEditGroupSelections.length} - copiedAgentPrompt={copiedAgentPrompt} - onClearSelection={clearDomSelection} - onSetStyle={handleDomStyleCommit} - onSetAttribute={handleDomAttributeCommit} - onSetHtmlAttribute={handleDomHtmlAttributeCommit} - onSetManualOffset={handleDomPathOffsetCommit} - onSetManualSize={handleDomBoxSizeCommit} - onSetManualRotation={handleDomRotationCommit} - onSetText={handleDomTextCommit} - onSetTextFieldStyle={handleDomTextFieldStyleCommit} - onAddTextField={handleDomAddTextField} - onRemoveTextField={handleDomRemoveTextField} - onAskAgent={handleAskAgent} - onImportAssets={handleImportFiles} - fontAssets={fontAssets} - onImportFonts={handleImportFonts} - /> - ) : motionPanelActive ? ( - 1 ? null : domEditSelection} - motion={selectedStudioMotion} - onClearSelection={clearDomSelection} - onSetMotion={handleDomMotionCommit} - onClearMotion={handleDomMotionClear} - /> - ) : ( + ) : null} + {!showBlockParams && showAnyInspector ? ( +
+ {inspectorTabsInOrder.map((tab) => ( +
+ +
+ {tab === "layers" ? ( + + ) : tab === "css" ? ( + + ) : tab === "design" ? ( + 1 ? null : domEditSelection} + multiSelectCount={domEditGroupSelections.length} + copiedAgentPrompt={copiedAgentPrompt} + onClearSelection={clearDomSelection} + onSetStyle={handleDomStyleCommit} + onSetAttribute={handleDomAttributeCommit} + onSetHtmlAttribute={handleDomHtmlAttributeCommit} + onSetManualOffset={handleDomPathOffsetCommit} + onSetManualSize={handleDomBoxSizeCommit} + onSetManualRotation={handleDomRotationCommit} + onSetText={handleDomTextCommit} + onSetTextFieldStyle={handleDomTextFieldStyleCommit} + onAddTextField={handleDomAddTextField} + onRemoveTextField={handleDomRemoveTextField} + onAskAgent={handleAskAgent} + onImportAssets={handleImportFiles} + fontAssets={fontAssets} + onImportFonts={handleImportFonts} + /> + ) : ( + 1 ? null : domEditSelection} + motion={selectedStudioMotion} + onClearSelection={clearDomSelection} + onSetMotion={handleDomMotionCommit} + onClearMotion={handleDomMotionClear} + /> + )} +
+
+ ))} +
+ ) : null} + {showRenders ? ( { await waitForPendingDomEditSaves(); const composition = - activeCompPath && activeCompPath !== "index.html" - ? activeCompPath - : undefined; + activeCompPath && activeCompPath !== "index.html" ? activeCompPath : undefined; await renderQueue.startRender({ fps, quality, @@ -221,7 +276,7 @@ export function StudioRightPanel({ compositionDimensions={compositionDimensions} isRendering={renderQueue.isRendering} /> - )} + ) : null}
)} diff --git a/packages/studio/src/components/editor/LayerCssRulesPanel.tsx b/packages/studio/src/components/editor/LayerCssRulesPanel.tsx new file mode 100644 index 000000000..f4e634a42 --- /dev/null +++ b/packages/studio/src/components/editor/LayerCssRulesPanel.tsx @@ -0,0 +1,360 @@ +import { memo, useCallback, useEffect, useRef, useState } from "react"; +import { useStudioContext } from "../../contexts/StudioContext"; +import { useDomEditContext } from "../../contexts/DomEditContext"; +import { useFileManagerContext } from "../../contexts/FileManagerContext"; +import { + denormalizeAuthoredCssRuleText, + findAuthoredCssRulesInHtml, + getAuthoredCssSelectorCandidates, + measureAuthoredCssRuleContinuationIndent, + normalizeAuthoredCssRuleText, +} from "../../utils/authoredCssRules"; + +type AuthoredCssEditorState = + | { + status: "idle" | "loading"; + sourceFile: string | null; + message: string | null; + rules: AuthoredCssEditorRule[]; + } + | { + status: "ready" | "error"; + sourceFile: string; + message: string | null; + rules: AuthoredCssEditorRule[]; + }; + +interface AuthoredCssEditorRule { + selectorText: string; + continuationIndent: string; + originalRuleText: string; + draftRuleText: string; + status: "ready" | "saving" | "error"; + message: string | null; +} + +export const LayerCssRulesPanel = memo(function LayerCssRulesPanel() { + const { activeCompPath, refreshKey } = useStudioContext(); + const { domEditSelection, handleDomAuthoredCssRuleCommit } = useDomEditContext(); + const { readProjectFile } = useFileManagerContext(); + + const [authoredCss, setAuthoredCss] = useState({ + status: "idle", + sourceFile: null, + message: "Select a layer to inspect its authored CSS rules.", + rules: [], + }); + const authoredCssRequestRef = useRef(0); + const authoredCssSaveRef = useRef(0); + + useEffect(() => { + if (!domEditSelection) { + authoredCssRequestRef.current += 1; + authoredCssSaveRef.current = 0; + setAuthoredCss({ + status: "idle", + sourceFile: null, + message: "Select a layer to inspect its authored CSS rules.", + rules: [], + }); + return; + } + + const selectors = getAuthoredCssSelectorCandidates(domEditSelection); + const sourceFile = domEditSelection.sourceFile || activeCompPath || "index.html"; + if (selectors.length === 0) { + authoredCssRequestRef.current += 1; + authoredCssSaveRef.current = 0; + setAuthoredCss({ + status: "idle", + sourceFile, + message: "Only rules targeting this layer by exact #id or .class are supported here.", + rules: [], + }); + return; + } + + const requestId = authoredCssRequestRef.current + 1; + authoredCssRequestRef.current = requestId; + authoredCssSaveRef.current = 0; + setAuthoredCss({ + status: "loading", + sourceFile, + message: null, + rules: [], + }); + + void readProjectFile(sourceFile) + .then((html) => { + if (authoredCssRequestRef.current !== requestId) return; + const matches = findAuthoredCssRulesInHtml(html, selectors); + if (matches.length === 0) { + setAuthoredCss({ + status: "idle", + sourceFile, + message: `No authored rule found for ${selectors.join(" or ")}.`, + rules: [], + }); + return; + } + setAuthoredCss({ + status: "ready", + sourceFile, + message: null, + rules: matches.map((match) => ({ + selectorText: match.selectorText, + continuationIndent: match.continuationIndent, + originalRuleText: match.ruleText, + draftRuleText: normalizeAuthoredCssRuleText(match), + status: "ready", + message: null, + })), + }); + }) + .catch((error) => { + if (authoredCssRequestRef.current !== requestId) return; + setAuthoredCss({ + status: "error", + sourceFile, + message: error instanceof Error ? error.message : "Failed to load CSS rule.", + rules: [], + }); + }); + }, [activeCompPath, domEditSelection, readProjectFile, refreshKey]); + + const handleAuthoredCssDraftChange = useCallback((ruleIndex: number, draftRuleText: string) => { + setAuthoredCss((current) => { + if (current.status !== "ready" && current.status !== "error") return current; + return { + ...current, + rules: current.rules.map((rule, index) => + index !== ruleIndex + ? rule + : { + ...rule, + draftRuleText, + status: rule.status === "error" ? "ready" : rule.status, + message: null, + }, + ), + }; + }); + }, []); + + const handleAuthoredCssReset = useCallback((ruleIndex: number) => { + setAuthoredCss((current) => { + if (current.status !== "ready" && current.status !== "error") return current; + return { + ...current, + rules: current.rules.map((rule, index) => + index !== ruleIndex + ? rule + : { + ...rule, + draftRuleText: normalizeAuthoredCssRuleText({ + selectorText: rule.selectorText, + ruleText: rule.originalRuleText, + start: 0, + end: rule.originalRuleText.length, + sourceStart: 0, + continuationIndent: rule.continuationIndent, + }), + status: "ready", + message: null, + }, + ), + }; + }); + }, []); + + const handleAuthoredCssSave = useCallback( + (ruleIndex: number) => { + if (!domEditSelection) return; + if (authoredCss.status !== "ready" && authoredCss.status !== "error") return; + + const rule = authoredCss.rules[ruleIndex]; + if (!rule) return; + + const nextRuleText = denormalizeAuthoredCssRuleText(rule.draftRuleText, { + selectorText: rule.selectorText, + ruleText: rule.originalRuleText, + start: 0, + end: rule.originalRuleText.length, + sourceStart: 0, + continuationIndent: rule.continuationIndent, + }); + const requestId = authoredCssRequestRef.current; + const saveId = authoredCssSaveRef.current + 1; + authoredCssSaveRef.current = saveId; + const selection = domEditSelection; + + setAuthoredCss((current) => { + if (current.status !== "ready" && current.status !== "error") return current; + return { + ...current, + rules: current.rules.map((currentRule, index) => + index !== ruleIndex + ? currentRule + : { + ...currentRule, + status: "saving", + message: null, + }, + ), + }; + }); + + void handleDomAuthoredCssRuleCommit(selection, ruleIndex, nextRuleText) + .then(() => { + setAuthoredCss((current) => { + if ( + authoredCssRequestRef.current !== requestId || + authoredCssSaveRef.current !== saveId + ) + return current; + if (current.status !== "ready" && current.status !== "error") return current; + + return { + ...current, + rules: current.rules.map((currentRule, index) => { + if (index !== ruleIndex) return currentRule; + const continuationIndent = measureAuthoredCssRuleContinuationIndent(nextRuleText); + return { + ...currentRule, + continuationIndent, + originalRuleText: nextRuleText, + draftRuleText: normalizeAuthoredCssRuleText({ + selectorText: currentRule.selectorText, + ruleText: nextRuleText, + start: 0, + end: nextRuleText.length, + sourceStart: 0, + continuationIndent, + }), + status: "ready", + message: null, + }; + }), + }; + }); + }) + .catch((error) => { + setAuthoredCss((current) => { + if ( + authoredCssRequestRef.current !== requestId || + authoredCssSaveRef.current !== saveId + ) + return current; + if (current.status !== "ready" && current.status !== "error") return current; + + return { + ...current, + status: "error", + rules: current.rules.map((currentRule, index) => + index !== ruleIndex + ? currentRule + : { + ...currentRule, + status: "error", + message: error instanceof Error ? error.message : "Failed to save CSS rule.", + }, + ), + }; + }); + }); + }, + [authoredCss, domEditSelection, handleDomAuthoredCssRuleCommit], + ); + + return ( +
+
+
Authored CSS
+
+ {authoredCss.sourceFile ?? domEditSelection?.sourceFile ?? activeCompPath ?? "index.html"} +
+
+
+ {authoredCss.status === "ready" || authoredCss.status === "error" ? ( +
+ {authoredCss.rules.length === 0 ? ( +

+ {authoredCss.message ?? "No authored CSS rule is available for this layer."} +

+ ) : ( + authoredCss.rules.map((rule, ruleIndex) => { + const dirty = + rule.draftRuleText !== + normalizeAuthoredCssRuleText({ + selectorText: rule.selectorText, + ruleText: rule.originalRuleText, + start: 0, + end: rule.originalRuleText.length, + sourceStart: 0, + continuationIndent: rule.continuationIndent, + }); + + return ( +
+
+
+ {rule.selectorText} +
+
Rule {ruleIndex + 1}
+
+