Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions packages/studio-server/src/routes/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2496,6 +2496,37 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
);
});

// Removing a marquee selection one element at a time meant one request and
// one rewrite of the whole file per element. A canvas selection runs to
// hundreds of members, so a single Delete press became hundreds of serial
// round trips: the file ended up correct, but only after long enough that the
// key looked like it had done nothing at all.
api.post("/projects/:id/file-mutations/remove-elements/*", async (c) => {
const ctx = await resolveFileMutationContext(c, adapter, "remove-elements");
if ("error" in ctx) return ctx.error;

if (!existsSync(ctx.absPath)) {
return c.json({ error: "not found" }, 404);
}

const body = (await c.req.json().catch(() => null)) as { targets?: MutationTarget[] } | null;
const targets = body?.targets;
if (!Array.isArray(targets) || targets.length === 0) {
return c.json({ error: "targets required" }, 400);
}

const originalContent = readFileSync(ctx.absPath, "utf-8");
// A member nested inside one already removed simply no longer matches, which
// is a normal outcome here rather than a failure. The response says whether
// the file changed, not how many of the targets landed — so a caller can
// tell a no-op from a write, but not a partial pass from a complete one.
let next = originalContent;
for (const target of targets) {
next = removeElementFromHtml(next, target);
}
return writeIfChanged(c, ctx.project.dir, ctx.filePath, ctx.absPath, originalContent, next);
});

api.post("/projects/:id/file-mutations/split-batch", async (c) => {
const body = (await c.req.json().catch(() => null)) as {
files?: unknown;
Expand Down
20 changes: 9 additions & 11 deletions packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -218,10 +218,9 @@ export function StudioApp() {
});
const clearDomSelectionRef = useRef<() => void>(() => {});
const domEditSelectionBridgeRef = useRef<DomEditSelection | null>(null);
const handleDomEditElementDeleteRef = useRef<(s: DomEditSelection) => Promise<void>>(
async () => {},
);
const domEditDeleteBridge = (s: DomEditSelection) => handleDomEditElementDeleteRef.current(s);
type DomEditDelete = (s: DomEditSelection, o?: { expandGroup?: boolean }) => Promise<void>;
const handleDomEditElementDeleteRef = useRef<DomEditDelete>(async () => {});
const domEditDeleteBridge: DomEditDelete = (s, o) => handleDomEditElementDeleteRef.current(s, o);
const resetKeyframesRef = useRef<() => boolean>(() => false);
const deleteSelectedKeyframesRef = useRef<() => void>(() => {});
const { handleCopy, handlePaste, handleCut } = useClipboard({
Expand All @@ -238,7 +237,7 @@ export function StudioApp() {
previewIframeRef,
});
const appHotkeys = useAppHotkeys({
handleTimelineElementDelete: timelineEditing.handleTimelineElementDelete,
handleTimelineElementsDelete: timelineEditing.handleTimelineElementsDelete,
handleTimelineElementSplit: timelineEditing.handleTimelineElementSplit,
handleDomEditElementDelete: domEditDeleteBridge,
domEditSelectionRef: domEditSelectionBridgeRef,
Expand Down Expand Up @@ -282,6 +281,7 @@ export function StudioApp() {
setRightCollapsed: panelLayout.setRightCollapsed,
setRightPanelTab: panelLayout.setRightPanelTab,
showToast,
isRecordingRef: isGestureRecordingRef,
refreshPreviewDocumentVersion,
queueDomEditSave: previewPersistence.queueDomEditSave,
readProjectFile: fileManager.readProjectFile,
Expand All @@ -298,7 +298,7 @@ export function StudioApp() {
previewDocumentVersion,
rightPanelTab: panelLayout.rightPanelTab,
applyStudioManualEditsToPreviewRef: previewPersistence.applyStudioManualEditsToPreviewRef,
syncPreviewHistoryHotkey: appHotkeys.syncPreviewHistoryHotkey,
syncPreviewHotkeys: appHotkeys.syncPreviewHotkeys,
reloadPreview,
setRefreshKey,
openSourceForSelection: fileManager.openSourceForSelection,
Expand Down Expand Up @@ -365,7 +365,6 @@ export function StudioApp() {
isGestureRecordingRef,
});
handleToggleRecordingRef.current = handleToggleRecording;
const recordingToggle = handleToggleRecording;
const canvasRectRef = useRef<DOMRect | null>(null);
useLayoutEffect(() => {
if (gestureState !== "recording" || !previewIframe) {
Expand All @@ -378,8 +377,7 @@ export function StudioApp() {
(iframe: HTMLIFrameElement | null) => {
previewIframeRef.current = iframe;
setPreviewIframe(iframe);
appHotkeys.syncPreviewTimelineHotkey(iframe);
appHotkeys.syncPreviewHistoryHotkey(iframe);
appHotkeys.syncPreviewHotkeys(iframe);
resetConsoleErrors();
refreshPreviewDocumentVersion();
},
Expand Down Expand Up @@ -526,7 +524,7 @@ export function StudioApp() {
}}
recordingState={gestureState}
recordingDuration={gestureRecording.recordingDuration}
onToggleRecording={recordingToggle}
onToggleRecording={handleToggleRecording}
sdkSession={sdkHandle.session}
publishSdkSession={sdkHandle.publish}
forceReloadSdkSession={sdkHandle.forceReload}
Expand Down Expand Up @@ -561,7 +559,7 @@ export function StudioApp() {
shouldShowSelectedDomBounds={shouldShowSelectedDomBounds}
isGestureRecording={gestureState === "recording"}
recordingState={gestureState}
onToggleRecording={recordingToggle}
onToggleRecording={handleToggleRecording}
blockPreview={blockPreview}
gestureOverlay={
gestureState === "recording" && previewIframe ? (
Expand Down
15 changes: 10 additions & 5 deletions packages/studio/src/components/editor/LayersPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ import { useLayerReorderTimelineMirror } from "../nle/useCanvasZOrderTimelineMir
import { runZLaneGesture } from "../nle/zLaneGesture";
import { useLayerRevealOverride } from "./useLayerRevealOverride";

// Rows this panel renders before it stops. A display budget, not a document limit.
const LAYERS_PANEL_MAX_ROWS = 80;

const TAG_ICONS: Record<string, string> = {
video: "Vi",
audio: "Au",
Expand Down Expand Up @@ -137,11 +140,13 @@ export const LayersPanel = memo(function LayersPanel() {
// A preview reload detaches the drilled-into wrapper; exit drill-in if so.
if (activeGroupElement && !activeGroupElement.isConnected) setActiveGroupElement(null);

const items = collectDomEditLayerItems(root, {
activeCompositionPath: activeCompPath,
isMasterView,
activeGroupElement,
});
const items = collectDomEditLayerItems(
root,
{ activeCompositionPath: activeCompPath, isMasterView, activeGroupElement },
// How many rows this panel is willing to render, nothing more. Hit-testing
// callers deliberately take the whole document instead.
LAYERS_PANEL_MAX_ROWS,
);
setLayers(sortLayersByZIndex(items));
}, [previewIframeRef, activeCompPath, isMasterView, activeGroupElement, setActiveGroupElement]);

Expand Down
2 changes: 1 addition & 1 deletion packages/studio/src/components/editor/SnapToolbar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ function AppHotkeyHarness() {
const leftSidebarRef = useRef<LeftSidebarHandle | null>(null);

useAppHotkeys({
handleTimelineElementDelete: vi.fn(),
handleTimelineElementsDelete: vi.fn(async () => {}),
handleTimelineElementSplit: vi.fn(),
handleDomEditElementDelete: vi.fn(),
domEditSelectionRef,
Expand Down
24 changes: 24 additions & 0 deletions packages/studio/src/components/editor/domEditingLayers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,3 +224,27 @@ describe("buildTextFieldChildLocator", () => {
expect(buildTextFieldChildLocator(fields, "missing")).toBeNull();
});
});

describe("collectDomEditLayerItems item budget", () => {
function documentWith(count: number): HTMLElement {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "index.html");
for (let i = 0; i < count; i++) {
const child = document.createElement("div");
child.id = `el-${i}`;
root.append(child);
}
return root;
}

it("returns the whole document by default", () => {
// A default cap here silently truncated the marquee's candidate list: a drag
// over the whole canvas only ever saw the first 80 elements, so everything
// past them was unselectable and survived a Delete.
expect(collectDomEditLayerItems(documentWith(200), opts)).toHaveLength(200);
});

it("truncates only when a caller asks for a rendering budget", () => {
expect(collectDomEditLayerItems(documentWith(200), opts, 80)).toHaveLength(80);
});
});
6 changes: 5 additions & 1 deletion packages/studio/src/components/editor/domEditingLayers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -458,10 +458,14 @@ export function countDomEditChildLayers(
return count;
}

// Every editable element under `root`, in document order. `maxItems` is a
// caller's rendering budget, not a property of the document: hit-testing
// callers (marquee, off-canvas indicators) must see all of it, and sharing a
// truncated list left everything past the cut unselectable however far you drag.
export function collectDomEditLayerItems(
root: HTMLElement | null | undefined,
options: DomEditContextOptions,
maxItems = 80,
maxItems = Number.POSITIVE_INFINITY,
): DomEditLayerItem[] {
if (!root) return [];

Expand Down
59 changes: 36 additions & 23 deletions packages/studio/src/components/editor/marqueeCommit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,18 @@ interface MarqueeHit {
}

/**
* Synchronous core of the marquee: the elements whose overlay-space rect
* intersects the marquee rect. Uses the SAME `toOverlayRect` basis as the
* single-selection / group-selection boxes, so what the marquee highlights
* and selects is exactly the box the user sees when they click an element.
* Shared by the live candidate highlight (per pointer-move) and the mouse-up
* commit. No async source probe — that only happens once, on commit.
* Every element the marquee could hit, with the overlay-space rect it would be
* tested against. Uses the SAME `toOverlayRect` basis as the single-selection /
* group-selection boxes, so what the marquee highlights and selects is exactly
* the box the user sees when they click an element.
*
* Measured once per drag rather than per pointer-move: this reads layout for
* every element in the document, and a captured page has enough of them that
* doing it 60 times a second stalls the tab. The iframe DOM does not mutate
* mid-drag, so the rects it returns stay true for the whole gesture.
*/
// fallow-ignore-next-line complexity
function collectMarqueeHits(
rect: Rect,
function collectMarqueeCandidates(
iframe: HTMLIFrameElement,
overlayEl: HTMLDivElement,
activeCompositionPath: string,
Expand All @@ -53,35 +55,39 @@ function collectMarqueeHits(
height: declH > 0 ? declH : rootEl.getBoundingClientRect().height || 1,
};

const hits: MarqueeHit[] = [];
const candidates: MarqueeHit[] = [];
for (const item of items) {
const el = item.element;
if (!isElementComputedVisible(el)) continue;
if (coversComposition(el.getBoundingClientRect(), viewport)) continue;
const overlayRect = toVisibleOverlayRect(overlayEl, iframe, el);
if (!overlayRect) continue;
const r: Rect = {
left: overlayRect.left,
top: overlayRect.top,
width: overlayRect.width,
height: overlayRect.height,
};
if (!rectsOverlap(rect, r)) continue;
hits.push({ element: el, rect: r });
candidates.push({
element: el,
rect: {
left: overlayRect.left,
top: overlayRect.top,
width: overlayRect.width,
height: overlayRect.height,
},
});
}

return hits;
return candidates;
}

function hitsWithin(rect: Rect, candidates: MarqueeHit[]): MarqueeHit[] {
return candidates.filter((candidate) => rectsOverlap(rect, candidate.rect));
}

async function runMarqueeIntersection(
rect: Rect,
iframe: HTMLIFrameElement,
overlayEl: HTMLDivElement,
candidates: MarqueeHit[],
activeCompositionPath: string,
): Promise<DomEditSelection[]> {
const isMasterView = !activeCompositionPath || activeCompositionPath === "index.html";
const hits: DomEditSelection[] = [];
for (const { element } of collectMarqueeHits(rect, iframe, overlayEl, activeCompositionPath)) {
for (const { element } of hitsWithin(rect, candidates)) {
const sel = await resolveDomEditSelection(element, {
activeCompositionPath,
isMasterView,
Expand Down Expand Up @@ -116,6 +122,8 @@ export function useMarqueeGestures(deps: MarqueeGesturesDeps) {
// iframe DOM doesn't mutate during a drag, so a sync intersection per move
// is cheap (clean layout → no thrash).
const [candidateRects, setCandidateRects] = useState<Rect[]>([]);
// Measured once when the drag passes the threshold and reused until it ends.
const candidatesRef = useRef<MarqueeHit[] | null>(null);

const commitMarquee = useCallback(
async (
Expand All @@ -126,7 +134,8 @@ export function useMarqueeGestures(deps: MarqueeGesturesDeps) {
const overlay = deps.overlayRef.current;
if (!iframe || !overlay || !deps.onMarqueeSelectRef.current) return;
const acp = deps.activeCompositionPathRef.current ?? "index.html";
const hits = await runMarqueeIntersection(rect, iframe, overlay, acp);
const candidates = candidatesRef.current ?? collectMarqueeCandidates(iframe, overlay, acp);
const hits = await runMarqueeIntersection(rect, candidates, acp);
deps.onMarqueeSelectRef.current(hits, additive);
},
[deps.iframeRef, deps.overlayRef, deps.onMarqueeSelectRef, deps.activeCompositionPathRef],
Expand All @@ -145,6 +154,7 @@ export function useMarqueeGestures(deps: MarqueeGesturesDeps) {
const dy = m.currentY - m.startY;
if (Math.hypot(dx, dy) < MARQUEE_THRESHOLD_PX) return;
m.pastThreshold = true;
candidatesRef.current = null;
}
const rect: Rect = {
left: Math.min(m.startX, m.currentX),
Expand All @@ -157,7 +167,8 @@ export function useMarqueeGestures(deps: MarqueeGesturesDeps) {
const overlay = deps.overlayRef.current;
if (iframe && overlay) {
const acp = deps.activeCompositionPathRef.current ?? "index.html";
setCandidateRects(collectMarqueeHits(rect, iframe, overlay, acp).map((h) => h.rect));
candidatesRef.current ??= collectMarqueeCandidates(iframe, overlay, acp);
setCandidateRects(hitsWithin(rect, candidatesRef.current).map((h) => h.rect));
}
return;
}
Expand Down Expand Up @@ -191,6 +202,7 @@ export function useMarqueeGestures(deps: MarqueeGesturesDeps) {
}
setMarqueeRect(null);
setCandidateRects([]);
candidatesRef.current = null;
return;
}
deps.gestures.onPointerUp(event);
Expand All @@ -203,6 +215,7 @@ export function useMarqueeGestures(deps: MarqueeGesturesDeps) {
marqueeRef.current = null;
setMarqueeRect(null);
setCandidateRects([]);
candidatesRef.current = null;
return;
}
deps.gestures.clearPointerState(deps.selectionRef);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { DomEditOverlay } from "./DomEditOverlay";
import { RECOMPUTE_INTERVAL_MS } from "./offCanvasIndicatorRefresh";

Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);

Expand Down Expand Up @@ -47,7 +48,12 @@ function domRect(left: number, top: number, width: number, height: number): DOMR
};
}

// The refresh rebuilds at most every RECOMPUTE_INTERVAL_MS — it walks the whole
// preview and reads layout per element, which is too much to do per frame while
// animation is writing inline styles. Waiting past that window is what makes
// consecutive frames here represent consecutive rebuilds.
async function flushAnimationFrames(): Promise<void> {
await new Promise<void>((resolve) => setTimeout(resolve, RECOMPUTE_INTERVAL_MS + 5));
await new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { RECOMPUTE_INTERVAL_MS, rebuildDue } from "./offCanvasIndicatorRefresh";

describe("rebuildDue", () => {
it("collapses mutations arriving inside one window into a single rebuild", () => {
// A rebuild walks the whole preview and reads layout per element, and what
// marks it dirty is a MutationObserver on inline style — which is how
// animation writes. Without this, playback pays that on nearly every frame.
const first = 1_000;
expect(rebuildDue(true, Number.NEGATIVE_INFINITY, first)).toBe(true);
expect(rebuildDue(true, first, first + RECOMPUTE_INTERVAL_MS - 1)).toBe(false);
expect(rebuildDue(true, first, first + RECOMPUTE_INTERVAL_MS)).toBe(true);
});

it("never rebuilds when nothing changed", () => {
expect(rebuildDue(false, Number.NEGATIVE_INFINITY, 1_000)).toBe(false);
});
});
Loading
Loading