diff --git a/src/main/app-control-server.ts b/src/main/app-control-server.ts index bb4de7d2..313ae57c 100644 --- a/src/main/app-control-server.ts +++ b/src/main/app-control-server.ts @@ -14,7 +14,7 @@ import { endAutomationInteractiveFrame, sendInteractiveState, } from './runtime/overlay-manager' -import { boundIsFillBrowserPage } from './runtime/runtime-geometry' +import { boundIsFocusFillFrame } from './runtime/runtime-geometry' import { activeSessions, resolveSession, @@ -583,9 +583,9 @@ export async function startAppControlServer(): Promise { // Snapshot scale on mousePressed; reuse for mouseReleased so a // mid-click zoom change doesn't split the pair across scales. if (cdpType === 'mousePressed') { - clickEmulationScale = boundIsFillBrowserPage(page) ? 1 : getZoom() + clickEmulationScale = boundIsFocusFillFrame(page) ? 1 : getZoom() } - const emulationScale = clickEmulationScale ?? (boundIsFillBrowserPage(page) ? 1 : getZoom()) + const emulationScale = clickEmulationScale ?? (boundIsFocusFillFrame(page) ? 1 : getZoom()) if (cdpType === 'mouseReleased') { clickEmulationScale = null } diff --git a/src/main/ipc/register-canvas-ipc.ts b/src/main/ipc/register-canvas-ipc.ts index 6303950a..c9f730fe 100644 --- a/src/main/ipc/register-canvas-ipc.ts +++ b/src/main/ipc/register-canvas-ipc.ts @@ -18,15 +18,14 @@ import { saveImageBuffer } from '../runtime/image-assets' import { imageSizeFromBuffer } from '../runtime/image-sizing' import { cancelPendingPlacement, + clearFocus, focusSelectedPage, getSelectedEntityIds, - selectBrowserTab, selectEntity, selectPage, selectPageById, selectedPageId, - setBrowserMode, - setCanvasMode, + setFocus, setSelectedEntities, } from '../runtime/ui-actions' import { @@ -50,10 +49,8 @@ import { setActiveWorkspaceTab, setWorkspaceTabExpanded, } from '../runtime/workspace-session' -import { - setFrameBrowserSizeMode, - type BrowserSizeMode, -} from '../runtime/runtime-entities' +import { setFrameSizeMode } from '../runtime/runtime-entities' +import type { FrameSizeMode } from '../../shared/types' import { createEdges, deleteEdges } from '../workspace-edges' import { selectEntitiesInRect } from '../workspace-entities' import { createFileEntity } from '../runtime/document-commands' @@ -152,34 +149,27 @@ export function registerCanvasIpc(): void { } }) - // --- Browser mode --- + // --- Focus --- + + ipcMain.on('canvas-set-focus', (_event, { entityId, entityKind }: { entityId: string; entityKind: CanvasEntityKind }) => { + setFocus(entityId, entityKind) + }) - ipcMain.on('canvas-select-browser-tab', (_event, { frameId }: { frameId: string }) => { - selectBrowserTab(frameId) + ipcMain.on('canvas-clear-focus', () => { + clearFocus() }) ipcMain.on( - 'canvas-set-browser-size-mode', - (_event, { frameId, mode }: { frameId: string; mode: BrowserSizeMode }) => { + 'canvas-set-frame-size-mode', + (_event, { frameId, mode }: { frameId: string; mode: FrameSizeMode }) => { const page = pages.find((candidate) => candidate.id === frameId) if (!page) return - page.metadata = setFrameBrowserSizeMode(page.metadata, mode) + page.metadata = setFrameSizeMode(page.metadata, mode) scheduleWorkspaceAutosave() layoutAllViews() }, ) - ipcMain.on( - 'canvas-set-browser-mode', - (_event, { mode }: { mode: 'canvas' | 'browser' }) => { - if (mode === 'browser') { - setBrowserMode() - return - } - setCanvasMode() - }, - ) - // --- Tab management --- ipcMain.on('canvas-select-tab', (_event, { tabId }: { tabId: string }) => { diff --git a/src/main/ipc/register-toolbar-ipc.ts b/src/main/ipc/register-toolbar-ipc.ts index 7e0e80bc..bded827a 100644 --- a/src/main/ipc/register-toolbar-ipc.ts +++ b/src/main/ipc/register-toolbar-ipc.ts @@ -12,14 +12,15 @@ import { } from '../runtime/surface-layout' import { cancelPendingPlacement, + clearFocus, clearToolMode, focusSelectedPage, getSelectedEntityIds, openInspectPanel, selectedPageId, + setFocus, startPendingPlacement, toggleAnnotateMode, - toggleBrowserMode, toggleLeftSidebar, toggleDevTools, toggleDrawMode, @@ -27,14 +28,11 @@ import { toggleInspectMode, } from '../runtime/ui-actions' import { endDevtoolsResize, setDevtoolsWidthFromScreenX } from '../runtime/window-shell' -import { selectBrowserTab } from '../runtime/runtime-core' -import { findPageById, setPendingFocus } from '../runtime/runtime-context' -import { addFrameFromSource } from '../workspace-frames' import { applyNavigationToSelectedPages } from '../navigation-sync' -import { workspaceViewMode as uiWorkspaceViewMode } from '../ui-state' +import { isFocused as uiIsFocused } from '../ui-state' -function recenterBrowserSelectionIfNeeded(): void { - if (uiWorkspaceViewMode() !== 'browser') return +function recenterFocusIfNeeded(): void { + if (!uiIsFocused()) return focusSelectedPage() } @@ -83,10 +81,6 @@ export function registerToolbarIpc(): void { applyNavigationToSelectedPages({ type: 'reload', fallbackUrl: 'about:blank' }) }) - ipcMain.on('toolbar-toggle-browser-mode', () => { - toggleBrowserMode() - }) - ipcMain.on('toolbar-toggle-inspect', () => { if (toggleInspectMode()) { openInspectPanel() @@ -111,7 +105,7 @@ export function registerToolbarIpc(): void { ipcMain.on('toggle-devtools', () => { toggleDevTools() - recenterBrowserSelectionIfNeeded() + recenterFocusIfNeeded() }) ipcMain.on('toggle-left-sidebar', () => { @@ -120,12 +114,12 @@ export function registerToolbarIpc(): void { ipcMain.on('devtools-resize-start', (_event, { screenX }: { screenX: number }) => { setDevtoolsWidthFromScreenX(screenX) - recenterBrowserSelectionIfNeeded() + recenterFocusIfNeeded() }) ipcMain.on('devtools-resize-move', (_event, { screenX }: { screenX: number }) => { setDevtoolsWidthFromScreenX(screenX) - recenterBrowserSelectionIfNeeded() + recenterFocusIfNeeded() }) ipcMain.on('devtools-resize-end', () => { @@ -140,38 +134,12 @@ export function registerToolbarIpc(): void { }) }) - ipcMain.on('add-browser-frame', (_event, presetIndex: number | 'custom') => { - const result = addFrameFromSource({ - presetIndex: typeof presetIndex === 'number' ? presetIndex : 0, - customSize: presetIndex === 'custom', - mode: 'add_from_toolbar', - focus: true, - }) - selectBrowserTab(result.frameId) - - // Focus the address bar after the new page finishes loading. - // We must wait because Chromium auto-focuses a webContents when - // its load completes, which would steal focus from the toolbar. - const page = findPageById(result.frameId) - if (toolbarView && page) { - const focusToolbar = () => { - if (!toolbarView) return - setPendingFocus({ kind: 'toolbar' }) - requestLayout() - toolbarView.webContents.send('focus-address-bar') - } - const wc = page.pageView.webContents - if (wc.isLoading()) { - const onDestroyed = () => wc.removeListener('did-finish-load', focusToolbar) - wc.once('destroyed', onDestroyed) - wc.once('did-finish-load', () => { - wc.removeListener('destroyed', onDestroyed) - focusToolbar() - }) - } else { - focusToolbar() - } - } + ipcMain.on('canvas-bg-set-focus', (_event, { entityId, entityKind }: { entityId: string; entityKind: import('../../shared/types').CanvasEntityKind }) => { + setFocus(entityId, entityKind) + }) + + ipcMain.on('canvas-bg-clear-focus', () => { + clearFocus() }) ipcMain.on('cancel-pending-placement', () => { diff --git a/src/main/routes/session.ts b/src/main/routes/session.ts index df31eb4d..92b256e0 100644 --- a/src/main/routes/session.ts +++ b/src/main/routes/session.ts @@ -27,7 +27,7 @@ import { } from '../runtime/runtime-context' import { selectNone as clearSelection } from '../runtime/selection-controller' import { sendInteractiveState } from '../runtime/overlay-manager' -import { setCanvasMode as setUiCanvasMode } from '../ui-state' +import { clearFocus as setUiClearFocus } from '../ui-state' import { writeJson, notifyStatusListeners } from '../app-control-server' function resetSmokeTestState(): void { @@ -35,7 +35,7 @@ function resetSmokeTestState(): void { resetCdpProxyState() clearAutomationInteractiveFrameIds() clearSelection() - setUiCanvasMode() + setUiClearFocus() sendInteractiveState() } diff --git a/src/main/runtime/app-menu.ts b/src/main/runtime/app-menu.ts index 95e72c37..b67b471c 100644 --- a/src/main/runtime/app-menu.ts +++ b/src/main/runtime/app-menu.ts @@ -1,8 +1,8 @@ import { app, dialog, Menu } from 'electron' import { deleteFrames } from '../workspace-entities' import { pages, selectedPageId } from './runtime-context' -import { workspaceViewMode } from '../ui-state' -import { selectBrowserTab } from './runtime-core' +import { focusedFrameId } from '../ui-state' +import { setFocus } from './selection-state' import { checkForUpdatesManually } from '../auto-updater' import { showOnboardingWindow } from '../onboarding-window' import { @@ -81,19 +81,19 @@ function buildTemplate(): Electron.MenuItemConstructorOptions[] { const frameId = selectedPageId() if (!frameId) return - const isBrowser = workspaceViewMode() === 'browser' - let nextTabId: string | null = null - - if (isBrowser) { + // If we're focused on this frame, find a neighbor so we can switch focus after delete + const isFocusedOnThisFrame = focusedFrameId() === frameId + let nextFrameId: string | null = null + if (isFocusedOnThisFrame) { const idx = pages.findIndex((p) => p.id === frameId) const next = pages[idx + 1] ?? pages[idx - 1] ?? null - nextTabId = next?.id ?? null + nextFrameId = next?.id ?? null } deleteFrames({ frameIds: [frameId] }) - if (isBrowser && nextTabId) { - selectBrowserTab(nextTabId) + if (isFocusedOnThisFrame && nextFrameId) { + setFocus(nextFrameId, 'frame') } }, }, diff --git a/src/main/runtime/canvas-layout-data.ts b/src/main/runtime/canvas-layout-data.ts index 7f938112..3b13909e 100644 --- a/src/main/runtime/canvas-layout-data.ts +++ b/src/main/runtime/canvas-layout-data.ts @@ -41,22 +41,22 @@ import { } from './runtime-context' import { activeWorkspaceTabId, workspaceAnnotations, workspaceEdges, workspaceGroups } from './workspace-model' import { - activeBrowserFrameId as uiActiveBrowserFrameId, annotationMode as uiAnnotationMode, devtoolsOpen as uiDevtoolsOpen, devtoolsWidth as uiDevtoolsWidth, + focusedEntityId as uiFocusedEntityId, + focusedFrameId as uiFocusedFrameId, pendingPlacement as uiPendingPlacement, selectedCanvasTargets as uiSelectedCanvasTargets, selectedEntityIds as uiSelectedEntityIds, selectedGroupId as uiSelectedGroupId, - workspaceViewMode as uiWorkspaceViewMode, } from '../ui-state' import { pageContentSize, boundEffectivePageContentSize as effectivePageContentSize, boundAvailableCanvasViewport as localAvailableCanvasViewport, boundCanvasOrigin as localCanvasOrigin, - boundFillBrowserViewportSize as localFillBrowserViewportSize, + boundFocusFillViewportSize as localFocusFillViewportSize, boundScreenBoundsForPage as screenBoundsForPage, } from './runtime-geometry' import { frameDisplayLabel, viewportPresetForIndex } from './runtime-serialization' @@ -68,7 +68,7 @@ import { } from './text-entity-state' import { frameUsesCustomSize, - frameBrowserSizeModeFromMetadata, + frameSizeModeFromMetadata, deviceIdFromMetadata, deviceOrientationFromMetadata, showDeviceFrameFromMetadata, @@ -103,15 +103,15 @@ function mainWindowContentBounds(): { // --- Exported data builders --- export function backgroundFrameOverlays(): CanvasSceneFrameEntity[] { - const viewMode = uiWorkspaceViewMode() - const activeBrowserPageId = viewMode === 'browser' ? uiActiveBrowserFrameId() : null + const focusedFrameIdValue = uiFocusedFrameId() return pages.map((page) => { const { width, height } = effectivePageContentSize(page) const bounds = screenBoundsForPage(page) const deviceId = deviceIdFromMetadata(page.metadata) - // In browser mode, only show the device shell for the active tab + const isFocusedFrame = focusedFrameIdValue === page.id + // When a frame is focused, only show the device shell for the focused frame const showShell = showDeviceFrameFromMetadata(page.metadata) - && (viewMode === 'canvas' || page.id === activeBrowserPageId) + && (focusedFrameIdValue === null || isFocusedFrame) return { kind: 'frame' as const, id: page.id, @@ -122,7 +122,9 @@ export function backgroundFrameOverlays(): CanvasSceneFrameEntity[] { canGoForward: page.pageView.webContents.canGoForward(), isLoading: page.pageView.webContents.isLoading(), isCustomSize: frameUsesCustomSize(page.metadata), - browserSizeMode: viewMode === 'canvas' ? 'device' : frameBrowserSizeModeFromMetadata(page.metadata), + sizeMode: isFocusedFrame + ? frameSizeModeFromMetadata(page.metadata) + : 'fit', canvasX: page.canvasX, canvasY: page.canvasY, width, @@ -147,22 +149,6 @@ export function backgroundFrameOverlays(): CanvasSceneFrameEntity[] { }) } -function buildLiveBrowserTabSummaries() { - return pages.map((page) => { - const { width, height } = pageContentSize(page) - return { - id: page.id, - label: frameDisplayLabel(page), - name: page.name?.trim() || undefined, - url: page.url, - presetIndex: page.presetIndex, - faviconUrl: page.faviconUrl ?? null, - width, - height, - } - }) -} - export function activeCanvasSelection(): ActiveCanvasEntitySelection | null { const selectedFrameIds = uiSelectedEntityIds() const targets = selectedFrameIds @@ -273,9 +259,9 @@ export function buildCanvasLayoutData( frames: CanvasSceneFrameEntity[], activeSelection: ActiveCanvasEntitySelection | null, ): LayoutUpdateData { - const fillViewport = localFillBrowserViewportSize() + const fillViewport = localFocusFillViewportSize() const pending = uiPendingPlacement() - const viewMode = uiWorkspaceViewMode() + const focusedFrameIdValue = uiFocusedFrameId() const origin = localCanvasOrigin() const pendingPlacementData: PendingPlacement | null = pending @@ -284,7 +270,7 @@ export function buildCanvasLayoutData( const isFile = pending.entityKind === 'file' const sourcePage = pending.sourceFrameId ? findPageById(pending.sourceFrameId) : null const preset = (isText || isFile) ? null : viewportPresetForIndex(pending.presetIndex ?? 0) - const customSize = sourcePage ? pageContentSize(sourcePage) : localFillBrowserViewportSize() + const customSize = sourcePage ? pageContentSize(sourcePage) : localFocusFillViewportSize() const contentBounds = mainWindowContentBounds() const cursor = screen.getCursorScreenPoint() const initialClientX = @@ -339,20 +325,14 @@ export function buildCanvasLayoutData( ), ...groupEntities, ] as CanvasSceneEntity[], - browserTabs: buildLiveBrowserTabSummaries(), - browserFillViewport: fillViewport, + focusFillViewport: fillViewport, selectedEntityIds: uiSelectedEntityIds(), selection: uiSelectedCanvasTargets(), activeSelection, annotationMode: uiAnnotationMode(), annotations: [...workspaceAnnotations], fixProgress: getFixProgress(), - viewMode, - activeBrowserTabId: - viewMode === 'browser' - ? selectedPageId() - : null, - activeBrowserFrameId: uiActiveBrowserFrameId(), + focusedEntityId: uiFocusedEntityId(), selectedGroupId: uiSelectedGroupId(), hover: hoverTarget, interaction: interactionState, @@ -363,10 +343,9 @@ export function buildCanvasLayoutData( groups: groupEntities, presenceCursors: getPresenceCursors() .filter((c) => { - // In browser mode, hide cursors that explicitly target a different frame. - if (viewMode !== 'browser') return true - const activeFrameId = uiActiveBrowserFrameId() - if (c.surface === 'frame' && c.frameId && c.frameId !== activeFrameId) return false + // When focused on a frame, hide cursors that explicitly target a different frame. + if (focusedFrameIdValue === null) return true + if (c.surface === 'frame' && c.frameId && c.frameId !== focusedFrameIdValue) return false return true }) .map((c): AgentPresenceCursor => ({ @@ -381,8 +360,6 @@ export function buildCanvasLayoutData( fallbackX: frame.width / 2, fallbackY: frame.height / 2, }) - // Clamp to the frame's visible area so the cursor doesn't - // render outside the frame when targeting off-screen elements. const clampedX = Math.max(0, Math.min(point.x, frame.width)) const clampedY = Math.max(0, Math.min(point.y, frame.height)) return { @@ -391,13 +368,10 @@ export function buildCanvasLayoutData( } } } - // In browser mode, place canvas-surface cursors on the active frame + // When focused on a frame, place canvas-surface cursors on that frame // so they remain visible instead of mapping to off-screen canvas coords. - if (viewMode === 'browser') { - const activeFrameId = uiActiveBrowserFrameId() - const frame = activeFrameId - ? frames.find((candidate) => candidate.id === activeFrameId) - : null + if (focusedFrameIdValue) { + const frame = frames.find((candidate) => candidate.id === focusedFrameIdValue) if (frame) { return { screenX: frame.screenX + frame.screenWidth / 2, @@ -466,7 +440,7 @@ export function toolbarSelectionData(): ToolbarSelectionData { loadingPhase: 'idle', activeTabId: activeWorkspaceTabId, activeTabName, - viewMode: uiWorkspaceViewMode(), + focusedEntityId: uiFocusedEntityId(), pendingPlacementActive: uiPendingPlacement() !== null, } } @@ -501,7 +475,7 @@ export function toolbarSelectionData(): ToolbarSelectionData { loadingPhase, activeTabId: activeWorkspaceTabId, activeTabName, - viewMode: uiWorkspaceViewMode(), + focusedEntityId: uiFocusedEntityId(), pendingPlacementActive: uiPendingPlacement() !== null, } } diff --git a/src/main/runtime/focus-reconciler-runtime.ts b/src/main/runtime/focus-reconciler-runtime.ts index 6cce40e2..062e7500 100644 --- a/src/main/runtime/focus-reconciler-runtime.ts +++ b/src/main/runtime/focus-reconciler-runtime.ts @@ -10,7 +10,7 @@ import type { FocusTarget } from '../../shared/interaction-types' import { expectedFocus, focusKey, type FocusState } from './focus-reconciler' import { aboveView, bgView, toolbarView, leftSidebarView, win } from './view-refs' import { pages, interactionState, pendingFocus, setPendingFocus } from './runtime-context' -import { isCommentOverlayVisible, selectedPageIndex, workspaceViewMode } from '../ui-state' +import { focusedFrameId, isCommentOverlayVisible, selectedPageIndex } from '../ui-state' function interactionModeKey(): FocusState['interactionMode'] { switch (interactionState.kind) { @@ -31,7 +31,7 @@ function currentFocusState(): FocusState { interactionMode: interactionModeKey(), editingTextEntityId: interactionState.kind === 'editing-text' ? interactionState.entityId : null, selectedPageId: selectedPage?.id ?? null, - workspaceViewMode: workspaceViewMode(), + focusedFrameId: focusedFrameId(), commentOverlayActive: isCommentOverlayVisible(), pendingFocus, } diff --git a/src/main/runtime/focus-reconciler.ts b/src/main/runtime/focus-reconciler.ts index 5f3a25cd..b3488829 100644 --- a/src/main/runtime/focus-reconciler.ts +++ b/src/main/runtime/focus-reconciler.ts @@ -24,7 +24,8 @@ export type FocusState = { interactionMode: 'idle' | 'panning' | 'marquee' | 'dragging-entities' | 'resizing-entity' | 'dragging-edge' | 'editing-text' editingTextEntityId: string | null selectedPageId: string | null - workspaceViewMode: 'canvas' | 'browser' + /** Frame id currently focused (any entity kind other than frame → null). */ + focusedFrameId: string | null commentOverlayActive: boolean /** Explicit intent set by a subsystem (overrides derivation). Cleared after reconcile. */ pendingFocus: FocusTarget | null @@ -54,13 +55,12 @@ export function expectedFocus(state: FocusState): FocusTarget { return { kind: 'aboveView' } } - // Browser mode with a live page — the page should be focused so - // keyboard input reaches the web content. - if (state.workspaceViewMode === 'browser' && state.selectedPageId) { - return { kind: 'page', id: state.selectedPageId } + // Focus-on-frame — the page should be focused so keyboard input reaches the web content. + if (state.focusedFrameId) { + return { kind: 'page', id: state.focusedFrameId } } - // Canvas mode default: bgView (canvas keyboard shortcuts, undo). + // Default: bgView (canvas keyboard shortcuts, undo). return { kind: 'bgView' } } diff --git a/src/main/runtime/gate-predicate.ts b/src/main/runtime/gate-predicate.ts index 2cb10389..8a90f4f7 100644 --- a/src/main/runtime/gate-predicate.ts +++ b/src/main/runtime/gate-predicate.ts @@ -17,7 +17,7 @@ import type { CanvasEntityKind } from '../../shared/types' export type GateInputs = { interactionKind: InteractionMode['kind'] toolMode: 'select' | 'inspect' | 'annotate-comment' | 'annotate-draw' | 'annotate-region-select' - viewMode: 'canvas' | 'browser' + isFocused: boolean /** Imperative override set by IPC handlers that open annotation/comment UI. */ commentOverlayActive: boolean selectionMarqueeVisible: boolean @@ -53,7 +53,7 @@ function toolModeOpensGate(toolMode: GateInputs['toolMode']): boolean { } function hasFloatingMenu(inputs: GateInputs): boolean { - if (inputs.viewMode !== 'canvas') return false + if (inputs.isFocused) return false if (inputs.selectedEntityIds.length !== 1) return false const kind = inputs.selectedEntityKinds[0] return kind === 'text' || kind === 'drawing' diff --git a/src/main/runtime/json-canvas-serializer.ts b/src/main/runtime/json-canvas-serializer.ts index f486aed5..a0f97d48 100644 --- a/src/main/runtime/json-canvas-serializer.ts +++ b/src/main/runtime/json-canvas-serializer.ts @@ -7,7 +7,6 @@ import type { Annotation, - BrowserTabMode, DevtoolsPanelTab, PersistedCanvasEntity, PersistedDrawingEntity, @@ -216,7 +215,6 @@ function serializeAppState(snapshot: WorkspaceSnapshot): JsonCanvasAppState { devtoolsOpen: snapshot.devtoolsOpen, devtoolsPanelTab: snapshot.devtoolsPanelTab, devtoolsWidth: snapshot.devtoolsWidth, - browserTabMode: snapshot.browserTabMode, } } @@ -269,7 +267,6 @@ export function deserializeFromJsonCanvas(doc: JsonCanvasDocument): { devtoolsOpen: appState.devtoolsOpen ?? false, devtoolsPanelTab: (appState.devtoolsPanelTab as DevtoolsPanelTab) ?? 'elements', devtoolsWidth: appState.devtoolsWidth ?? 400, - browserTabMode: (appState.browserTabMode as BrowserTabMode) ?? 'frame', edges, } diff --git a/src/main/runtime/keyboard-shortcuts.ts b/src/main/runtime/keyboard-shortcuts.ts index d5400a2d..050371c3 100644 --- a/src/main/runtime/keyboard-shortcuts.ts +++ b/src/main/runtime/keyboard-shortcuts.ts @@ -6,8 +6,8 @@ import type { WebContents } from 'electron' import { DRAWING_FEATURE_ENABLED } from '../../shared/featureFlags' import { arrowNavigationLocked, setArrowNavigationLocked, setSpaceModifierHeld } from './runtime-context' import { undo, redo, canUndo, canRedo } from './workspace-undo' -import { pendingPlacement as uiPendingPlacement } from '../ui-state' -import { selectAdjacentPage } from './selection-state' +import { isFocused as uiIsFocused, pendingPlacement as uiPendingPlacement } from '../ui-state' +import { clearFocus, selectAdjacentPage } from './selection-state' import { layoutAllViews } from './layout-engine' type ArrowDirection = 'left' | 'right' | 'up' | 'down' @@ -116,6 +116,21 @@ export function watchModifierKeys(webContents: WebContents, { handleShortcuts = return } + // Escape exits focus — intercepts before the webview's own Escape handler. + if ( + input.type === 'keyDown' && + input.key === 'Escape' && + !input.shift && + !input.meta && + !input.control && + !input.alt && + uiIsFocused() + ) { + event.preventDefault() + clearFocus() + return + } + // Undo: Cmd+Z if ( input.type === 'keyDown' && diff --git a/src/main/runtime/layout-engine.ts b/src/main/runtime/layout-engine.ts index 72a38826..67097122 100644 --- a/src/main/runtime/layout-engine.ts +++ b/src/main/runtime/layout-engine.ts @@ -3,10 +3,9 @@ import { boundsKey, boundApplyEmulation, boundEffectivePageContentSize, - boundIsFillBrowserPage, + boundIsFocusFillFrame, boundScreenBoundsForPage, boundSelectedPage, - boundSelectedPageId, boundCanvasOrigin, } from './runtime-geometry' import { @@ -45,11 +44,13 @@ import { devtoolsOpen as uiDevtoolsOpen, devtoolsPanelTab as uiDevtoolsPanelTab, devtoolsWidth as uiDevtoolsWidth, + focusedEntityId as uiFocusedEntityId, + focusedFrameId as uiFocusedFrameId, isCommentOverlayVisible as uiCommentOverlayVisible, + isFocused as uiIsFocused, leftSidebarOpen as uiLeftSidebarOpen, selectedEntityIds as uiSelectedEntityIds, setDevtoolsWidth as setUiDevtoolsWidth, - workspaceViewMode as uiWorkspaceViewMode, } from '../ui-state' import { backgroundFrameOverlays, @@ -88,7 +89,6 @@ export function setBoundsIfChanged( } import { - BROWSER_HEADER_HEIGHT, CARD_BORDER_RADIUS, DEVTOOLS_HEADER_GAP, DEVTOOLS_HEADER_HEIGHT, @@ -202,13 +202,15 @@ export function layoutAllViews(): void { if (!win || win.isDestroyed()) return const layoutStart = DEVTOOLS_PANEL_DEBUG ? Date.now() : 0 if (consumeDirty('stack')) applyStack() - const viewMode = uiWorkspaceViewMode() + const focusedEntityId = uiFocusedEntityId() + const focusedFrameIdValue = uiFocusedFrameId() + const isFocused = uiIsFocused() const devtoolsOpen = uiDevtoolsOpen() const devtoolsWidth = uiDevtoolsWidth() const devtoolsPanelTab = uiDevtoolsPanelTab() const selectedFrameIds = uiSelectedEntityIds() - const contentTopInset = layoutCache.toolbarHeight + (viewMode === 'browser' ? BROWSER_HEADER_HEIGHT : 0) + const contentTopInset = layoutCache.toolbarHeight const frameOverlays = backgroundFrameOverlays() const nextActiveSelection = activeCanvasSelection() @@ -269,7 +271,7 @@ export function layoutAllViews(): void { : interactionState.kind === 'editing-text' ? 'editing-text' : interactionState.kind, toolMode: getUiState().toolMode, - viewMode: uiWorkspaceViewMode(), + isFocused, commentOverlayActive: uiCommentOverlayVisible(), selectionMarqueeVisible: selectionOverlayActive, spaceHeld: spaceModifierHeld, @@ -328,10 +330,11 @@ export function layoutAllViews(): void { const windowRect = { x: 0, y: 0, width: winBounds.width, height: winBounds.height } // --- Per-page bounds, emulation, annotations --- - const visibleBrowserPageId = boundSelectedPageId() for (const page of pages) { const pageStart = DEVTOOLS_PANEL_DEBUG ? Date.now() : 0 - const isVisibleInCurrentMode = viewMode === 'canvas' || page.id === visibleBrowserPageId + // In focus-fill mode, only the focused frame renders (mirrors old browser mode) + const isVisibleInCurrentMode = + focusedFrameIdValue === null || page.id === focusedFrameIdValue if (!isVisibleInCurrentMode) { page.lastFrameBoundsKey = setBoundsIfChanged(page.frameView, HIDDEN_BOUNDS, page.lastFrameBoundsKey) page.lastPageBoundsKey = setBoundsIfChanged(page.pageView, HIDDEN_BOUNDS, page.lastPageBoundsKey) @@ -360,10 +363,10 @@ export function layoutAllViews(): void { continue } - const isFillBrowser = boundIsFillBrowserPage(page) + const isFocusFill = boundIsFocusFillFrame(page) const deviceId = deviceIdFromMetadata(page.metadata) const showShell = showDeviceFrameFromMetadata(page.metadata) - const borderRadius = isFillBrowser + const borderRadius = isFocusFill ? 0 : deviceId && showShell ? Math.round(contentCornerRadiusForDevice(deviceId, deviceOrientationFromMetadata(page.metadata)) * zoom) @@ -376,8 +379,8 @@ export function layoutAllViews(): void { const { width: emulatedWidth, height: emulatedHeight } = boundEffectivePageContentSize(page) const nativeScale = screen.getPrimaryDisplay().scaleFactor - const pageScale = isFillBrowser ? 1 : zoom - const pageEmulationKey = `${emulatedWidth}:${emulatedHeight}:${pageScale}:${nativeScale}:${viewMode}:${devtoolsOpen ? devtoolsWidth : 0}` + const pageScale = isFocusFill ? 1 : zoom + const pageEmulationKey = `${emulatedWidth}:${emulatedHeight}:${pageScale}:${nativeScale}:${focusedEntityId ?? ''}:${devtoolsOpen ? devtoolsWidth : 0}` if (pageEmulationKey !== page.lastPageEmulationKey) { const emulationStart = DEVTOOLS_PANEL_DEBUG ? Date.now() : 0 boundApplyEmulation(page.pageView.webContents, page.presetIndex, page) @@ -387,7 +390,7 @@ export function layoutAllViews(): void { durationMs: Date.now() - emulationStart, emulatedWidth, emulatedHeight, - viewMode, + focusedEntityId, devtoolsOpen, }) @@ -395,7 +398,7 @@ export function layoutAllViews(): void { // Inject or remove safe-area CSS padding when the device shell is active const orientation = deviceOrientationFromMetadata(page.metadata) - const safeAreaCss = deviceId && showShell && !isFillBrowser + const safeAreaCss = deviceId && showShell && !isFocusFill ? safeAreaCssForDevice(deviceId, orientation) : null const safeAreaKey = safeAreaCss ?? '' diff --git a/src/main/runtime/overlay-manager.ts b/src/main/runtime/overlay-manager.ts index 03fd35c1..b79646f2 100644 --- a/src/main/runtime/overlay-manager.ts +++ b/src/main/runtime/overlay-manager.ts @@ -18,10 +18,10 @@ import { } from './runtime-context' import { workspaceGroups } from './workspace-model' import { + focusedEntityId as uiFocusedEntityId, getUiState, isSelectionMarqueeVisible as uiSelectionMarqueeVisible, setSelectionMarqueeVisible as setUiSelectionMarqueeVisible, - workspaceViewMode as uiWorkspaceViewMode, } from '../ui-state' import { selectionDebug } from './runtime-constants' import { boundEffectivePageContentSize } from './runtime-geometry' @@ -83,7 +83,7 @@ export function sendInteractiveState(): void { const wc = pages[i].pageView.webContents safeSend(wc, 'set-interactive', isSelected) safeSend(wc, 'set-multi-selected', isMultiSelected) - safeSend(wc, 'set-workspace-view-mode', uiWorkspaceViewMode()) + safeSend(wc, 'set-focused-entity-id', uiFocusedEntityId()) } } diff --git a/src/main/runtime/region-capture.ts b/src/main/runtime/region-capture.ts index d752e72e..399dd92d 100644 --- a/src/main/runtime/region-capture.ts +++ b/src/main/runtime/region-capture.ts @@ -27,10 +27,10 @@ import { showDeviceFrameFromMetadata, } from './runtime-entities' import { contentCornerRadiusForDevice } from '../../shared/device-catalog' -import { boundIsFillBrowserPage } from './runtime-geometry' +import { boundIsFocusFillFrame } from './runtime-geometry' function pageCornerRadiusPx(page: Page, dpr: number): number { - if (boundIsFillBrowserPage(page)) return 0 + if (boundIsFocusFillFrame(page)) return 0 const deviceId = deviceIdFromMetadata(page.metadata) if (!deviceId || !showDeviceFrameFromMetadata(page.metadata)) return 0 const orientation = deviceOrientationFromMetadata(page.metadata) diff --git a/src/main/runtime/runtime-constants.ts b/src/main/runtime/runtime-constants.ts index 49698dbd..6f4ebbb2 100644 --- a/src/main/runtime/runtime-constants.ts +++ b/src/main/runtime/runtime-constants.ts @@ -7,6 +7,10 @@ export const CARD_BORDER_WIDTH = 1 export const CARD_BORDER_RADIUS = 0 export const CHROME_HEADER_HEIGHT = 44 export const CHROME_PAGE_GAP = 0 +/** Pixels between the toolbar and the top of a focused entity's pinned chrome. */ +export const FOCUS_CHROME_TOP_OFFSET = 8 +/** Pixels between the pinned chrome bottom and the focused entity's content. */ +export const FOCUS_CHROME_BOTTOM_GAP = 8 export { TOOLBAR_HEIGHT } from '../../shared/constants' export const TOOLBAR_BORDER_LIGHT = '#d4d4d8' export const TOOLBAR_BORDER_DARK = '#3f3f46' diff --git a/src/main/runtime/runtime-core.ts b/src/main/runtime/runtime-core.ts index f80fad63..6bd9d27b 100644 --- a/src/main/runtime/runtime-core.ts +++ b/src/main/runtime/runtime-core.ts @@ -12,7 +12,6 @@ import { selectedPageIndex as uiSelectedPageIndex, setCommentOverlayVisible as setUiCommentOverlayVisible, setDevtoolsWidth as setUiDevtoolsWidth, - workspaceViewMode as uiWorkspaceViewMode, } from '../ui-state' import type { DevtoolsPanelData, @@ -43,9 +42,6 @@ import { notifyDevtoolsPanelData, } from './inspect-session' import type { Page } from './runtime-entities' -import { - setBrowserMode, -} from './selection-state' import { selectEntities as commitSelectedEntities, selectEntity as commitSelectEntity, @@ -219,9 +215,6 @@ function collapseSelectionForBrowserMode(frameId?: string): boolean { } return true } -export function selectBrowserTab(frameId: string): boolean { - return setBrowserMode(frameId) -} export function removePage(senderWebContents: WebContents): void { if (!win) return const idx = pages.findIndex( @@ -262,10 +255,6 @@ export function setDevtoolsWidthFromScreenX(screenX: number): void { requestLayout() } -function currentViewMode(): string { - return uiWorkspaceViewMode() -} - function currentDevtoolsOpen(): boolean { return uiDevtoolsOpen() } diff --git a/src/main/runtime/runtime-entities.ts b/src/main/runtime/runtime-entities.ts index b5210efd..1fee0684 100644 --- a/src/main/runtime/runtime-entities.ts +++ b/src/main/runtime/runtime-entities.ts @@ -115,26 +115,29 @@ export function clearCustomFrameSizeMetadata( } // --------------------------------------------------------------------------- -// Browser size mode metadata (per-frame fill vs device in browser mode) +// Frame size mode metadata (per-frame fill / fit / device when focused) // --------------------------------------------------------------------------- -export type BrowserSizeMode = 'fill' | 'device' +import type { FrameSizeMode } from '../../shared/types' -export function frameBrowserSizeModeFromMetadata( +export function frameSizeModeFromMetadata( metadata: Record | undefined, -): BrowserSizeMode { - if (!metadata) return 'device' - const mode = metadata.browserSizeMode - return mode === 'fill' ? 'fill' : 'device' +): FrameSizeMode { + if (!metadata) return 'fit' + // Prefer the new key; fall back to legacy 'browserSizeMode' for older files + const raw = metadata.sizeMode ?? metadata.browserSizeMode + if (raw === 'fill' || raw === 'fit' || raw === 'device') return raw + return 'fit' } -export function setFrameBrowserSizeMode( +export function setFrameSizeMode( metadata: Record | undefined, - mode: BrowserSizeMode, + mode: FrameSizeMode, ): Record { + const { browserSizeMode: _legacy, ...rest } = (metadata ?? {}) as Record return { - ...(metadata ?? {}), - browserSizeMode: mode, + ...rest, + sizeMode: mode, } } diff --git a/src/main/runtime/runtime-geometry.ts b/src/main/runtime/runtime-geometry.ts index d4c8e019..4d595b22 100644 --- a/src/main/runtime/runtime-geometry.ts +++ b/src/main/runtime/runtime-geometry.ts @@ -3,15 +3,21 @@ import type { WebContents } from 'electron' import type { WorkspaceBounds } from '../../shared/types' import type { Page } from './runtime-entities' import { - BROWSER_HEADER_HEIGHT, CARD_BORDER_WIDTH, + CHROME_HEADER_HEIGHT, CHROME_PAGE_GAP, + FOCUS_CHROME_BOTTOM_GAP, + FOCUS_CHROME_TOP_OFFSET, LEFT_SIDEBAR_WIDTH, devtoolsPanelDebug, } from './runtime-constants' + +/** Total vertical space reserved for the pinned focus chrome (offset + height + gap). */ +export const FOCUS_CHROME_INSET = + FOCUS_CHROME_TOP_OFFSET + CHROME_HEADER_HEIGHT + FOCUS_CHROME_BOTTOM_GAP import { frameCustomSizeFromMetadata, - frameBrowserSizeModeFromMetadata, + frameSizeModeFromMetadata, deviceIdFromMetadata, deviceOrientationFromMetadata, showDeviceFrameFromMetadata, @@ -23,9 +29,9 @@ import { pages, pan, zoom } from './runtime-context' import { devtoolsOpen as uiDevtoolsOpen, devtoolsWidth as uiDevtoolsWidth, + focusedFrameId as uiFocusedFrameId, leftSidebarOpen as uiLeftSidebarOpen, selectedPageIndex as uiSelectedPageIndex, - workspaceViewMode as uiWorkspaceViewMode, } from '../ui-state' import { viewportPresetForIndex } from './runtime-serialization' @@ -107,25 +113,20 @@ export function pageOuterCanvasBounds( // --------------------------------------------------------------------------- export function computeCanvasOrigin(input: { - currentViewMode: () => string toolbarHeight: number - browserHeaderHeight: number leftSidebarWidth: number }): { x: number; y: number } { - const viewMode = input.currentViewMode() return { x: input.leftSidebarWidth, - y: input.toolbarHeight + (viewMode === 'browser' ? input.browserHeaderHeight : 0), + y: input.toolbarHeight, } } export function computeAvailableCanvasViewport(input: { win: { getBounds(): { width: number; height: number } } | null - currentViewMode: () => string currentDevtoolsOpen: () => boolean currentDevtoolsWidth: () => number toolbarHeight: number - browserHeaderHeight: number leftSidebarWidth: number }): { width: number; height: number } { const viewport = computeAvailableCanvasViewportRect(input) @@ -134,17 +135,14 @@ export function computeAvailableCanvasViewport(input: { export function computeAvailableCanvasViewportRect(input: { win: { getBounds(): { width: number; height: number } } | null - currentViewMode: () => string currentDevtoolsOpen: () => boolean currentDevtoolsWidth: () => number toolbarHeight: number - browserHeaderHeight: number leftSidebarWidth: number }): { x: number; y: number; width: number; height: number } { const { width = 0, height = 0 } = input.win?.getBounds() ?? {} const leftInset = input.leftSidebarWidth - const topInset = - input.toolbarHeight + (input.currentViewMode() === 'browser' ? input.browserHeaderHeight : 0) + const topInset = input.toolbarHeight return { x: leftInset, y: topInset, @@ -156,7 +154,7 @@ export function computeAvailableCanvasViewportRect(input: { } } -function computeFillBrowserViewportSize(input: { +function computeFocusFillViewportSize(input: { availableCanvasViewport: () => { width: number; height: number } }): { width: number; height: number } { const viewport = input.availableCanvasViewport() @@ -166,25 +164,27 @@ function computeFillBrowserViewportSize(input: { } } -export function computeIsFillBrowserPage(input: { +/** + * True when the frame is the focused entity AND its size mode is 'fill' — + * the frame should be drawn at viewport dimensions (not canvas position). + */ +export function computeIsFocusFillFrame(input: { page: Pick - currentViewMode: () => string - selectedPageId: () => string | null + focusedFrameId: () => string | null }): boolean { return ( - input.currentViewMode() === 'browser' && - input.selectedPageId() === input.page.id && - frameBrowserSizeModeFromMetadata(input.page.metadata) === 'fill' + input.focusedFrameId() === input.page.id && + frameSizeModeFromMetadata(input.page.metadata) === 'fill' ) } export function computeEffectivePageContentSize(input: { page: Pick - isFillBrowserPage: (page: Pick) => boolean - fillBrowserViewportSize: () => { width: number; height: number } + isFocusFillFrame: (page: Pick) => boolean + focusFillViewportSize: () => { width: number; height: number } }): { width: number; height: number } { - if (input.isFillBrowserPage(input.page)) { - return input.fillBrowserViewportSize() + if (input.isFocusFillFrame(input.page)) { + return input.focusFillViewportSize() } return pageContentSize(input.page) } @@ -193,15 +193,14 @@ export function computeScreenBoundsForPage(input: { page: Page effectivePageContentSize: (page: Pick) => { width: number; height: number } availableCanvasViewportRect: () => { x: number; y: number; width: number; height: number } - currentViewMode: () => string - selectedPageId: () => string | null - isFillBrowserPage: (page: Pick) => boolean + focusedFrameId: () => string | null + isFocusFillFrame: (page: Pick) => boolean zoom: number pan: { x: number; y: number } toolbarHeight: number - browserHeaderHeight: number chromePageGap: number cardBorderWidth: number + focusChromeInset: number }): { frame: { x: number; y: number; width: number; height: number } chrome: { x: number; y: number; width: number; height: number } @@ -210,43 +209,32 @@ export function computeScreenBoundsForPage(input: { } { const { width: w, height: h } = input.effectivePageContentSize(input.page) const bw = input.cardBorderWidth - const isBrowserActive = - input.currentViewMode() === 'browser' && input.selectedPageId() === input.page.id - const isFillBrowserActive = input.isFillBrowserPage(input.page) - const displayZoom = isFillBrowserActive ? 1 : input.zoom + const isFocusFillActive = input.isFocusFillFrame(input.page) + const displayZoom = isFocusFillActive ? 1 : input.zoom const chromeH = Math.round(input.page.chromeHeight * input.zoom) const gap = Math.round(input.chromePageGap * input.zoom) const contentW = Math.round(w * displayZoom) const fullPageH = Math.round(h * displayZoom) const viewport = input.availableCanvasViewportRect() - const browserViewportTop = input.toolbarHeight + input.browserHeaderHeight - const browserViewportHeight = viewport.height - const maxBrowserPageH = Math.max(0, browserViewportHeight) - const pageH = isFillBrowserActive - ? maxBrowserPageH - : isBrowserActive - ? Math.min(fullPageH, maxBrowserPageH) - : fullPageH - const rawChromeX = isBrowserActive - ? isFillBrowserActive - ? viewport.x - : Math.round(viewport.x + (viewport.width - w * input.zoom) / 2) + const viewportTop = input.toolbarHeight + const viewportHeight = viewport.height + // In focus-fill mode the pinned chrome sits between the toolbar and the + // content; content starts below the chrome inset and fills the remainder. + const fillContentTop = viewportTop + input.focusChromeInset + const fillContentHeight = Math.max(0, viewportHeight - input.focusChromeInset) + const pageH = isFocusFillActive ? fillContentHeight : fullPageH + const rawChromeX = isFocusFillActive + ? viewport.x : Math.round(viewport.x + input.page.canvasX * input.zoom + input.pan.x) - const browserMinPageY = browserViewportTop - const centeredBrowserPageY = Math.round( - browserViewportTop + (browserViewportHeight - pageH) / 2, - ) - const pageY = isBrowserActive - ? fullPageH >= maxBrowserPageH - ? browserMinPageY - : Math.max(browserMinPageY, centeredBrowserPageY) + const pageY = isFocusFillActive + ? fillContentTop : Math.round(input.page.canvasY * input.zoom + input.pan.y) + input.toolbarHeight + chromeH + gap - const chromeY = isBrowserActive - ? browserViewportTop + const chromeY = isFocusFillActive + ? fillContentTop : Math.round(input.page.canvasY * input.zoom + input.pan.y) + input.toolbarHeight - // Compute shell rect (device frame bezel) — skip in fill-browser mode + // Compute shell rect (device frame bezel) — skip in focus-fill mode const insets = pageShellInsets(input.page) - const shellRect = insets && !isFillBrowserActive + const shellRect = insets && !isFocusFillActive ? { x: rawChromeX - Math.round(insets.left * displayZoom), y: pageY - Math.round(insets.top * displayZoom), @@ -289,13 +277,13 @@ export function computeApplyEmulation(input: { page?: Page zoom: number effectivePageContentSize: (page: Pick) => { width: number; height: number } - isFillBrowserPage: (page: Pick) => boolean + isFocusFillFrame: (page: Pick) => boolean viewportPresetForIndex: (presetIndex: number) => { width: number; height: number } }): void { const start = Date.now() const vp = input.viewportPresetForIndex(input.presetIndex) const nativeScale = screen.getPrimaryDisplay().scaleFactor - const fillScale = input.page && input.isFillBrowserPage(input.page) ? 1 : input.zoom + const fillScale = input.page && input.isFocusFillFrame(input.page) ? 1 : input.zoom const size = input.page ? input.effectivePageContentSize(input.page) : { width: vp.width, height: vp.height } @@ -333,22 +321,19 @@ export function boundSelectedPageId(): string | null { return page?.id ?? null } -export function boundIsFillBrowserPage(page: Pick): boolean { - return computeIsFillBrowserPage({ +export function boundIsFocusFillFrame(page: Pick): boolean { + return computeIsFocusFillFrame({ page, - currentViewMode: uiWorkspaceViewMode, - selectedPageId: boundSelectedPageId, + focusedFrameId: uiFocusedFrameId, }) } export function boundAvailableCanvasViewport(): { width: number; height: number } { return computeAvailableCanvasViewport({ win, - currentViewMode: uiWorkspaceViewMode, currentDevtoolsOpen: uiDevtoolsOpen, currentDevtoolsWidth: uiDevtoolsWidth, toolbarHeight: layoutCache.toolbarHeight, - browserHeaderHeight: BROWSER_HEADER_HEIGHT, leftSidebarWidth: uiLeftSidebarOpen() ? LEFT_SIDEBAR_WIDTH : 0, }) } @@ -356,17 +341,15 @@ export function boundAvailableCanvasViewport(): { width: number; height: number export function boundAvailableCanvasViewportRect(): { x: number; y: number; width: number; height: number } { return computeAvailableCanvasViewportRect({ win, - currentViewMode: uiWorkspaceViewMode, currentDevtoolsOpen: uiDevtoolsOpen, currentDevtoolsWidth: uiDevtoolsWidth, toolbarHeight: layoutCache.toolbarHeight, - browserHeaderHeight: BROWSER_HEADER_HEIGHT, leftSidebarWidth: uiLeftSidebarOpen() ? LEFT_SIDEBAR_WIDTH : 0, }) } -export function boundFillBrowserViewportSize(): { width: number; height: number } { - return computeFillBrowserViewportSize({ +export function boundFocusFillViewportSize(): { width: number; height: number } { + return computeFocusFillViewportSize({ availableCanvasViewport: boundAvailableCanvasViewport, }) } @@ -376,16 +359,14 @@ export function boundEffectivePageContentSize( ): { width: number; height: number } { return computeEffectivePageContentSize({ page, - isFillBrowserPage: boundIsFillBrowserPage, - fillBrowserViewportSize: boundFillBrowserViewportSize, + isFocusFillFrame: boundIsFocusFillFrame, + focusFillViewportSize: boundFocusFillViewportSize, }) } export function boundCanvasOrigin(): { x: number; y: number } { return computeCanvasOrigin({ - currentViewMode: uiWorkspaceViewMode, toolbarHeight: layoutCache.toolbarHeight, - browserHeaderHeight: BROWSER_HEADER_HEIGHT, leftSidebarWidth: uiLeftSidebarOpen() ? LEFT_SIDEBAR_WIDTH : 0, }) } @@ -399,15 +380,14 @@ export function boundScreenBoundsForPage(page: Page) { page, effectivePageContentSize: boundEffectivePageContentSize, availableCanvasViewportRect: boundAvailableCanvasViewportRect, - currentViewMode: uiWorkspaceViewMode, - selectedPageId: boundSelectedPageId, - isFillBrowserPage: boundIsFillBrowserPage, + focusedFrameId: uiFocusedFrameId, + isFocusFillFrame: boundIsFocusFillFrame, zoom, pan, toolbarHeight: layoutCache.toolbarHeight, - browserHeaderHeight: BROWSER_HEADER_HEIGHT, chromePageGap: CHROME_PAGE_GAP, cardBorderWidth: CARD_BORDER_WIDTH, + focusChromeInset: FOCUS_CHROME_INSET, }) } @@ -418,7 +398,7 @@ export function boundApplyEmulation(webContents: WebContents, presetIndex: numbe page, zoom, effectivePageContentSize: boundEffectivePageContentSize, - isFillBrowserPage: boundIsFillBrowserPage, + isFocusFillFrame: boundIsFocusFillFrame, viewportPresetForIndex, }) } diff --git a/src/main/runtime/selection-controller.ts b/src/main/runtime/selection-controller.ts index 44be65fb..d7540d66 100644 --- a/src/main/runtime/selection-controller.ts +++ b/src/main/runtime/selection-controller.ts @@ -1,13 +1,13 @@ import type { CanvasEntityKind, UiState } from '../../shared/types' import { + clearFocus as setUiClearFocus, devtoolsPanelTab as uiDevtoolsPanelTab, + focusedEntity as uiFocusedEntity, getUiState, selectedEntityIds as uiSelectedEntityIds, selectedGroupId as uiSelectedGroupId, - setCanvasMode as setUiCanvasMode, setDevtoolsPanelTab as setUiDevtoolsPanelTab, setSelection as setUiSelection, - workspaceViewMode as uiWorkspaceViewMode, } from '../ui-state' import { findPageById, @@ -21,6 +21,8 @@ import { clearInspectTargets, notifyDevtoolsPanelData, syncInspectionState } fro import { layoutAllViews } from './layout-engine' import { sendInteractiveState } from './overlay-manager' import { savePreferences } from './preferences' +import { animateCameraTo, computeFocusCamera } from './viewport-control' +import { setFocus as setUiFocusState } from '../ui-state' import { drawingEntities } from './drawing-entity-state' import { fileEntities } from './file-entity-state' import { textEntities } from './text-entity-state' @@ -69,8 +71,8 @@ export function resolveEntityKind(entityId: string): CanvasEntityKind { return 'frame' } -function browserSelectionAllowed(nextSelection: SelectionCommand): boolean { - return nextSelection.kind === 'single-entity' && nextSelection.entityKind === 'frame' +function focusAllowed(nextSelection: SelectionCommand): boolean { + return nextSelection.kind === 'single-entity' } function describeSelection(selection: SelectionCommand): Record | undefined { @@ -118,8 +120,27 @@ function commitSelection( const shouldSyncInspection = options?.syncInspection ?? true const shouldNotifyDevtools = options?.notifyDevtools ?? true - if (uiWorkspaceViewMode(currentUi) === 'browser' && !browserSelectionAllowed(nextSelection)) { - setUiCanvasMode() + // Focus follows selection when a single entity (edges excluded) is chosen. + // Multi / none / edge selections exit focus entirely. + const existingFocus = uiFocusedEntity(currentUi) + if (existingFocus) { + if ( + nextSelection.kind === 'single-entity' && + nextSelection.entityKind !== 'edge' && + nextSelection.entityId !== existingFocus.entityId + ) { + // Switch focus to the newly-selected entity, preserving the original priorCamera + setUiFocusState({ + entityId: nextSelection.entityId, + entityKind: nextSelection.entityKind, + priorCamera: existingFocus.priorCamera, + }) + const target = computeFocusCamera(nextSelection.entityId, nextSelection.entityKind) + if (target) animateCameraTo(target) + } else if (!focusAllowed(nextSelection) || (nextSelection.kind === 'single-entity' && nextSelection.entityKind === 'edge')) { + setUiClearFocus() + animateCameraTo(existingFocus.priorCamera) + } } if (selectionEquals(getUiState().selection, nextSelection)) { @@ -138,7 +159,7 @@ function commitSelection( setUiSelection(nextSelection) breadcrumb('selection', nextSelection.kind, describeSelection(nextSelection)) - if (!browserSelectionAllowed(nextSelection) && uiDevtoolsPanelTab() === 'browser-devtools') { + if (!focusAllowed(nextSelection) && uiDevtoolsPanelTab() === 'browser-devtools') { setUiDevtoolsPanelTab('comments') savePreferences() } diff --git a/src/main/runtime/selection-state.ts b/src/main/runtime/selection-state.ts index 13a06920..df0f61e0 100644 --- a/src/main/runtime/selection-state.ts +++ b/src/main/runtime/selection-state.ts @@ -1,18 +1,21 @@ +import type { CanvasEntityKind } from '../../shared/types' import { pages, + pan, setHoverTarget, + zoom, } from './runtime-context' import { activeWorkspaceTabId, workspaceTabs } from './workspace-model' import { + clearFocus as setUiClearFocus, devtoolsPanelTab as uiDevtoolsPanelTab, + focusedEntity as uiFocusedEntity, pendingPlacement as uiPendingPlacement, selectedEntityIds as uiSelectedEntityIds, selectedPageIndex as uiSelectedPageIndex, - setBrowserMode as setUiBrowserMode, - setCanvasMode as setUiCanvasMode, setDevtoolsPanelTab as setUiDevtoolsPanelTab, + setFocus as setUiFocus, setPendingPlacement as setUiPendingPlacement, - workspaceViewMode as uiWorkspaceViewMode, } from '../ui-state' import { selectFrames, @@ -21,6 +24,11 @@ import { } from './selection-controller' import { cancelActive as cancelActiveInteraction } from './interaction-controller' import { layoutAllViews } from './layout-engine' +import { setPan, setZoom, animateCameraTo, cancelCameraAnimation, computeFocusCamera } from './viewport-control' +import { textEntities } from './text-entity-state' +import { fileEntities } from './file-entity-state' +import { drawingEntities } from './drawing-entity-state' +import { workspaceGroups } from './workspace-model' type ArrowDirection = 'left' | 'right' | 'up' | 'down' @@ -41,64 +49,90 @@ export function deselectAll(): void { void selectNone() } +function lookupEntityKind(entityId: string): CanvasEntityKind | null { + if (pages.some((page) => page.id === entityId)) return 'frame' + if (textEntities.some((entity) => entity.id === entityId)) return 'text' + if (fileEntities.some((entity) => entity.id === entityId)) return 'file' + if (drawingEntities.some((entity) => entity.id === entityId)) return 'drawing' + if (workspaceGroups.some((group) => group.id === entityId)) return 'group' + return null +} + /** - * Single gate for all view-mode transitions. Clears transient state - * (interaction, hover, pending placement) so nothing leaks across modes. + * Enter or switch focus to an entity. Stashes the prior camera on first entry + * so exitFocus() can restore it. Edges cannot be focused. */ -function transitionViewMode(target: 'canvas' | 'browser', frameId?: string): boolean { - // 1. Clear transient interaction state +export function setFocus(entityId: string, entityKind?: CanvasEntityKind): boolean { + const resolvedKind = entityKind ?? lookupEntityKind(entityId) + if (!resolvedKind || resolvedKind === 'edge') return false + + // Clear transient state — nothing leaks across focus transitions cancelActiveInteraction('external') setHoverTarget(null) if (uiPendingPlacement()) { setUiPendingPlacement(null) } - // 2. Perform the mode-specific transition - if (target === 'browser') { - const selectedFrameIds = uiSelectedEntityIds() - const selectedIdx = uiSelectedPageIndex(pages.map((p) => p.id)) - const currentSelectedPageId = - selectedIdx !== null && selectedIdx >= 0 && selectedIdx < pages.length - ? pages[selectedIdx].id - : null - const targetId = - frameId ?? currentSelectedPageId ?? selectedFrameIds[0] ?? pages[0]?.id ?? null - if (!targetId) return false - const page = pages.find((p) => p.id === targetId) + const existing = uiFocusedEntity() + // First entry stashes camera; subsequent switches keep the original stash + const priorCamera = existing?.priorCamera ?? { + zoom, + pan: { x: pan.x, y: pan.y }, + } + + // Frame focus selects the page so navigation/keyboard work transparently + if (resolvedKind === 'frame') { + const page = pages.find((p) => p.id === entityId) if (!page) return false - if (currentSelectedPageId !== targetId || selectedFrameIds.length !== 1 || selectedFrameIds[0] !== targetId) { - selectPageById(targetId) + const selectedFrameIds = uiSelectedEntityIds() + if (selectedFrameIds.length !== 1 || selectedFrameIds[0] !== entityId) { + selectPageById(entityId) } - setUiBrowserMode({ frameId: targetId }) - } else { - if (uiWorkspaceViewMode() === 'canvas') return false - setUiCanvasMode() } - // 3. Validate devtools panel tab — browser-devtools only valid in browser mode - if (target === 'canvas' && uiDevtoolsPanelTab() === 'browser-devtools') { - setUiDevtoolsPanelTab('comments') - } + setUiFocus({ entityId, entityKind: resolvedKind, priorCamera }) - // 4. One layout pass at the end - layoutAllViews() + // Animate camera to frame the focused entity. For fill-mode frames, + // geometry overrides the on-screen position regardless of zoom/pan, + // so the animation is just a visual lead-in. + const target = computeFocusCamera(entityId, resolvedKind) + if (target) { + animateCameraTo(target) + } else { + layoutAllViews() + } return true } -export function setBrowserMode(frameId?: string): boolean { - return transitionViewMode('browser', frameId) -} +/** + * Exit focus and restore the camera stashed at entry. + */ +export function clearFocus(): boolean { + const existing = uiFocusedEntity() + if (!existing) return false -export function setCanvasMode(): void { - transitionViewMode('canvas') -} + cancelActiveInteraction('external') + setHoverTarget(null) + + setUiClearFocus() -export function toggleBrowserMode(): boolean { - if (uiWorkspaceViewMode() === 'browser') { - setCanvasMode() - return false + // browser-devtools panel tab no longer valid outside focus + if (uiDevtoolsPanelTab() === 'browser-devtools') { + setUiDevtoolsPanelTab('comments') } - return setBrowserMode() + + // Animate back to the stashed camera. + animateCameraTo(existing.priorCamera) + return true +} + +/** + * Focus the selected entity, or no-op if selection is empty/multi/edge. + */ +export function focusSelectedEntity(): boolean { + const selectedIds = uiSelectedEntityIds() + if (selectedIds.length !== 1) return false + return setFocus(selectedIds[0]) } export function selectAdjacentPage(direction: ArrowDirection): boolean { diff --git a/src/main/runtime/sidebar-builder.ts b/src/main/runtime/sidebar-builder.ts index f03e1e1d..4e4fee5b 100644 --- a/src/main/runtime/sidebar-builder.ts +++ b/src/main/runtime/sidebar-builder.ts @@ -20,10 +20,11 @@ import { import { activeWorkspaceTabId, workspaceGroups } from './workspace-model' import { leftSidebarView } from './view-refs' import { + focusedEntityId as uiFocusedEntityId, leftSidebarOpen as uiLeftSidebarOpen, selectedEntityIds as uiSelectedEntityIds, selectedGroupId as uiSelectedGroupId, - workspaceViewMode as uiWorkspaceViewMode, + sidebarFilter as uiSidebarFilter, } from '../ui-state' import { textEntities } from './text-entity-state' import { fileEntities } from './file-entity-state' @@ -207,16 +208,37 @@ export function buildSidebarItems(): SidebarCanvasItem[] { return sortSidebarItems([...rootLeafItems, ...rootGroupItems]) } +function applySidebarFilter( + items: SidebarCanvasItem[], + filter: ReturnType, +): SidebarCanvasItem[] { + if (filter.kind === 'all') return items + const keep = (item: SidebarCanvasItem): SidebarCanvasItem | null => { + if (item.kind === 'group') { + const filteredChildren = item.children + .map(keep) + .filter((c): c is SidebarCanvasItem => c !== null) + if (filteredChildren.length === 0) return null + return { ...item, children: filteredChildren, entityCount: filteredChildren.length } + } + return item.kind === filter.entityKind ? item : null + } + return items.map(keep).filter((item): item is SidebarCanvasItem => item !== null) +} + export function buildLeftSidebarData(): LeftSidebarData { + const filter = uiSidebarFilter() + const items = applySidebarFilter(buildSidebarItems(), filter) return { width: uiLeftSidebarOpen() ? LEFT_SIDEBAR_WIDTH : 0, selectedEntityIds: uiSelectedEntityIds(), selectedGroupId: uiSelectedGroupId(), tabs: workspaceTabSummaries(), activeTabId: activeWorkspaceTabId, - viewMode: uiWorkspaceViewMode(), + focusedEntityId: uiFocusedEntityId(), + filter, hasFrames: pages.length > 0, - items: buildSidebarItems(), + items, } } diff --git a/src/main/runtime/tool-mode.ts b/src/main/runtime/tool-mode.ts index ae0f59ed..37081964 100644 --- a/src/main/runtime/tool-mode.ts +++ b/src/main/runtime/tool-mode.ts @@ -8,13 +8,13 @@ import { pages } from './runtime-context' import { markDirty } from './layout-dirty' import { annotationMode as uiAnnotationMode, + clearFocus as setUiClearFocus, devtoolsPanelTab as uiDevtoolsPanelTab, inspectEnabled as uiInspectEnabled, pendingPlacement as uiPendingPlacement, selectedPageIndex as uiSelectedPageIndex, clearToolMode as clearUiToolMode, setAnnotationMode as setUiAnnotationMode, - setCanvasMode as setUiCanvasMode, setDevtoolsPanelTab as setUiDevtoolsPanelTab, setInspectEnabled as setUiInspectEnabled, setPendingPlacement as setUiPendingPlacement, @@ -96,7 +96,7 @@ export function startPendingPlacement(input: { customSize: input.customSize ?? false, sourceFrameId: input.sourceFrameId, }) - setUiCanvasMode() + setUiClearFocus() setUiInspectEnabled(false, { hasPages: pages.length > 0 }) setUiAnnotationMode('off', { hasPages: pages.length > 0 }) requestLayout() diff --git a/src/main/runtime/ui-actions.ts b/src/main/runtime/ui-actions.ts index c3926dcb..f45d727b 100644 --- a/src/main/runtime/ui-actions.ts +++ b/src/main/runtime/ui-actions.ts @@ -1,12 +1,12 @@ // Facade: re-exports UI action functions. export { + clearFocus, deselectAll, + focusSelectedEntity, selectPage, - setBrowserMode, - setCanvasMode, + setFocus, setSelectedFrames, - toggleBrowserMode, } from './selection-state' export { @@ -52,7 +52,6 @@ export { export { getSelectedEntityIds, getSelectedGroupId, - selectBrowserTab, selectEntity, selectPageById, setHoverEntity, diff --git a/src/main/runtime/viewport-control.ts b/src/main/runtime/viewport-control.ts index 17440882..c6b29e0e 100644 --- a/src/main/runtime/viewport-control.ts +++ b/src/main/runtime/viewport-control.ts @@ -1,4 +1,8 @@ import { pages, pan, zoom, setPanState, setZoomState, selectedPage } from './runtime-context' +import { textEntities } from './text-entity-state' +import { fileEntities } from './file-entity-state' +import { drawingEntities } from './drawing-entity-state' +import { workspaceGroups } from './workspace-model' import { win } from './view-refs' import { layoutCache } from './layout-cache' import { layoutAllViews } from './layout-engine' @@ -7,14 +11,17 @@ import { boundAvailableCanvasViewportRect as availableCanvasViewportRect, boundCanvasOriginX as canvasOriginX, boundEffectivePageContentSize as effectivePageContentSize, + FOCUS_CHROME_INSET, + pageContentSize, } from './runtime-geometry' import { scheduleWorkspaceAutosave } from './workspace-autosave' import { safeSend } from './safe-send' -import type { WorkspaceBounds } from '../../shared/types' +import type { CanvasEntityKind, WorkspaceBounds } from '../../shared/types' export function setZoom(value: number): void { const nextZoom = Math.max(0.1, Math.min(3.0, value)) if (nextZoom === zoom) return + cancelCameraAnimation() setZoomState(nextZoom) markDirty('canvas', 'toolbar', 'pages') broadcastCanvasZoomToPages() @@ -29,6 +36,7 @@ export function broadcastCanvasZoomToPages(): void { export function setPan(x: number, y: number): void { if (pan.x === x && pan.y === y) return + cancelCameraAnimation() setPanState({ x, y }) markDirty('canvas') scheduleWorkspaceAutosave() @@ -42,6 +50,141 @@ export function requestLayout(): void { }, 16) } +// --------------------------------------------------------------------------- +// Camera animation (used by enter/exit focus for seamless transitions) +// --------------------------------------------------------------------------- + +const CAMERA_ANIMATION_DURATION_MS = 220 +const CAMERA_ANIMATION_FRAME_MS = 16 + +let cameraAnimationToken = 0 + +/** easeInOutQuart — strong ease, matches the "flick" feel of focus transitions. */ +function easeInOutQuart(t: number): number { + return t < 0.5 ? 8 * t * t * t * t : 1 - Math.pow(-2 * t + 2, 4) / 2 +} + +export function cancelCameraAnimation(): void { + cameraAnimationToken++ +} + +/** + * Animate zoom + pan toward target over the given duration. + * Cancels any in-flight animation before starting. + */ +export function animateCameraTo( + target: { zoom: number; pan: { x: number; y: number } }, + duration: number = CAMERA_ANIMATION_DURATION_MS, + onComplete?: () => void, +): void { + const token = ++cameraAnimationToken + const startZoom = zoom + const startPan = { x: pan.x, y: pan.y } + const startTime = Date.now() + + const clampedZoom = Math.max(0.1, Math.min(3.0, target.zoom)) + + const step = () => { + if (token !== cameraAnimationToken) return + const elapsed = Date.now() - startTime + const t = Math.min(1, elapsed / duration) + const eased = easeInOutQuart(t) + + const nextZoom = startZoom + (clampedZoom - startZoom) * eased + const nextPanX = startPan.x + (target.pan.x - startPan.x) * eased + const nextPanY = startPan.y + (target.pan.y - startPan.y) * eased + + setZoomState(nextZoom) + setPanState({ x: Math.round(nextPanX), y: Math.round(nextPanY) }) + markDirty('canvas', 'pages') + broadcastCanvasZoomToPages() + layoutAllViews() + + if (t < 1) { + setTimeout(step, CAMERA_ANIMATION_FRAME_MS) + } else { + scheduleWorkspaceAutosave() + onComplete?.() + } + } + + step() +} + +// --------------------------------------------------------------------------- +// Entity bounds helpers (canvas coords) +// --------------------------------------------------------------------------- + +function entityCanvasBounds( + entityId: string, + entityKind: CanvasEntityKind, +): WorkspaceBounds | null { + if (entityKind === 'frame') { + const page = pages.find((p) => p.id === entityId) + if (!page) return null + const size = pageContentSize(page) + return { x: page.canvasX, y: page.canvasY, width: size.width, height: size.height } + } + if (entityKind === 'text') { + const e = textEntities.find((t) => t.id === entityId) + if (!e) return null + return { x: e.canvasX, y: e.canvasY, width: e.width, height: e.height } + } + if (entityKind === 'file') { + const e = fileEntities.find((f) => f.id === entityId) + if (!e) return null + return { x: e.canvasX, y: e.canvasY, width: e.width, height: e.height } + } + if (entityKind === 'drawing') { + const e = drawingEntities.find((d) => d.id === entityId) + if (!e) return null + return { x: e.canvasX, y: e.canvasY, width: e.width, height: e.height } + } + if (entityKind === 'group') { + const g = workspaceGroups.find((candidate) => candidate.id === entityId) + if (!g) return null + return { x: g.canvasX, y: g.canvasY, width: g.width, height: g.height } + } + return null +} + +/** + * Compute target camera (zoom + pan) that fits the given entity into the + * available viewport with a small padding. Reserves space at the top of the + * viewport for the pinned focus chrome so the entity centers in the region + * below it. + */ +export function computeFocusCamera( + entityId: string, + entityKind: CanvasEntityKind, +): { zoom: number; pan: { x: number; y: number } } | null { + const bounds = entityCanvasBounds(entityId, entityKind) + if (!bounds || !win) return null + + const viewport = availableCanvasViewportRect() + const padding = 64 + + // The pinned focus chrome eats `FOCUS_CHROME_INSET` at the top; content centers in the remainder. + const availW = Math.max(100, viewport.width - padding * 2) + const availH = Math.max(100, viewport.height - padding - FOCUS_CHROME_INSET) + const targetZoom = Math.max( + 0.1, + Math.min(3.0, Math.min(availW / bounds.width, availH / bounds.height)), + ) + + // Center of the content region (below the chrome) measured inside the viewport. + const contentRegionCenterY = FOCUS_CHROME_INSET + (viewport.height - FOCUS_CHROME_INSET) / 2 + + const targetPanX = Math.round( + viewport.x + viewport.width / 2 - canvasOriginX() - (bounds.x + bounds.width / 2) * targetZoom, + ) + const targetPanY = Math.round( + contentRegionCenterY - (bounds.y + bounds.height / 2) * targetZoom, + ) + + return { zoom: targetZoom, pan: { x: targetPanX, y: targetPanY } } +} + export function focusCanvasBounds(bounds: WorkspaceBounds): void { if (!win) return const viewport = availableCanvasViewportRect() diff --git a/src/main/runtime/workspace-autosave.ts b/src/main/runtime/workspace-autosave.ts index 3cd31034..6a27e8fc 100644 --- a/src/main/runtime/workspace-autosave.ts +++ b/src/main/runtime/workspace-autosave.ts @@ -67,7 +67,6 @@ export function saveWorkspaceStore(): void { writeAllTabsAsCanvasFiles(userDataPath, record.id, record.tabs) writeWorkspaceMetaSync(userDataPath, record.id, { activeTabId: record.activeTabId, - viewMode: record.viewMode, tabs: record.tabs.map((t) => ({ id: t.id, name: t.name, diff --git a/src/main/runtime/workspace-persistence.ts b/src/main/runtime/workspace-persistence.ts index fb4c390f..74b49698 100644 --- a/src/main/runtime/workspace-persistence.ts +++ b/src/main/runtime/workspace-persistence.ts @@ -3,7 +3,6 @@ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileS import { join } from 'path' import type { Annotation, - BrowserTabMode, DevtoolsPanelTab, LegacyPersistedWorkspaceStore, PersistedCanvasEntity, @@ -17,7 +16,6 @@ import type { WorkspacePageSnapshot, WorkspaceSnapshot, WorkspaceTabSummary, - WorkspaceViewMode, } from '../../shared/types' import { cloneAnnotationsForPersistence, @@ -95,14 +93,12 @@ export function buildWorkspaceTabSummary( export function buildPersistedWorkspaceRecord(params: { workspaceTabs: PersistedWorkspaceTab[] activeWorkspaceTabId: string - viewMode: WorkspaceViewMode }): PersistedWorkspaceRecord { return { id: DEFAULT_WORKSPACE_ID, name: DEFAULT_WORKSPACE_NAME, updatedAt: new Date().toISOString(), activeTabId: params.activeWorkspaceTabId, - viewMode: params.viewMode, tabs: params.workspaceTabs.map((tab) => ({ ...tab, expanded: tab.expanded ?? true, @@ -220,7 +216,6 @@ export function makeEmptyWorkspaceSnapshot(params: { devtoolsOpen: false, devtoolsPanelTab: params.devtoolsPanelTab, devtoolsWidth: params.devtoolsWidth, - browserTabMode: 'frame', groups: [], edges: [], } @@ -342,7 +337,6 @@ export function buildWorkspaceSnapshot(params: { devtoolsOpen: boolean devtoolsPanelTab: DevtoolsPanelTab devtoolsWidth: number - browserTabMode: BrowserTabMode groups: WorkspaceGroup[] edges: WorkspaceEdge[] }): WorkspaceSnapshot { @@ -361,7 +355,6 @@ export function buildWorkspaceSnapshot(params: { devtoolsOpen: params.devtoolsOpen, devtoolsPanelTab: params.devtoolsPanelTab, devtoolsWidth: params.devtoolsWidth, - browserTabMode: params.browserTabMode, groups: cloneWorkspaceGroupsForSnapshot(params.groups), edges: cloneWorkspaceEdgesForSnapshot(params.edges), } @@ -471,7 +464,6 @@ export function writeWorkspaceMetaSync( workspaceId: string, meta: { activeTabId: string - viewMode?: string tabs: Array<{ id: string; name: string; updatedAt: string; expanded?: boolean }> }, ): void { @@ -485,11 +477,15 @@ export function writeWorkspaceMetaSync( export function readWorkspaceMeta( userDataPath: string, workspaceId: string, -): { activeTabId: string; viewMode?: string; tabs: Array<{ id: string; name: string; updatedAt: string; expanded?: boolean }> } | null { +): { activeTabId: string; tabs: Array<{ id: string; name: string; updatedAt: string; expanded?: boolean }> } | null { const filePath = join(workspaceDir(userDataPath, workspaceId), 'workspace-meta.json') if (!existsSync(filePath)) return null try { - return JSON.parse(readFileSync(filePath, 'utf8')) + const raw = JSON.parse(readFileSync(filePath, 'utf8')) as { + activeTabId: string + tabs: Array<{ id: string; name: string; updatedAt: string; expanded?: boolean }> + } + return { activeTabId: raw.activeTabId, tabs: raw.tabs } } catch { return null } @@ -510,7 +506,6 @@ export function migrateWorkspaceStoreToCanvasFiles( // Write workspace metadata writeWorkspaceMetaSync(userDataPath, workspace.id, { activeTabId: workspace.activeTabId, - viewMode: workspace.viewMode, tabs: workspace.tabs.map((t) => ({ id: t.id, name: t.name, @@ -559,7 +554,6 @@ export function loadWorkspaceFromCanvasFiles( name: DEFAULT_WORKSPACE_NAME, updatedAt: new Date().toISOString(), activeTabId: meta.activeTabId, - viewMode: meta.viewMode as WorkspaceViewMode | undefined, tabs, } } diff --git a/src/main/runtime/workspace-restore.ts b/src/main/runtime/workspace-restore.ts index d5b34d77..c0a14dd7 100644 --- a/src/main/runtime/workspace-restore.ts +++ b/src/main/runtime/workspace-restore.ts @@ -9,9 +9,6 @@ import { setDevtoolsWidth as setUiDevtoolsWidth, setLeftSidebarOpen as setUiLeftSidebarOpen, setDevtoolsPanelTab as setUiDevtoolsPanelTab, - setBrowserMode as setUiBrowserMode, - setCanvasMode as setUiCanvasMode, - selectedEntityId as uiSelectedEntityId, selectedGroupId as uiSelectedGroupId, createDefaultUiState, resetUiState, @@ -84,7 +81,6 @@ import { import { deselectAll, selectPage, - setBrowserMode, setSelectedFrames, } from './selection-state' import { @@ -275,16 +271,7 @@ export function restoreWorkspaceSnapshot(snapshot: WorkspaceSnapshot): boolean { commitSelectGroup(snapshot.selectedGroupId) } - // Restore browser mode — legacy snapshots may have browserTabMode 'responsive' or 'frame', - // both now just mean "browser mode targeting a frame" - if (snapshot.browserTabMode === 'frame' || snapshot.browserTabMode === 'responsive') { - const frameId = snapshot.selectedFrameId ?? uiSelectedEntityId() - if (frameId) { - setUiBrowserMode({ frameId }) - } else { - setUiCanvasMode() - } - } + // Focus state is ephemeral — snapshots never restore focus. if (snapshot.devtoolsOpen && uiSelectedPageIndex(pages.map((p) => p.id)) !== null) { toggleDevTools() @@ -328,14 +315,6 @@ export function restorePersistedWorkspace( ) const activeTab = workspaceTabs.find((tab) => tab.id === activeWorkspaceTabId) if (!activeTab) return false - if (record.viewMode === 'browser') { - const frameId = activeTab.snapshot.selectedFrameId ?? activeTab.snapshot.selectedFrameIds?.[0] - if (frameId) { - setUiBrowserMode({ frameId }) - } - } else { - setUiCanvasMode() - } applyTabState(activeTab) // Startup path: UndoManager not yet created, so this initial hydration // won't generate an undo step. initializeDocObservers() handles the diff --git a/src/main/runtime/workspace-tab-operations.ts b/src/main/runtime/workspace-tab-operations.ts index b0f00b65..d8c2794c 100644 --- a/src/main/runtime/workspace-tab-operations.ts +++ b/src/main/runtime/workspace-tab-operations.ts @@ -14,8 +14,6 @@ import { devtoolsPanelTab as uiDevtoolsPanelTab, devtoolsWidth as uiDevtoolsWidth, setDevtoolsOpen as setUiDevtoolsOpen, - setBrowserMode as setUiBrowserMode, - setCanvasMode as setUiCanvasMode, } from '../ui-state' import { withSuppressedDocSync } from './workspace-doc' import { @@ -64,10 +62,7 @@ export function applyTabState(tab: PersistedWorkspaceTab): void { ...getUiState(), selection: { kind: 'none' }, toolMode: 'select', - viewMode: - tab.snapshot.browserTabMode === 'frame' && tab.snapshot.selectedFrameId - ? { kind: 'browser', frameId: tab.snapshot.selectedFrameId } - : { kind: 'canvas' }, + focus: null, devtools: { ...getUiState().devtools, open: false, diff --git a/src/main/runtime/workspace-tabs.ts b/src/main/runtime/workspace-tabs.ts index e15a5254..bea7acc4 100644 --- a/src/main/runtime/workspace-tabs.ts +++ b/src/main/runtime/workspace-tabs.ts @@ -25,7 +25,6 @@ import { selectedEntityIds as uiSelectedEntityIds, selectedGroupId as uiSelectedGroupId, devtoolsOpen as uiDevtoolsOpen, - workspaceViewMode as uiWorkspaceViewMode, } from '../ui-state' import { buildWorkspaceTabSummary, @@ -88,7 +87,6 @@ export function workspaceSnapshot(): WorkspaceSnapshot { devtoolsOpen: uiDevtoolsOpen(), devtoolsPanelTab: uiDevtoolsPanelTab(), devtoolsWidth: uiDevtoolsWidth(), - browserTabMode: 'frame', groups: workspaceGroups, edges: workspaceEdges, }) @@ -191,7 +189,6 @@ export function buildPersistedWorkspaceRecord(): PersistedWorkspaceRecord { return createPersistedWorkspaceRecord({ workspaceTabs, activeWorkspaceTabId: activeWorkspaceTabId ?? workspaceTabs[0]!.id, - viewMode: uiWorkspaceViewMode(), }) } diff --git a/src/main/sentry-context.ts b/src/main/sentry-context.ts index 2daa0534..8d18308f 100644 --- a/src/main/sentry-context.ts +++ b/src/main/sentry-context.ts @@ -43,7 +43,7 @@ export type BreadcrumbCategory = | 'page' | 'navigation' | 'undo' - | 'view-mode' + | 'focus' | 'mcp' | 'onboarding' diff --git a/src/main/sentry.ts b/src/main/sentry.ts index da52217b..25b14cab 100644 --- a/src/main/sentry.ts +++ b/src/main/sentry.ts @@ -10,7 +10,7 @@ import { pages } from './runtime/page-runtime' import { textEntities } from './runtime/text-entity-state' import { fileEntities } from './runtime/file-entity-state' import { drawingEntities } from './runtime/drawing-entity-state' -import { workspaceViewMode } from './ui-state' +import { isFocused } from './ui-state' /** * Initialize Sentry for the Electron main process. @@ -68,7 +68,7 @@ function readAppStateTags(): Record { drawingEntities.length + workspaceGroups.length, ), - view_mode: workspaceViewMode(), + focused: isFocused() ? 'true' : 'false', } } diff --git a/src/main/ui-state.ts b/src/main/ui-state.ts index 512e4e14..355aa139 100644 --- a/src/main/ui-state.ts +++ b/src/main/ui-state.ts @@ -3,11 +3,11 @@ import type { CanvasEntityKind, CanvasSelectableTarget, DevtoolsPanelTab, + SidebarFilter, + UiFocus, UiPendingPlacement, UiSelection, UiState, - UiViewMode, - WorkspaceViewMode, } from '../shared/types' import { isAnnotationModeEnabled, isCanvasEntityKindEnabled } from '../shared/featureFlags' import { markDirty } from './runtime/layout-dirty' @@ -22,10 +22,6 @@ type SelectionInput = entityKindsById: Partial> } -type BrowserTarget = { - frameId: string -} - const DEFAULT_DEVTOOLS_WIDTH = 400 let uiState: UiState = createDefaultUiState() @@ -52,7 +48,8 @@ export function createDefaultUiState(): UiState { return { selection: { kind: 'none' }, toolMode: 'select', - viewMode: { kind: 'canvas' }, + focus: null, + sidebarFilter: { kind: 'all' }, leftSidebarOpen: true, devtools: { open: false, @@ -115,8 +112,8 @@ export function setSelection(input: SelectionInput): UiState { entityKindsById: { ...nextInput.entityKindsById }, } } - if (uiState.viewMode.kind === 'browser') { - uiState.viewMode = { kind: 'canvas' } + if (uiState.focus !== null) { + uiState.focus = null } markDirty('canvas', 'sidebar', 'toolbar', 'floating-ui', 'devtools') return getUiState() @@ -159,25 +156,33 @@ export function setAnnotationMode( return getUiState() } -export function setCanvasMode(): UiState { - if (uiState.viewMode.kind !== 'canvas') { - breadcrumb('view-mode', 'canvas') +export function setFocus(focus: UiFocus): UiState { + const prev = uiState.focus + if (!prev) { + breadcrumb('focus', `enter:${focus.entityKind}`) + } else if (prev.entityId !== focus.entityId) { + breadcrumb('focus', `switch:${focus.entityKind}`) } - uiState.viewMode = { kind: 'canvas' } + uiState.selection = { kind: 'single-entity', entityId: focus.entityId, entityKind: focus.entityKind } + uiState.focus = focus markDirty('canvas', 'sidebar', 'toolbar', 'bounds', 'pages') return getUiState() } -export function setBrowserMode(target: BrowserTarget): UiState { - if (uiState.viewMode.kind !== 'browser' || uiState.viewMode.frameId !== target.frameId) { - breadcrumb('view-mode', 'browser') - } - uiState.selection = { kind: 'single-entity', entityId: target.frameId, entityKind: 'frame' } - uiState.viewMode = { kind: 'browser', frameId: target.frameId } +export function clearFocus(): UiState { + if (uiState.focus === null) return getUiState() + breadcrumb('focus', 'exit') + uiState.focus = null markDirty('canvas', 'sidebar', 'toolbar', 'bounds', 'pages') return getUiState() } +export function setSidebarFilter(filter: SidebarFilter): UiState { + uiState.sidebarFilter = filter + markDirty('sidebar') + return getUiState() +} + export function updateSelectionForRemovedEntity(entityId: string): UiState { if (uiState.selection.kind === 'single-entity') { if (uiState.selection.entityId === entityId) { @@ -205,8 +210,8 @@ export function updateSelectionForRemovedEntity(entityId: string): UiState { : { kind: 'none' } } - if (uiState.viewMode.kind === 'browser' && uiState.viewMode.frameId === entityId) { - uiState.viewMode = { kind: 'canvas' } + if (uiState.focus?.entityId === entityId) { + uiState.focus = null } return getUiState() } @@ -260,18 +265,27 @@ export function setDevtoolsWidth(width: number): UiState { return getUiState() } -export function workspaceViewMode(ui: UiState = uiState): WorkspaceViewMode { - return ui.viewMode.kind === 'canvas' ? 'canvas' : 'browser' +export function focusedEntity(ui: UiState = uiState): UiFocus | null { + return ui.focus } -export function activeBrowserFrameId(ui: UiState = uiState): string | null { - if (ui.viewMode.kind === 'browser') return ui.viewMode.frameId - return selectedEntityId(ui) +export function focusedEntityId(ui: UiState = uiState): string | null { + return ui.focus?.entityId ?? null } -export function activeBrowserTabId(ui: UiState = uiState): string | null { - if (ui.viewMode.kind === 'canvas') return null - return ui.viewMode.frameId +/** True when any entity is focused (replaces the old browser-mode check). */ +export function isFocused(ui: UiState = uiState): boolean { + return ui.focus !== null +} + +/** Frame id being focused, or null if focus is on a non-frame or none. */ +export function focusedFrameId(ui: UiState = uiState): string | null { + if (ui.focus?.entityKind === 'frame') return ui.focus.entityId + return null +} + +export function sidebarFilter(ui: UiState = uiState): SidebarFilter { + return ui.sidebarFilter } export function selectedEntityIds(ui: UiState = uiState): string[] { @@ -308,7 +322,7 @@ export function selectedCanvasTargets(ui: UiState = uiState): CanvasSelectableTa export function selectedEntityId(ui: UiState = uiState): string | null { if (ui.selection.kind === 'single-entity') return ui.selection.entityId if (ui.selection.kind === 'multi-entity') return ui.selection.entityIds[0] ?? null - if (ui.viewMode.kind === 'browser') return ui.viewMode.frameId + if (ui.focus) return ui.focus.entityId return null } @@ -386,7 +400,20 @@ function cloneUiState(input: UiState): UiState { } : { ...input.selection }, toolMode: input.toolMode, - viewMode: { ...input.viewMode }, + focus: input.focus + ? { + entityId: input.focus.entityId, + entityKind: input.focus.entityKind, + priorCamera: { + zoom: input.focus.priorCamera.zoom, + pan: { ...input.focus.priorCamera.pan }, + }, + } + : null, + sidebarFilter: + input.sidebarFilter.kind === 'by-kind' + ? { kind: 'by-kind', entityKind: input.sidebarFilter.entityKind } + : { kind: 'all' }, leftSidebarOpen: input.leftSidebarOpen, devtools: { ...input.devtools }, overlays: { ...input.overlays }, diff --git a/src/preload/canvas-bg.ts b/src/preload/canvas-bg.ts index 22ef81ca..48bac43e 100644 --- a/src/preload/canvas-bg.ts +++ b/src/preload/canvas-bg.ts @@ -78,14 +78,14 @@ const api: CanvasBgElectronAPI = { ipcRenderer.send('canvas-click-at', { screenX, screenY }), clearAnnotateHover: () => ipcRenderer.send('canvas-clear-annotate-hover'), selectFrame: (frameId) => ipcRenderer.send('canvas-select-frame', { frameId }), - selectBrowserTab: (frameId) => ipcRenderer.send('canvas-select-browser-tab', { frameId }), - addBrowserFrame: (presetIndex) => ipcRenderer.send('add-browser-frame', presetIndex), + setFocus: (entityId, entityKind) => ipcRenderer.send('canvas-set-focus', { entityId, entityKind }), + clearFocus: () => ipcRenderer.send('canvas-clear-focus'), navigateFrame: (frameId, url) => ipcRenderer.send('canvas-navigate-frame', { frameId, url }), goBackFrame: (frameId) => ipcRenderer.send('canvas-back-frame', { frameId }), goForwardFrame: (frameId) => ipcRenderer.send('canvas-forward-frame', { frameId }), reloadFrame: (frameId) => ipcRenderer.send('canvas-reload-frame', { frameId }), setFrameCustom: (frameId) => ipcRenderer.send('canvas-set-frame-custom', { frameId }), - setBrowserSizeMode: (frameId, mode) => ipcRenderer.send('canvas-set-browser-size-mode', { frameId, mode }), + setFrameSizeMode: (frameId, mode) => ipcRenderer.send('canvas-set-frame-size-mode', { frameId, mode }), updateFrameBounds: (frameId, patch) => ipcRenderer.send('canvas-update-frame-bounds', { frameId, patch }), placePendingEntity: (canvasX, canvasY) => ipcRenderer.send('canvas-place-pending-entity', { canvasX, canvasY }), diff --git a/src/preload/left-sidebar.ts b/src/preload/left-sidebar.ts index 79b29c03..cb440b66 100644 --- a/src/preload/left-sidebar.ts +++ b/src/preload/left-sidebar.ts @@ -3,6 +3,7 @@ import type { CanvasEntityKind, LeftSidebarData, LeftSidebarElectronAPI, + SidebarFilter, ThemeData, } from '../shared/types' @@ -32,7 +33,11 @@ const api: LeftSidebarElectronAPI = { setTabExpanded: (tabId, expanded) => ipcRenderer.send('canvas-set-tab-expanded', { tabId, expanded }), setTextEditing: (active) => ipcRenderer.send('canvas-set-text-editing', { active }), - toggleBrowserMode: () => ipcRenderer.send('toolbar-toggle-browser-mode'), + setFocus: (entityId: string, entityKind: CanvasEntityKind) => + ipcRenderer.send('canvas-set-focus', { entityId, entityKind }), + clearFocus: () => ipcRenderer.send('canvas-clear-focus'), + setSidebarFilter: (filter: SidebarFilter) => + ipcRenderer.send('left-sidebar-set-filter', { filter }), getInitialData: () => ipcRenderer.invoke('get-left-sidebar-bootstrap'), onThemeChanged: (callback) => { const handler = (_event: Electron.IpcRendererEvent, data: ThemeData) => callback(data) diff --git a/src/preload/toolbar.ts b/src/preload/toolbar.ts index c2514717..ddee9de6 100644 --- a/src/preload/toolbar.ts +++ b/src/preload/toolbar.ts @@ -24,7 +24,6 @@ const api: ToolbarElectronAPI = { toggleAnnotateMode: () => ipcRenderer.send('toolbar-toggle-annotate'), toggleDrawMode: () => ipcRenderer.send('toolbar-toggle-draw'), toggleRegionSelectMode: () => ipcRenderer.send('toolbar-toggle-region-select'), - toggleBrowserMode: () => ipcRenderer.send('toolbar-toggle-browser-mode'), dropdownOpen: () => ipcRenderer.send('toolbar-dropdown-open'), dropdownClose: () => ipcRenderer.send('toolbar-dropdown-close'), setTextEditing: (active) => ipcRenderer.send('canvas-set-text-editing', { active }), diff --git a/src/renderer/above-view/FloatingUiLayer.tsx b/src/renderer/above-view/FloatingUiLayer.tsx index 25d6ad4b..f4b8dd48 100644 --- a/src/renderer/above-view/FloatingUiLayer.tsx +++ b/src/renderer/above-view/FloatingUiLayer.tsx @@ -53,7 +53,7 @@ export function FloatingUiLayer({ const [delayedTextMenuId, setDelayedTextMenuId] = useState(null) const shouldQueueTextMenu = - layoutData.viewMode === 'canvas' && + layoutData.focusedEntityId === null && layoutData.interaction.kind === 'idle' && selectedTextEntity !== null @@ -116,7 +116,7 @@ export function FloatingUiLayer({ /** Predicate for whether the floating UI should be visible (drives overlayActive). */ export function hasFloatingMenu(layoutData: LayoutUpdateData): boolean { - if (layoutData.viewMode !== 'canvas') return false + if (layoutData.focusedEntityId !== null) return false if (layoutData.selectedEntityIds.length !== 1) return false const [id] = layoutData.selectedEntityIds return layoutData.entities.some( diff --git a/src/renderer/above-view/useAnnotationDrawingGestures.ts b/src/renderer/above-view/useAnnotationDrawingGestures.ts index f6c2832e..5c188fc9 100644 --- a/src/renderer/above-view/useAnnotationDrawingGestures.ts +++ b/src/renderer/above-view/useAnnotationDrawingGestures.ts @@ -70,7 +70,7 @@ export function useAnnotationDrawingGestures({ if (event.pointerType === 'mouse' && event.button !== 0) return if (event.clientY < layoutData.canvasOrigin.y) return if ( - layoutData.viewMode === 'canvas' && + layoutData.focusedEntityId === null && event.clientX < layoutData.canvasOrigin.x ) { return @@ -125,7 +125,7 @@ export function useAnnotationDrawingGestures({ layoutData.canvasOrigin.x, layoutData.canvasOrigin.y, layoutData.selectedEntityIds, - layoutData.viewMode, + layoutData.focusedEntityId, layoutRef, pendingAnnotation, setDrawingSession, diff --git a/src/renderer/canvas-bg/App.tsx b/src/renderer/canvas-bg/App.tsx index c84a3d42..bb907213 100644 --- a/src/renderer/canvas-bg/App.tsx +++ b/src/renderer/canvas-bg/App.tsx @@ -15,12 +15,12 @@ import { DRAW_CURSOR } from './canvasBgConstants' import { buildSelectedFrameIdSet } from './canvasBgSelectors' import { EntityHoverProvider } from './EntityHoverProvider' import { CanvasDebugBadge, CanvasGridSurface, PlacementPreviewLayer, DragCopyPreviewLayer, CanvasEntityViewportLayer } from './CanvasGridSurface' -import { BrowserTabBar } from './BrowserTabBar' import { CanvasSelectionOutlineLayer, GroupSelectionOverlayLayer } from './CanvasSelectionLayers' import { DeviceShellLayer } from './DeviceShellLayer' import { FrameBorderLayer } from './FrameBorderLayer' import { SvgDeviceShellLayer } from './SvgDeviceShellLayer' import { FrameChromeLayer } from './FrameChromeLayer' +import { FocusChromeLayer } from './FocusChromeLayer' import { TextBlockLayer } from './TextBlockLayer' import { FileBlockLayer, type FileJsonModeMap } from './FileBlockLayer' import { FileChromeLayer } from './FileChromeLayer' @@ -103,12 +103,24 @@ export default function App({ () => layoutData.entities.filter((e): e is CanvasSceneDrawingEntity => e.kind === 'drawing'), [layoutData.entities], ) + const focusedFrameId = + layoutData.focusedEntityId && frameEntities.some((f) => f.id === layoutData.focusedEntityId) + ? layoutData.focusedEntityId + : null + // When focused on a frame with 'fill' sizeMode, only the focused frame renders + // (mirrors the old browser-mode behavior where siblings were hidden). + const focusedFrameSizeMode = useMemo( + () => focusedFrameId ? frameEntities.find((f) => f.id === focusedFrameId)?.sizeMode ?? null : null, + [focusedFrameId, frameEntities], + ) + const isFocusFilling = focusedFrameSizeMode === 'fill' const borderFrames = useMemo( - () => layoutData.viewMode === 'browser' - ? frameEntities.filter((f) => f.id === layoutData.activeBrowserTabId) + () => isFocusFilling + ? frameEntities.filter((f) => f.id === focusedFrameId) : frameEntities, - [frameEntities, layoutData.viewMode, layoutData.activeBrowserTabId], + [frameEntities, isFocusFilling, focusedFrameId], ) + const isFocused = layoutData.focusedEntityId !== null const selectedEntityIdSet = useMemo( () => buildSelectedFrameIdSet(layoutData.selectedEntityIds), [layoutData.selectedEntityIds], @@ -119,7 +131,7 @@ export default function App({ }, [layoutData.groups, layoutData.selectedGroupId]) const [delayedSelectedGroupMenuId, setDelayedSelectedGroupMenuId] = useState(null) const shouldQueueSelectedGroupMenu = - layoutData.viewMode === 'canvas' && + !isFocused && layoutData.interaction.kind === 'idle' && selectedGroupEntity !== null useEffect(() => { @@ -170,7 +182,7 @@ export default function App({ ) : null} - {layoutData.viewMode === 'canvas' && (layoutData.groups?.length ?? 0) > 0 ? ( + {!isFocused && (layoutData.groups?.length ?? 0) > 0 ? ( ) : null} - {layoutData.viewMode === 'canvas' ? ( + {!isFocused ? ( ) : null} - {layoutData.viewMode === 'browser' ? ( - ) : null}
- {layoutData.viewMode === 'canvas' && !captureMode ? ( + {!isFocused && !captureMode ? ( ) : null} - {layoutData.viewMode === 'canvas' && layoutData.presenceCursors.length > 0 ? ( + {!isFocused && layoutData.presenceCursors.length > 0 ? ( !f.useSvgDeviceShell)} - fileEntities={layoutData.viewMode === 'browser' ? [] : fileEntities} + fileEntities={isFocusFilling ? [] : fileEntities} isDark={isDark} /> - {layoutData.viewMode === 'canvas' ? ( - - ) : null} + f.id !== layoutData.focusedEntityId)} + dragEnabled={frameInteractionsEnabled} + isDark={isDark} + selectedFrameId={layoutData.selectedEntityIds.length === 1 ? layoutData.selectedEntityIds[0] : null} + hoveredFrameId={hoveredEntityId} + isIdle={layoutData.interaction.kind === 'idle'} + handleChromeMouseDown={handleChromeMouseDown} + onHoverFrame={handleHoverEntity} + onNavigateFrame={api.navigateFrame} + onGoBackFrame={api.goBackFrame} + onGoForwardFrame={api.goForwardFrame} + onReloadFrame={api.reloadFrame} + onShowContextMenu={api.showFrameContextMenu} + onSetFocus={(frameId) => api.setFocus(frameId, 'frame')} + /> - {layoutData.viewMode === 'canvas' ? ( - e.id !== layoutData.focusedEntityId)} + isDark={isDark} + selectedEntityId={layoutData.selectedEntityIds.length === 1 ? layoutData.selectedEntityIds[0] : null} + hoveredEntityId={hoveredEntityId} + isIdle={layoutData.interaction.kind === 'idle'} + callbacks={{ + onHoverEntity: handleHoverEntity, + onStartDragEntity: api.startDragEntity, + onDragEntity: api.dragEntity, + onEndDragEntity: api.endDragEntity, + onRenameFileEntity: api.renameFileEntity, + onWriteFile: api.writeNoteFile, + onSetFocus: (entityId) => api.setFocus(entityId, 'file'), + onJsonModeChange: (entityId, jsonMode) => { + setFileJsonModeMap((prev) => { + const next = new Map(prev) + next.set(entityId, jsonMode) + return next + }) + }, + }} + /> + + {isFocused ? ( + { - setFileJsonModeMap((prev) => { - const next = new Map(prev) - next.set(entityId, jsonMode) - return next - }) + onClearFocus: api.clearFocus, + onNavigateFrame: api.navigateFrame, + onGoBackFrame: api.goBackFrame, + onGoForwardFrame: api.goForwardFrame, + onReloadFrame: api.reloadFrame, + onShowFrameContextMenu: api.showFrameContextMenu, + onToggleFrameSizeMode: (frameId, currentMode) => { + const next = currentMode === 'fill' ? 'fit' : 'fill' + api.setFrameSizeMode(frameId, next) }, + onRenameFileEntity: api.renameFileEntity, }} /> ) : null} - {layoutData.viewMode === 'canvas' && !captureMode ? ( + {!isFocusFilling && !captureMode ? ( selectedEntityIdSet.has(e.id) || e.id === hoveredEntityId || marqueePreviewIds?.has(e.id))} allTextEntities={textEntities} @@ -309,6 +338,7 @@ export default function App({ selectedIdSet={selectedEntityIdSet} marqueePreviewIds={marqueePreviewIds} hoveredEntityId={hoveredEntityId} + focusedEntityId={layoutData.focusedEntityId} onFrameMouseDown={handleChromeMouseDown} onResizeFrame={(id, patch) => api.updateFrameBounds(id, patch)} onResizeTextEntity={(id, patch) => api.updateTextEntity(id, patch)} @@ -357,7 +387,7 @@ export default function App({ {/* Selected frame menu now renders in the floating-ui view (above frames) */} - {layoutData.viewMode === 'canvas' ? ( + {!isFocusFilling ? ( ) : null} - {layoutData.viewMode === 'canvas' ? ( + {!isFocusFilling ? ( void - onDeleteFrame: (frameId: string) => void - onRenameFrame: (frameId: string, name: string) => void - onSelectBrowserTab: (frameId: string) => void -}) { - return ( -
-
-
-
- {browserTabs.map((frame) => ( -
- onSelectBrowserTab(frame.id)} - onRename={(name) => onRenameFrame(frame.id, name)} - onDelete={() => onDeleteFrame(frame.id)} - /> -
- ))} -
-
- -
-
- ) -} diff --git a/src/renderer/canvas-bg/CanvasSelectionLayers.tsx b/src/renderer/canvas-bg/CanvasSelectionLayers.tsx index 721f9f59..a59a926f 100644 --- a/src/renderer/canvas-bg/CanvasSelectionLayers.tsx +++ b/src/renderer/canvas-bg/CanvasSelectionLayers.tsx @@ -1,4 +1,4 @@ -import { useContext, useMemo } from 'react' +import { useContext, useMemo, useState } from 'react' import type { CanvasSceneDrawingEntity, CanvasSceneFileEntity, @@ -36,15 +36,19 @@ function FrameSelectionOverlay({ interactionsEnabled, isDark, showResizeHandles, + hiddenUntilHover, onResize, }: { frame: CanvasSceneFrameEntity interactionsEnabled: boolean isDark: boolean showResizeHandles: boolean + /** When true, outline is opacity 0 and fades in on hover (used for focused entities). */ + hiddenUntilHover?: boolean onResize: (id: string, patch: EntityResizePatch) => void }) { const zoom = frame.width > 0 ? frame.screenWidth / frame.width : 1 + const [hoverRevealed, setHoverRevealed] = useState(false) return (
setHoverRevealed(true) : undefined} + onMouseLeave={hiddenUntilHover ? () => setHoverRevealed(false) : undefined} > {interactionsEnabled && showResizeHandles ? ( marqueePreviewIds: Set | null - /** Main-authoritative hover id from layoutData.hover. Used as a fallback - * when SelectableEntityShell's mouseenter/leave can't fire (e.g. when - * above-view is covering the canvas because saved drawings are visible). */ hoveredEntityId: string | null + /** When set, the entity with this id renders its outline with opacity 0 + * (revealed on hover) so focused frames don't have a persistent border. */ + focusedEntityId: string | null onFrameMouseDown: (frameId: string, event: React.MouseEvent) => void onResizeFrame: (id: string, patch: EntityResizePatch) => void onResizeTextEntity: (id: string, patch: EntityResizePatch) => void @@ -340,6 +349,7 @@ export function CanvasSelectionOutlineLayer({ ) : null} {frames.map((frame) => { const isSelected = selectedIdSet.has(frame.id) + const isFocusedFrame = focusedEntityId === frame.id if (isSelected) { return ( ) diff --git a/src/renderer/canvas-bg/DeviceShellLayer.tsx b/src/renderer/canvas-bg/DeviceShellLayer.tsx index 73ee1aba..07472a6b 100644 --- a/src/renderer/canvas-bg/DeviceShellLayer.tsx +++ b/src/renderer/canvas-bg/DeviceShellLayer.tsx @@ -32,7 +32,7 @@ export function DeviceShellLayer({ fileEntities?: CanvasSceneFileEntity[] isDark: boolean }) { - const framedFrames: DeviceShellItem[] = frames.filter((f) => f.showDeviceFrame && f.browserSizeMode !== 'fill') + const framedFrames: DeviceShellItem[] = frames.filter((f) => f.showDeviceFrame && f.sizeMode !== 'fill') const framedFiles: DeviceShellItem[] = (fileEntities ?? []).filter((f) => f.showDeviceFrame) if (!framedFrames.length && !framedFiles.length) return null diff --git a/src/renderer/canvas-bg/EntityChromeHeader.tsx b/src/renderer/canvas-bg/EntityChromeHeader.tsx index 1cd83162..5dcccdf1 100644 --- a/src/renderer/canvas-bg/EntityChromeHeader.tsx +++ b/src/renderer/canvas-bg/EntityChromeHeader.tsx @@ -19,17 +19,34 @@ interface EntityChromeContext { const ChromeCtx = createContext({ isDark: false, - showControls: false, + showControls: true, }) // --------------------------------------------------------------------------- -// EntityChrome.Root +// EntityChrome.Root — two positioning modes: +// • inline: anchored at a canvas position, floats above its frame via translateY(-100%). +// No background; controls hidden until hover/active. +// • pinned: fixed at top of viewport for a focused entity. Opaque background, +// height matches toolbar (44px), controls always visible. // --------------------------------------------------------------------------- +export type EntityChromePositioning = + | { + mode: 'inline' + screenX: number + screenY: number + screenWidth: number + } + | { + mode: 'pinned' + topY: number + leftX: number + width: number + height: number + } + function Root({ - screenX, - screenY, - screenWidth, + positioning, isDark, dragEnabled = true, isActive, @@ -38,9 +55,7 @@ function Root({ onMouseLeave, children, }: { - screenX: number - screenY: number - screenWidth: number + positioning: EntityChromePositioning isDark: boolean dragEnabled?: boolean isActive: boolean @@ -50,12 +65,18 @@ function Root({ children: ReactNode }) { const [isHovered, setIsHovered] = useState(false) - const [showControls, setShowControls] = useState(isActive) + const [showControls, setShowControls] = useState(isActive || positioning.mode === 'pinned') const hideTimerRef = useRef | null>(null) + // Pinned chrome always shows its controls. Inline chrome fades on hover/active. + const controlsAlwaysVisible = positioning.mode === 'pinned' const isHoveredOrActive = isActive || isHovered useEffect(() => { + if (controlsAlwaysVisible) { + setShowControls(true) + return + } if (isHoveredOrActive) { if (hideTimerRef.current) { clearTimeout(hideTimerRef.current); hideTimerRef.current = null } setShowControls(true) @@ -63,7 +84,37 @@ function Root({ hideTimerRef.current = setTimeout(() => setShowControls(false), 150) } return () => { if (hideTimerRef.current) clearTimeout(hideTimerRef.current) } - }, [isHoveredOrActive]) + }, [isHoveredOrActive, controlsAlwaysVisible]) + + if (positioning.mode === 'pinned') { + return ( + +
+
+ {children} +
+
+
+ ) + } return ( @@ -73,9 +124,9 @@ function Root({ onMouseEnter={() => { setIsHovered(true); onMouseEnter?.() }} onMouseLeave={() => { setIsHovered(false); onMouseLeave?.() }} style={{ - left: screenX, - top: screenY, - width: screenWidth, + left: positioning.screenX, + top: positioning.screenY, + width: positioning.screenWidth, transform: 'translateY(-100%)', pointerEvents: dragEnabled ? 'auto' : 'none', }} diff --git a/src/renderer/canvas-bg/FileChromeLayer.tsx b/src/renderer/canvas-bg/FileChromeLayer.tsx index af56a592..87479bb9 100644 --- a/src/renderer/canvas-bg/FileChromeLayer.tsx +++ b/src/renderer/canvas-bg/FileChromeLayer.tsx @@ -1,6 +1,6 @@ import { memo, useCallback, useState } from 'react' import { Popover } from '@base-ui/react/popover' -import { EllipsisVertical, FileText } from 'lucide-react' +import { EllipsisVertical, FileText, Maximize2 } from 'lucide-react' import type { CanvasSceneFileEntity } from '../../shared/types' import { MARKDOWN_EXTENSIONS, WIREFRAME_EXTENSIONS } from './entityConstants' import { EntityChrome } from './EntityChromeHeader' @@ -17,6 +17,7 @@ interface FileChromeCallbacks { onWriteFile: (path: string, content: string) => Promise /** Notify the card content that jsonMode changed. */ onJsonModeChange: (entityId: string, jsonMode: boolean) => void + onSetFocus: (entityId: string) => void } export function FileChromeLayer({ @@ -150,9 +151,12 @@ const FileChromeItem = memo(function FileChromeItem({ return ( - {isWireframe && ( - + + callbacks.onSetFocus(entity.id)}> + + + {isWireframe && ( { if (open) readTheme() }}> } @@ -240,8 +247,8 @@ const FileChromeItem = memo(function FileChromeItem({ - - )} + )} + ) }) diff --git a/src/renderer/canvas-bg/FocusChromeLayer.tsx b/src/renderer/canvas-bg/FocusChromeLayer.tsx new file mode 100644 index 00000000..cecaffb8 --- /dev/null +++ b/src/renderer/canvas-bg/FocusChromeLayer.tsx @@ -0,0 +1,266 @@ +import { useEffect, useState } from 'react' +import { ChevronLeft, ChevronRight, EllipsisVertical, Maximize2, Minimize2, RotateCw, Search } from 'lucide-react' +import type { + CanvasSceneEntity, + CanvasSceneFileEntity, + CanvasSceneFrameEntity, + LayoutUpdateData, +} from '../../shared/types' +import { normalizeUserUrl } from '../../shared/url' +import { EntityChrome } from './EntityChromeHeader' +import { InlineEditLabel } from '../shared/InlineEditLabel' + +// Matches src/main/runtime/runtime-constants.ts. Kept here (renderer-side) so +// the chrome doesn't need to depend on main-process constants. +const FOCUS_CHROME_TOP_OFFSET = 8 +const FOCUS_CHROME_HEIGHT = 44 + +interface FocusChromeCallbacks { + onClearFocus: () => void + onNavigateFrame: (frameId: string, url: string) => void + onGoBackFrame: (frameId: string) => void + onGoForwardFrame: (frameId: string) => void + onReloadFrame: (frameId: string) => void + onShowFrameContextMenu: (frameId: string) => void + onToggleFrameSizeMode: (frameId: string, currentMode: 'fill' | 'fit' | 'device') => void + onRenameFileEntity: (entityId: string, newName: string) => void +} + +export function FocusChromeLayer({ + layoutData, + isDark, + callbacks, +}: { + layoutData: LayoutUpdateData + isDark: boolean + callbacks: FocusChromeCallbacks +}) { + const focusedId = layoutData.focusedEntityId + if (!focusedId) return null + + const entity = layoutData.entities.find((e): e is CanvasSceneEntity => e.id === focusedId) + if (!entity) return null + + const leftX = layoutData.canvasOrigin.x + FOCUS_CHROME_TOP_OFFSET + const topY = layoutData.canvasOrigin.y + FOCUS_CHROME_TOP_OFFSET + // Width matches the focused entity's visible width (for fill frames this is the full viewport width; + // for fit frames it matches the zoomed frame). Clamp to a sane minimum and respect the left inset. + const rawWidth = Math.max(280, entity.screenWidth - FOCUS_CHROME_TOP_OFFSET * 2) + const width = Math.min( + rawWidth, + Math.max(280, layoutData.focusFillViewport.width - FOCUS_CHROME_TOP_OFFSET * 2), + ) + const pinnedLeftX = Math.max( + layoutData.canvasOrigin.x + FOCUS_CHROME_TOP_OFFSET, + entity.screenX + (entity.screenWidth - width) / 2, + ) + + if (entity.kind === 'frame') { + return ( + + ) + } + + if (entity.kind === 'file') { + return ( + + ) + } + + // Generic fallback: a minimal bar with the entity label + exit button + const label = 'label' in entity ? (entity as { label?: string }).label ?? 'Focused' : 'Focused' + return ( + + {label} + + + + + + + ) +} + +function FocusFrameChrome({ + frame, + leftX, + topY, + width, + isDark, + onClearFocus, + onNavigateFrame, + onGoBackFrame, + onGoForwardFrame, + onReloadFrame, + onShowContextMenu, + onToggleSizeMode, +}: { + frame: CanvasSceneFrameEntity + leftX: number + topY: number + width: number + isDark: boolean + onClearFocus: () => void + onNavigateFrame: (frameId: string, url: string) => void + onGoBackFrame: (frameId: string) => void + onGoForwardFrame: (frameId: string) => void + onReloadFrame: (frameId: string) => void + onShowContextMenu: (frameId: string) => void + onToggleSizeMode: (frameId: string, currentMode: 'fill' | 'fit' | 'device') => void +}) { + const [isEditing, setIsEditing] = useState(false) + const [pendingUrl, setPendingUrl] = useState(null) + const isBlank = frame.url === 'about:blank' && !pendingUrl + + useEffect(() => { setPendingUrl(null) }, [frame.url]) + + const editValue = isBlank ? '' : frame.url + const displayValue = isBlank ? 'Type a URL' : frame.label || pendingUrl || frame.url + + const handleCommitUrl = (next: string) => { + const trimmed = next.trim() + if (trimmed && trimmed !== frame.url) { + const normalized = normalizeUserUrl(trimmed) + setPendingUrl(normalized) + onNavigateFrame(frame.id, normalized) + } + setIsEditing(false) + } + + const faviconIcon = isBlank ? ( + + ) : frame.faviconUrl ? ( + + ) : null + + return ( + + onGoBackFrame(frame.id)}> + + + onGoForwardFrame(frame.id)}> + + + onReloadFrame(frame.id)}> + + +
+
+ {faviconIcon} + setIsEditing(true)} + onCommit={handleCommitUrl} + onCancel={() => setIsEditing(false)} + variant="canvas-chrome" + isDark={isDark} + placeholder={isBlank ? 'Type a URL' : frame.url} + titleClassName={`min-w-0 truncate ${isBlank ? 'text-zinc-400' : ''}`} + onTitleClick={() => setIsEditing(true)} + /> +
+
+ onToggleSizeMode(frame.id, frame.sizeMode)} + > + + + onShowContextMenu(frame.id)}> + + + + + +
+ ) +} + +function FocusFileChrome({ + entity, + leftX, + topY, + width, + isDark, + onClearFocus, + onRenameFileEntity, +}: { + entity: CanvasSceneFileEntity + leftX: number + topY: number + width: number + isDark: boolean + onClearFocus: () => void + onRenameFileEntity: (entityId: string, newName: string) => void +}) { + const [isRenaming, setIsRenaming] = useState(false) + const fileName = entity.file.split('/').pop() ?? entity.file + const displayName = fileName + .replace(/\.wireframe\.json$/i, '') + .replace(/\.md$/i, '') + + return ( + +
+ setIsRenaming(true)} + onCommit={(next) => { + const trimmed = next.trim() + if (trimmed && trimmed !== displayName) onRenameFileEntity(entity.id, trimmed) + setIsRenaming(false) + }} + onCancel={() => setIsRenaming(false)} + variant="canvas-chrome" + isDark={isDark} + titleClassName="min-w-0 truncate font-medium" + onTitleClick={() => setIsRenaming(true)} + /> +
+ + + +
+ ) +} diff --git a/src/renderer/canvas-bg/FrameBorderLayer.tsx b/src/renderer/canvas-bg/FrameBorderLayer.tsx index 21a5c7ed..582d6fa0 100644 --- a/src/renderer/canvas-bg/FrameBorderLayer.tsx +++ b/src/renderer/canvas-bg/FrameBorderLayer.tsx @@ -21,7 +21,7 @@ type BorderItem = { deviceOrientation?: 'portrait' | 'landscape' showDeviceFrame?: boolean useSvgDeviceShell?: boolean - browserSizeMode?: string + sizeMode?: string width: number } @@ -50,7 +50,7 @@ export function FrameBorderLayer({ const fw = frame.screenWidth const fh = frame.screenHeight - const hasShell = frame.showDeviceFrame && frame.browserSizeMode !== 'fill' + const hasShell = frame.showDeviceFrame && frame.sizeMode !== 'fill' // SVG device shell handles its own borders if (hasShell && frame.useSvgDeviceShell) return null diff --git a/src/renderer/canvas-bg/FrameChromeLayer.tsx b/src/renderer/canvas-bg/FrameChromeLayer.tsx index 04c2827f..87e29493 100644 --- a/src/renderer/canvas-bg/FrameChromeLayer.tsx +++ b/src/renderer/canvas-bg/FrameChromeLayer.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { ChevronLeft, ChevronRight, EllipsisVertical, RotateCw, Search } from 'lucide-react' +import { ChevronLeft, ChevronRight, EllipsisVertical, Maximize2, RotateCw, Search } from 'lucide-react' import type { CanvasSceneFrameEntity } from '../../shared/types' import { normalizeUserUrl } from '../../shared/url' import { EntityChrome } from './EntityChromeHeader' @@ -19,6 +19,7 @@ export function FrameChromeLayer({ onGoForwardFrame, onReloadFrame, onShowContextMenu, + onSetFocus, }: { frames: CanvasSceneFrameEntity[] dragEnabled: boolean @@ -33,6 +34,7 @@ export function FrameChromeLayer({ onGoForwardFrame: (frameId: string) => void onReloadFrame: (frameId: string) => void onShowContextMenu: (frameId: string) => void + onSetFocus: (frameId: string) => void }) { return ( <> @@ -54,6 +56,7 @@ export function FrameChromeLayer({ onGoForwardFrame={onGoForwardFrame} onReloadFrame={onReloadFrame} onShowContextMenu={onShowContextMenu} + onSetFocus={onSetFocus} /> ) })} @@ -74,6 +77,7 @@ function FrameChromeItem({ onGoForwardFrame, onReloadFrame, onShowContextMenu, + onSetFocus, }: { frame: CanvasSceneFrameEntity isDark: boolean @@ -87,6 +91,7 @@ function FrameChromeItem({ onGoForwardFrame: (frameId: string) => void onReloadFrame: (frameId: string) => void onShowContextMenu: (frameId: string) => void + onSetFocus: (frameId: string) => void }) { const [isEditing, setIsEditing] = useState(false) const [pendingUrl, setPendingUrl] = useState(null) @@ -125,9 +130,12 @@ function FrameChromeItem({ return ( onReloadFrame(frame.id)}> + onSetFocus(frame.id)}> + + onShowContextMenu(frame.id)}> diff --git a/src/renderer/canvas-bg/SvgDeviceShellLayer.tsx b/src/renderer/canvas-bg/SvgDeviceShellLayer.tsx index bd132997..e5651fdf 100644 --- a/src/renderer/canvas-bg/SvgDeviceShellLayer.tsx +++ b/src/renderer/canvas-bg/SvgDeviceShellLayer.tsx @@ -31,7 +31,7 @@ export function SvgDeviceShellLayer({ frames: CanvasSceneFrameEntity[] isDark: boolean }) { - const framedFrames = frames.filter((f) => f.showDeviceFrame && f.browserSizeMode !== 'fill') + const framedFrames = frames.filter((f) => f.showDeviceFrame && f.sizeMode !== 'fill') if (!framedFrames.length) return null diff --git a/src/renderer/canvas-bg/canvasBgConstants.ts b/src/renderer/canvas-bg/canvasBgConstants.ts index 91ece5b7..d79c4f87 100644 --- a/src/renderer/canvas-bg/canvasBgConstants.ts +++ b/src/renderer/canvas-bg/canvasBgConstants.ts @@ -16,17 +16,14 @@ export const EMPTY_LAYOUT: LayoutUpdateData = { pan: { x: 0, y: 0 }, canvasOrigin: { x: 0, y: 44 }, entities: [], - browserTabs: [], - browserFillViewport: { width: 0, height: 0 }, + focusFillViewport: { width: 0, height: 0 }, selectedEntityIds: [], selection: [], activeSelection: null, annotationMode: 'off', annotations: [], fixProgress: {}, - viewMode: 'canvas', - activeBrowserTabId: null, - activeBrowserFrameId: null, + focusedEntityId: null, selectedGroupId: null, hover: null, interaction: { kind: 'idle' }, diff --git a/src/renderer/canvas-bg/useCanvasViewportGestures.ts b/src/renderer/canvas-bg/useCanvasViewportGestures.ts index 3c48104a..967c2ac0 100644 --- a/src/renderer/canvas-bg/useCanvasViewportGestures.ts +++ b/src/renderer/canvas-bg/useCanvasViewportGestures.ts @@ -71,7 +71,7 @@ export function useCanvasViewportGestures({ // Mouse left-button gestures. if (ctx.pointerType === 'mouse' && ctx.button === 0) { - if (layout.viewMode === 'browser') return null + if (layout.focusedEntityId !== null) return null // Pending-placement click: commit the placement and decline the // gesture. Returning null cleanly releases capture. @@ -145,7 +145,7 @@ export function useCanvasViewportGestures({ } // marquee if (rect.width < 4 || rect.height < 4) { - if (layout.viewMode === 'canvas') api.canvasDeselect() + if (layout.focusedEntityId === null) api.canvasDeselect() } else { api.canvasSelectInRect(screenRectToCanvasRect(rect, layout)) } @@ -203,9 +203,10 @@ export function useCanvasViewportGestures({ const handleMiddleMouseDown = (event: MouseEvent) => { const layout = layoutRef.current if (event.button !== 1) return - if (layout.viewMode === 'browser') return if (isOverlayUiTarget(event.target)) return if (event.clientY < layout.canvasOrigin.y) return + // Pan is a deliberate canvas gesture — exits focus before panning. + if (layout.focusedEntityId !== null) api.clearFocus() middleDrag = { screenX: event.screenX, screenY: event.screenY } event.preventDefault() } @@ -232,7 +233,7 @@ export function useCanvasViewportGestures({ // File drag/drop onto the canvas. const handleDragOver = (event: DragEvent) => { const layout = layoutRef.current - if (layout.viewMode === 'browser') return + if (layout.focusedEntityId !== null) return if (!event.dataTransfer?.types.includes('Files')) return event.preventDefault() if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy' @@ -240,7 +241,7 @@ export function useCanvasViewportGestures({ const handleDrop = (event: DragEvent) => { const layout = layoutRef.current - if (layout.viewMode === 'browser') return + if (layout.focusedEntityId !== null) return if (!event.dataTransfer?.files.length) return event.preventDefault() event.stopImmediatePropagation() diff --git a/src/renderer/shared/hooks/useCanvasGlobalShortcuts.ts b/src/renderer/shared/hooks/useCanvasGlobalShortcuts.ts index b0bf5bdc..b1eec28f 100644 --- a/src/renderer/shared/hooks/useCanvasGlobalShortcuts.ts +++ b/src/renderer/shared/hooks/useCanvasGlobalShortcuts.ts @@ -68,7 +68,7 @@ export function useCanvasGlobalShortcuts(input: { if (isTypingTarget(event.target)) return false const sel = window.getSelection() if (sel && sel.toString().length > 0) return false - return layoutRef.current.viewMode === 'canvas' + return layoutRef.current.focusedEntityId === null } const handleCopy = (event: ClipboardEvent) => { diff --git a/src/renderer/toolbar/App.tsx b/src/renderer/toolbar/App.tsx index 4148e39d..e2c8ed1b 100644 --- a/src/renderer/toolbar/App.tsx +++ b/src/renderer/toolbar/App.tsx @@ -32,7 +32,6 @@ export default function App({ initialTheme }: { initialTheme: ThemeData }) { currentPresetValue, hasSelection, hasFrames, - isBrowserMode, defaultToolActive, agentCursors, } = useToolbarState() @@ -53,8 +52,7 @@ export default function App({ initialTheme }: { initialTheme: ThemeData }) { isPlainShortcutKey(event, 'escape') && (selection.pendingPlacementActive || annotationMode !== 'off' || - inspectEnabled || - selection.viewMode === 'browser') + inspectEnabled) ) { event.preventDefault() if (document.activeElement instanceof HTMLElement) { @@ -64,9 +62,6 @@ export default function App({ initialTheme }: { initialTheme: ThemeData }) { if (annotationMode !== 'off' || inspectEnabled) { toolbarApi.clearToolMode() } - if (selection.viewMode === 'browser') { - toolbarApi.toggleBrowserMode() - } return } @@ -77,11 +72,10 @@ export default function App({ initialTheme }: { initialTheme: ThemeData }) { } window.addEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown) - }, [annotationMode, inspectEnabled, selection.viewMode]) + }, [annotationMode, inspectEnabled]) const isMac = navigator.userAgent.includes('Mac') const showMultiFrameAddressBar = selection.selectionCount > 1 - const showTabsModeAddressBar = isBrowserMode && hasSelection - const showCenterActionsOnly = !showMultiFrameAddressBar && !showTabsModeAddressBar + const showCenterActionsOnly = !showMultiFrameAddressBar return ( <> @@ -133,56 +127,7 @@ export default function App({ initialTheme }: { initialTheme: ThemeData }) { onToggleLeftSidebar={toolbarApi.toggleLeftSidebar} /> - {showTabsModeAddressBar ? ( -
- -
- -
- -
- { - if (open) toolbarApi.dropdownOpen() - else toolbarApi.dropdownClose() - }} - onClearToolMode={toolbarApi.clearToolMode} - onToggleAnnotateMode={toolbarApi.toggleAnnotateMode} - onToggleDrawMode={toolbarApi.toggleDrawMode} - onToggleRegionSelectMode={toolbarApi.toggleRegionSelectMode} - onToggleInspectMode={toolbarApi.toggleInspectMode} - onToggleTheme={toolbarApi.toggleTheme} - onZoomSet={(value) => toolbarApi.zoomSet(value / 100)} - /> -
-
- ) : showMultiFrameAddressBar ? ( + {showMultiFrameAddressBar ? (
@@ -204,7 +149,6 @@ export default function App({ initialTheme }: { initialTheme: ThemeData }) {
diff --git a/src/renderer/toolbar/toolbarSections.tsx b/src/renderer/toolbar/toolbarSections.tsx index 25f7a19f..fb774efc 100644 --- a/src/renderer/toolbar/toolbarSections.tsx +++ b/src/renderer/toolbar/toolbarSections.tsx @@ -1,17 +1,14 @@ import type { Dispatch, SetStateAction } from 'react' import { Select } from '@base-ui/react/select' -import { Tabs } from '@base-ui/react/tabs' import { ChevronDown, ChevronLeft, ChevronRight, Frame, - LayoutTemplate, MessageCircle, Moon, MousePointer2, PanelRight, - PanelTop, PencilLine, Pipette, RotateCw, @@ -115,7 +112,6 @@ export function LeftActions({ interface CenterActionsProps { isDark: boolean - isBrowserMode: boolean defaultToolActive: boolean annotationMode: AnnotationMode annotateAvailable: boolean @@ -140,7 +136,6 @@ interface CenterActionsProps { export function CenterActions({ isDark, - isBrowserMode, defaultToolActive, annotationMode, annotateAvailable, @@ -185,7 +180,7 @@ export function CenterActions({ - {!isBrowserMode ? ( + {true ? (
) : null} - {!isBrowserMode ? ( + {true ? (