diff --git a/blocksuite/affine/blocks/frame/src/edgeless-toolbar/presentation-toolbar.ts b/blocksuite/affine/blocks/frame/src/edgeless-toolbar/presentation-toolbar.ts index a8940b20f..3c17b4ce2 100644 --- a/blocksuite/affine/blocks/frame/src/edgeless-toolbar/presentation-toolbar.ts +++ b/blocksuite/affine/blocks/frame/src/edgeless-toolbar/presentation-toolbar.ts @@ -185,9 +185,33 @@ export class PresentationToolbar extends EdgelessToolbarToolMixin( if (document.fullscreenElement) { document.exitFullscreen().catch(console.error); } + + // Reset the flag when fully exiting presentation mode + this.edgeless.std + .get(EditPropsStore) + .setStorage('presentNoFrameToastShown', false); } - private _moveToCurrentFrame() { + private _moveToCurrentFrame(forceMove = false) { + const currentToolOption = this.gfx.tool.currentToolOption$.value; + const toolOptions = currentToolOption?.options; + + // If PresentTool is being activated after a temporary pan (indicated by restoredAfterPan) + // and a forced move isn't explicitly requested, skip moving to the current frame. + // This preserves the user's panned position instead of resetting to the frame's default view. + if ( + currentToolOption?.toolType === PresentTool && + toolOptions?.restoredAfterPan && + !forceMove + ) { + // Clear the flag so future navigations behave normally + this.gfx.tool.setTool(PresentTool, { + ...toolOptions, + restoredAfterPan: false, + }); + return; + } + const current = this._currentFrameIndex; const viewport = this.gfx.viewport; const frame = this._frames[current]; @@ -263,28 +287,56 @@ export class PresentationToolbar extends EdgelessToolbarToolMixin( _disposables.add( effect(() => { - const currentTool = this.gfx.tool.currentToolOption$.value; - const selection = this.gfx.selection; + const currentToolOption = this.gfx.tool.currentToolOption$.value; - if (currentTool?.toolType === PresentTool) { - this._cachedIndex = this._currentFrameIndex; - this._navigatorMode = - (currentTool.options as ToolOptions)?.mode ?? - this._navigatorMode; - if (isFrameBlock(selection.selectedElements[0])) { - this._cachedIndex = this._frames.findIndex( - frame => frame.id === selection.selectedElements[0].id - ); + if (currentToolOption?.toolType === PresentTool) { + const opts = currentToolOption.options as + | ToolOptions + | undefined; + + const isAlreadyFullscreen = !!document.fullscreenElement; + + if (!isAlreadyFullscreen) { + this._toggleFullScreen(); + } else { + this._fullScreenMode = true; } - if (this._frames.length === 0) - toast( - this.host, - 'The presentation requires at least 1 frame. You can firstly create a frame.', - 5000 - ); - this._toggleFullScreen(); - } + this._cachedIndex = this._currentFrameIndex; + this._navigatorMode = opts?.mode ?? this._navigatorMode; + + const selection = this.gfx.selection; + if ( + selection.selectedElements.length > 0 && + isFrameBlock(selection.selectedElements[0]) + ) { + const selectedFrameId = selection.selectedElements[0].id; + const indexOfSelectedFrame = this._frames.findIndex( + frame => frame.id === selectedFrameId + ); + if (indexOfSelectedFrame !== -1) { + this._cachedIndex = indexOfSelectedFrame; + } + } + + const store = this.edgeless.std.get(EditPropsStore); + if (this._frames.length === 0) { + if (!store.getStorage('presentNoFrameToastShown')) { + toast( + this.host, + 'The presentation requires at least 1 frame. You can firstly create a frame.', + 5000 + ); + store.setStorage('presentNoFrameToastShown', true); + } + } else { + // If frames exist, and the flag was set, reset it. + // This allows the toast to show again if all frames are subsequently deleted. + if (store.getStorage('presentNoFrameToastShown')) { + store.setStorage('presentNoFrameToastShown', false); + } + } + } this.requestUpdate(); }) ); @@ -305,12 +357,10 @@ export class PresentationToolbar extends EdgelessToolbarToolMixin( _disposables.addFromEvent(document, 'fullscreenchange', () => { if (document.fullscreenElement) { - // When enter fullscreen, we need to set current frame to the cached index this._timer = setTimeout(() => { this._currentFrameIndex = this._cachedIndex; }, 400); } else { - // When exit fullscreen, we need to clear the timer clearTimeout(this._timer); if ( this.edgelessTool.toolType === PresentTool && @@ -324,7 +374,7 @@ export class PresentationToolbar extends EdgelessToolbarToolMixin( } } - setTimeout(() => this._moveToCurrentFrame(), 400); + setTimeout(() => this._moveToCurrentFrame(true), 400); this.slots.fullScreenToggled.next(); }); @@ -430,11 +480,29 @@ export class PresentationToolbar extends EdgelessToolbarToolMixin( } protected override updated(changedProperties: PropertyValues) { - if ( - changedProperties.has('_currentFrameIndex') && - this.edgelessTool.toolType === PresentTool - ) { - this._moveToCurrentFrame(); + const currentToolOption = this.gfx.tool.currentToolOption$.value; + const isPresentToolActive = currentToolOption?.toolType === PresentTool; + const toolOptions = currentToolOption?.options; + const isRestoredAfterPan = !!( + isPresentToolActive && toolOptions?.restoredAfterPan + ); + + if (changedProperties.has('_currentFrameIndex') && isPresentToolActive) { + // When the current frame index changes (e.g., user navigates), a viewport update is needed. + // However, if PresentTool is merely being restored after a pan (isRestoredAfterPan = true) + // without an explicit index change in this update cycle, we avoid forcing a move to preserve the panned position. + // Thus, `forceMove` is true unless it's a pan restoration. + const shouldForceMove = !isRestoredAfterPan; + this._moveToCurrentFrame(shouldForceMove); + } else if (isPresentToolActive && changedProperties.has('edgelessTool')) { + // Handles cases where the tool is set/switched to PresentTool (e.g., initial activation or returning from another tool). + // Similar to frame index changes, avoid forcing a viewport move if restoring after a pan. + const currentToolIsPresentTool = + this.edgelessTool.toolType === PresentTool; + if (currentToolIsPresentTool) { + const shouldForceMoveOnToolChange = !isRestoredAfterPan; + this._moveToCurrentFrame(shouldForceMoveOnToolChange); + } } } diff --git a/blocksuite/affine/blocks/frame/src/present-tool.ts b/blocksuite/affine/blocks/frame/src/present-tool.ts index 3576e8704..cf9a147bc 100644 --- a/blocksuite/affine/blocks/frame/src/present-tool.ts +++ b/blocksuite/affine/blocks/frame/src/present-tool.ts @@ -4,6 +4,7 @@ import type { NavigatorMode } from './frame-manager'; export type PresentToolOption = { mode?: NavigatorMode; + restoredAfterPan?: boolean; }; export class PresentTool extends BaseTool { diff --git a/blocksuite/affine/blocks/root/src/edgeless/edgeless-keyboard.ts b/blocksuite/affine/blocks/root/src/edgeless/edgeless-keyboard.ts index 5c79e48e1..56c1471b3 100644 --- a/blocksuite/affine/blocks/root/src/edgeless/edgeless-keyboard.ts +++ b/blocksuite/affine/blocks/root/src/edgeless/edgeless-keyboard.ts @@ -467,6 +467,9 @@ export class EdgelessPageKeyboardManager extends PageKeyboardManager { const selection = gfx.selection; if (event.code === 'Space' && !event.repeat) { + const currentToolName = + this.rootComponent.gfx.tool.currentToolName$.peek(); + if (currentToolName === 'frameNavigator') return false; this._space(event); } else if ( !selection.editing && @@ -504,8 +507,12 @@ export class EdgelessPageKeyboardManager extends PageKeyboardManager { ctx => { const event = ctx.get('keyboardState').raw; if (event.code === 'Space' && !event.repeat) { + const currentToolName = + this.rootComponent.gfx.tool.currentToolName$.peek(); + if (currentToolName === 'frameNavigator') return false; this._space(event); } + return false; }, { global: true } ); diff --git a/blocksuite/affine/gfx/pointer/src/tools/pan-tool.ts b/blocksuite/affine/gfx/pointer/src/tools/pan-tool.ts index 500856c81..66833c015 100644 --- a/blocksuite/affine/gfx/pointer/src/tools/pan-tool.ts +++ b/blocksuite/affine/gfx/pointer/src/tools/pan-tool.ts @@ -1,8 +1,13 @@ import { on } from '@blocksuite/affine-shared/utils'; import type { PointerEventState } from '@blocksuite/std'; -import { BaseTool, MouseButton } from '@blocksuite/std/gfx'; +import { BaseTool, MouseButton, type ToolOptions } from '@blocksuite/std/gfx'; import { Signal } from '@preact/signals-core'; +interface RestorablePresentToolOptions { + mode?: string; // 'fit' | 'fill', simplified to string for local use + restoredAfterPan?: boolean; +} + export type PanToolOption = { panning: boolean; }; @@ -53,14 +58,30 @@ export class PanTool extends BaseTool { evt.raw.preventDefault(); - const selection = this.gfx.selection.surfaceSelections; const currentTool = this.controller.currentToolOption$.peek(); const restoreToPrevious = () => { - const { toolType, options } = currentTool; - if (toolType && options) { - this.controller.setTool(toolType, options); - this.gfx.selection.set(selection); + const { toolType, options: originalToolOptions } = currentTool; + const selectionToRestore = this.gfx.selection.surfaceSelections; + if (!toolType) return; + + let finalOptions: ToolOptions> | undefined = + originalToolOptions; + const PRESENT_TOOL_NAME = 'frameNavigator'; + + if (toolType.toolName === PRESENT_TOOL_NAME) { + // When restoring PresentTool (frameNavigator) after a temporary pan (e.g., via middle mouse button), + // set 'restoredAfterPan' to true. This allows PresentTool to avoid an unwanted viewport reset + // and maintain the panned position. + const currentPresentOptions = originalToolOptions as + | RestorablePresentToolOptions + | undefined; + finalOptions = { + ...currentPresentOptions, + restoredAfterPan: true, + } as RestorablePresentToolOptions; } + this.controller.setTool(toolType, finalOptions); + this.gfx.selection.set(selectionToRestore); }; this.controller.setTool(PanTool, { diff --git a/blocksuite/affine/shared/src/services/edit-props-store.ts b/blocksuite/affine/shared/src/services/edit-props-store.ts index 36510ee68..06ac58178 100644 --- a/blocksuite/affine/shared/src/services/edit-props-store.ts +++ b/blocksuite/affine/shared/src/services/edit-props-store.ts @@ -41,6 +41,7 @@ const LocalPropsSchema = z.object({ presentBlackBackground: z.boolean(), presentFillScreen: z.boolean(), presentHideToolbar: z.boolean(), + presentNoFrameToastShown: z.boolean(), autoHideEmbedHTMLFullScreenToolbar: z.boolean(), }); @@ -126,6 +127,8 @@ export class EditPropsStore extends LifeCycleWatcher { return 'blocksuite:presentation:fillScreen'; case 'presentHideToolbar': return 'blocksuite:presentation:hideToolbar'; + case 'presentNoFrameToastShown': + return 'blocksuite:presentation:noFrameToastShown'; case 'templateCache': return 'blocksuite:' + id + ':templateTool'; case 'remoteColor':