fix(editor): mid button drag in presentation mode (#12309)

Fixes https://linear.app/affine-design/issue/BS-3448

Before this PR, presentation mode would force quit if user either:

1. Press space
2. Drag with mouse middle button

Unfixed behavior:

https://github.com/user-attachments/assets/8ff4e13a-69a8-4de6-8994-bf36e6e3eb49

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **Bug Fixes**
	- Improved presentation mode to preserve your current panned view when exiting pan mode or toggling fullscreen, preventing unwanted viewport resets.
	- Spacebar actions are now correctly disabled when using the frame navigator tool, avoiding accidental tool switches.
- **New Features**
	- Enhanced presentation controls for smoother transitions and better handling of user navigation states.
	- Added a one-time toast notification for presentations without frames, shown only once per session for better user guidance.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
doodlewind
2025-05-15 11:12:40 +00:00
parent 147fa9a6b1
commit b6e9c41ee3
5 changed files with 134 additions and 34 deletions

View File

@@ -185,9 +185,33 @@ export class PresentationToolbar extends EdgelessToolbarToolMixin(
if (document.fullscreenElement) { if (document.fullscreenElement) {
document.exitFullscreen().catch(console.error); 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 current = this._currentFrameIndex;
const viewport = this.gfx.viewport; const viewport = this.gfx.viewport;
const frame = this._frames[current]; const frame = this._frames[current];
@@ -263,28 +287,56 @@ export class PresentationToolbar extends EdgelessToolbarToolMixin(
_disposables.add( _disposables.add(
effect(() => { effect(() => {
const currentTool = this.gfx.tool.currentToolOption$.value; const currentToolOption = this.gfx.tool.currentToolOption$.value;
const selection = this.gfx.selection;
if (currentTool?.toolType === PresentTool) { if (currentToolOption?.toolType === PresentTool) {
this._cachedIndex = this._currentFrameIndex; const opts = currentToolOption.options as
this._navigatorMode = | ToolOptions<PresentTool>
(currentTool.options as ToolOptions<PresentTool>)?.mode ?? | undefined;
this._navigatorMode;
if (isFrameBlock(selection.selectedElements[0])) { const isAlreadyFullscreen = !!document.fullscreenElement;
this._cachedIndex = this._frames.findIndex(
frame => frame.id === selection.selectedElements[0].id 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(); this.requestUpdate();
}) })
); );
@@ -305,12 +357,10 @@ export class PresentationToolbar extends EdgelessToolbarToolMixin(
_disposables.addFromEvent(document, 'fullscreenchange', () => { _disposables.addFromEvent(document, 'fullscreenchange', () => {
if (document.fullscreenElement) { if (document.fullscreenElement) {
// When enter fullscreen, we need to set current frame to the cached index
this._timer = setTimeout(() => { this._timer = setTimeout(() => {
this._currentFrameIndex = this._cachedIndex; this._currentFrameIndex = this._cachedIndex;
}, 400); }, 400);
} else { } else {
// When exit fullscreen, we need to clear the timer
clearTimeout(this._timer); clearTimeout(this._timer);
if ( if (
this.edgelessTool.toolType === PresentTool && 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(); this.slots.fullScreenToggled.next();
}); });
@@ -430,11 +480,29 @@ export class PresentationToolbar extends EdgelessToolbarToolMixin(
} }
protected override updated(changedProperties: PropertyValues) { protected override updated(changedProperties: PropertyValues) {
if ( const currentToolOption = this.gfx.tool.currentToolOption$.value;
changedProperties.has('_currentFrameIndex') && const isPresentToolActive = currentToolOption?.toolType === PresentTool;
this.edgelessTool.toolType === PresentTool const toolOptions = currentToolOption?.options;
) { const isRestoredAfterPan = !!(
this._moveToCurrentFrame(); 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);
}
} }
} }

View File

@@ -4,6 +4,7 @@ import type { NavigatorMode } from './frame-manager';
export type PresentToolOption = { export type PresentToolOption = {
mode?: NavigatorMode; mode?: NavigatorMode;
restoredAfterPan?: boolean;
}; };
export class PresentTool extends BaseTool<PresentToolOption> { export class PresentTool extends BaseTool<PresentToolOption> {

View File

@@ -467,6 +467,9 @@ export class EdgelessPageKeyboardManager extends PageKeyboardManager {
const selection = gfx.selection; const selection = gfx.selection;
if (event.code === 'Space' && !event.repeat) { if (event.code === 'Space' && !event.repeat) {
const currentToolName =
this.rootComponent.gfx.tool.currentToolName$.peek();
if (currentToolName === 'frameNavigator') return false;
this._space(event); this._space(event);
} else if ( } else if (
!selection.editing && !selection.editing &&
@@ -504,8 +507,12 @@ export class EdgelessPageKeyboardManager extends PageKeyboardManager {
ctx => { ctx => {
const event = ctx.get('keyboardState').raw; const event = ctx.get('keyboardState').raw;
if (event.code === 'Space' && !event.repeat) { if (event.code === 'Space' && !event.repeat) {
const currentToolName =
this.rootComponent.gfx.tool.currentToolName$.peek();
if (currentToolName === 'frameNavigator') return false;
this._space(event); this._space(event);
} }
return false;
}, },
{ global: true } { global: true }
); );

View File

@@ -1,8 +1,13 @@
import { on } from '@blocksuite/affine-shared/utils'; import { on } from '@blocksuite/affine-shared/utils';
import type { PointerEventState } from '@blocksuite/std'; 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'; import { Signal } from '@preact/signals-core';
interface RestorablePresentToolOptions {
mode?: string; // 'fit' | 'fill', simplified to string for local use
restoredAfterPan?: boolean;
}
export type PanToolOption = { export type PanToolOption = {
panning: boolean; panning: boolean;
}; };
@@ -53,14 +58,30 @@ export class PanTool extends BaseTool<PanToolOption> {
evt.raw.preventDefault(); evt.raw.preventDefault();
const selection = this.gfx.selection.surfaceSelections;
const currentTool = this.controller.currentToolOption$.peek(); const currentTool = this.controller.currentToolOption$.peek();
const restoreToPrevious = () => { const restoreToPrevious = () => {
const { toolType, options } = currentTool; const { toolType, options: originalToolOptions } = currentTool;
if (toolType && options) { const selectionToRestore = this.gfx.selection.surfaceSelections;
this.controller.setTool(toolType, options); if (!toolType) return;
this.gfx.selection.set(selection);
let finalOptions: ToolOptions<BaseTool<any>> | 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, { this.controller.setTool(PanTool, {

View File

@@ -41,6 +41,7 @@ const LocalPropsSchema = z.object({
presentBlackBackground: z.boolean(), presentBlackBackground: z.boolean(),
presentFillScreen: z.boolean(), presentFillScreen: z.boolean(),
presentHideToolbar: z.boolean(), presentHideToolbar: z.boolean(),
presentNoFrameToastShown: z.boolean(),
autoHideEmbedHTMLFullScreenToolbar: z.boolean(), autoHideEmbedHTMLFullScreenToolbar: z.boolean(),
}); });
@@ -126,6 +127,8 @@ export class EditPropsStore extends LifeCycleWatcher {
return 'blocksuite:presentation:fillScreen'; return 'blocksuite:presentation:fillScreen';
case 'presentHideToolbar': case 'presentHideToolbar':
return 'blocksuite:presentation:hideToolbar'; return 'blocksuite:presentation:hideToolbar';
case 'presentNoFrameToastShown':
return 'blocksuite:presentation:noFrameToastShown';
case 'templateCache': case 'templateCache':
return 'blocksuite:' + id + ':templateTool'; return 'blocksuite:' + id + ':templateTool';
case 'remoteColor': case 'remoteColor':