fix: drag block issue (#9902)

### Changed
- Added support for changing the preview offset during dragging.
- Fixed the preview rendering for embed block and surface-ref block
- Resolved an issue where the host element might be reused in certain cases, which could cause unexpected behavior
- Moved viewport-related constants and methods to a more appropriate location
This commit is contained in:
doouding
2025-02-05 07:25:53 +00:00
parent abeff8bb1a
commit 02122098c7
22 changed files with 177 additions and 138 deletions
@@ -30,7 +30,10 @@ import {
BlockStdScope,
type EditorHost,
} from '@blocksuite/block-std';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import {
GfxControllerIdentifier,
GfxExtension,
} from '@blocksuite/block-std/gfx';
import { assertExists, Bound, getCommonBound } from '@blocksuite/global/utils';
import { type GetBlocksOptions, type Query, Text } from '@blocksuite/store';
import { computed, signal } from '@preact/signals-core';
@@ -145,8 +148,17 @@ export class EmbedSyncedDocBlockComponent extends EmbedBlockComponent<EmbedSynce
}
}
class GfxViewportInitializer extends GfxExtension {
static override key = 'gfx-viewport-initializer';
override mounted(): void {
this.gfx.fitToScreen();
}
}
previewSpecBuilder.extend([
EmbedSyncedDocWatcher,
GfxViewportInitializer,
EditorSettingExtension(editorSetting),
]);
@@ -18,7 +18,10 @@ import {
} from '@blocksuite/affine-model';
import {
DocModeProvider,
EditorSettingExtension,
EditorSettingProvider,
EditPropsStore,
GeneralSettingSchema,
ThemeProvider,
} from '@blocksuite/affine-shared/services';
import {
@@ -37,6 +40,7 @@ import {
import {
GfxBlockElementModel,
GfxControllerIdentifier,
GfxExtension,
} from '@blocksuite/block-std/gfx';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import {
@@ -47,6 +51,7 @@ import {
type SerializedXYWH,
} from '@blocksuite/global/utils';
import type { BaseSelection, Store } from '@blocksuite/store';
import { signal } from '@preact/signals-core';
import { css, html, nothing, type TemplateResult } from 'lit';
import { query, state } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
@@ -424,6 +429,11 @@ export class SurfaceRefBlockComponent extends BlockComponent<SurfaceRefBlockMode
private _initSpec() {
const refreshViewport = this._refreshViewport.bind(this);
// oxlint-disable-next-line @typescript-eslint/no-this-alias
const self = this;
const editorSetting =
this.std.getOptional(EditorSettingProvider) ??
signal(GeneralSettingSchema.parse({}));
class PageViewWatcher extends BlockServiceWatcher {
static override readonly flavour = 'affine:page';
@@ -444,7 +454,23 @@ export class SurfaceRefBlockComponent extends BlockComponent<SurfaceRefBlockMode
);
}
}
this._previewSpec.extend([PageViewWatcher]);
class ViewportInitializer extends GfxExtension {
static override readonly key = 'surface-ref-viewport-initializer';
override mounted() {
this.gfx.viewport.setViewportByBound(
Bound.deserialize(self._referenceXYWH!)
);
refreshViewport();
}
}
this._previewSpec.extend([
ViewportInitializer,
PageViewWatcher,
EditorSettingExtension(editorSetting),
]);
const referenceId = this.model.reference;
const setReferenceXYWH = (xywh: typeof this._referenceXYWH) => {
@@ -1,3 +1,4 @@
import { requestConnectedFrame } from '@blocksuite/affine-shared/utils';
import { assertExists } from '@blocksuite/global/utils';
import {
arrow,
@@ -188,9 +189,9 @@ export class Tooltip extends LitElement {
assertExists(parent, 'Tooltip must have a parent element');
// Wait for render
setTimeout(() => {
requestConnectedFrame(() => {
this._hoverController.setReference(parent);
}, 0);
}, this);
};
private _getStyles() {
@@ -211,6 +211,7 @@ export class DatabaseColumnStatsCell extends SignalWatcher(
this.subscriptionMap.forEach(unsub => {
unsub();
});
this.subscriptionMap.clear();
});
}
@@ -617,7 +617,7 @@ export class DragEventWatcher {
onDrop: () => {
this._cleanup();
},
setDragPreview: ({ source, container }) => {
setDragPreview: ({ source, container, setOffset }) => {
if (!source.data?.bsEntity?.modelIds.length) {
return;
}
@@ -626,6 +626,9 @@ export class DragEventWatcher {
source.data?.bsEntity?.modelIds,
container
);
const rect = container.getBoundingClientRect();
setOffset({ x: rect.width / 2, y: rect.height / 2 });
},
setDragData: () => {
const { snapshot } = this._getSnapshotFromHoveredBlocks();
@@ -47,7 +47,6 @@ import { EdgelessPageKeyboardManager } from './edgeless-keyboard.js';
import type { EdgelessRootService } from './edgeless-root-service.js';
import { getBackgroundGrid, isCanvasElement } from './utils/query.js';
import { mountShapeTextEditor } from './utils/text.js';
import { fitToScreen } from './utils/viewport.js';
export class EdgelessRootBlockComponent extends BlockComponent<
RootBlockModel,
@@ -341,9 +340,7 @@ export class EdgelessRootBlockComponent extends BlockComponent<
const storedViewport = std.get(EditPropsStore).getStorage('viewport');
if (!storedViewport) {
fitToScreen(this.gfx.gfxElements, gfx.viewport, {
smooth: false,
});
this.gfx.fitToScreen();
return;
}
@@ -23,7 +23,6 @@ import { query, state } from 'lit/decorators.js';
import type { EdgelessRootBlockWidgetName } from '../types.js';
import type { EdgelessRootService } from './edgeless-root-service.js';
import { getBackgroundGrid, isCanvasElement } from './utils/query.js';
import { fitToScreen } from './utils/viewport.js';
export class EdgelessRootPreviewBlockComponent
extends BlockComponent<
@@ -178,14 +177,6 @@ export class EdgelessRootPreviewBlockComponent
);
}
private _initViewport() {
const gfx = this.service.gfx;
fitToScreen(gfx.gfxElements, gfx.viewport, {
smooth: false,
});
}
private get _disableScheduleUpdate() {
const editorSetting = this.std.getOptional(EditorSettingProvider);
@@ -195,7 +186,6 @@ export class EdgelessRootPreviewBlockComponent
override connectedCallback() {
super.connectedCallback();
this._initViewport();
this.handleEvent('selectionChange', () => {
const surface = this.host.selection.value.find(
(sel): sel is SurfaceSelection => sel.is(SurfaceSelection)
@@ -27,6 +27,9 @@ import {
GfxControllerIdentifier,
GfxExtensionIdentifier,
isGfxGroupCompatibleModel,
ZOOM_MAX,
ZOOM_MIN,
ZOOM_STEP,
} from '@blocksuite/block-std/gfx';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { Bound, getCommonBound } from '@blocksuite/global/utils';
@@ -41,15 +44,7 @@ import {
createStickerMiddleware,
replaceIdMiddleware,
} from './services/template-middlewares.js';
import { FIT_TO_SCREEN_PADDING } from './utils/consts.js';
import { getCursorMode } from './utils/query.js';
import {
ZOOM_INITIAL,
ZOOM_MAX,
ZOOM_MIN,
ZOOM_STEP,
type ZoomAction,
} from './utils/zoom.js';
export class EdgelessRootService extends RootService implements SurfaceContext {
static override readonly flavour = RootBlockSchema.model.flavour;
@@ -288,34 +283,6 @@ export class EdgelessRootService extends RootService implements SurfaceContext {
return this.surface.getConnectors(id) as ConnectorElementModel[];
}
getFitToScreenData(
padding: [number, number, number, number] = [0, 0, 0, 0],
inputBounds?: Bound[]
) {
let bounds = [];
if (inputBounds && inputBounds.length) {
bounds = inputBounds;
} else {
this.blocks.forEach(block => {
bounds.push(Bound.deserialize(block.xywh));
});
const surfaceElementsBound = getCommonBound(this.elements);
if (surfaceElementsBound) {
bounds.push(surfaceElementsBound);
}
}
const bound = getCommonBound(bounds);
return this.viewport.getFitToScreenData(
bound,
padding,
ZOOM_INITIAL,
FIT_TO_SCREEN_PADDING
);
}
override mounted() {
super.mounted();
this._initSlotEffects();
@@ -372,12 +339,12 @@ export class EdgelessRootService extends RootService implements SurfaceContext {
}
}
setZoomByAction(action: ZoomAction) {
setZoomByAction(action: 'fit' | 'out' | 'reset' | 'in') {
if (this.locked) return;
switch (action) {
case 'fit':
this.zoomToFit();
this.gfx.fitToScreen();
break;
case 'reset':
this.viewport.smoothZoom(1.0);
@@ -439,9 +406,4 @@ export class EdgelessRootService extends RootService implements SurfaceContext {
this.selectionManager.set([]);
this.disposables.dispose();
}
zoomToFit() {
const { centerX, centerY, zoom } = this.getFitToScreenData();
this.viewport.setViewport(zoom, [centerX, centerY], true);
}
}
@@ -67,7 +67,6 @@ import {
mountShapeTextEditor,
mountTextElementEditor,
} from '../utils/text.js';
import { fitToScreen } from '../utils/viewport.js';
import { CanvasElementEventExt } from './default-tool-ext/event-ext.js';
import type { DefaultToolExt } from './default-tool-ext/ext.js';
import { DefaultModeDragType } from './default-tool-ext/ext.js';
@@ -766,11 +765,7 @@ export class DefaultTool extends BaseTool {
if (this.doc.readonly) {
const viewport = this.gfx.viewport;
if (viewport.zoom === 1) {
// Fit to Screen
fitToScreen(
[...this.gfx.layer.blocks, ...this.gfx.layer.canvasElements],
this.gfx.viewport
);
this.gfx.fitToScreen();
} else {
// Zoom to 100% and Center
const [x, y] = viewport.toModelCoord(e.x, e.y);
@@ -1,28 +0,0 @@
import type { GfxModel, Viewport } from '@blocksuite/block-std/gfx';
import { Bound, getCommonBound } from '@blocksuite/global/utils';
import { FIT_TO_SCREEN_PADDING } from './consts.js';
import { ZOOM_INITIAL } from './zoom.js';
export function fitToScreen(
elements: GfxModel[],
viewport: Viewport,
options: {
padding?: [number, number, number, number];
smooth?: boolean;
} = {
padding: [0, 0, 0, 0],
smooth: true,
}
) {
const elemBounds = elements.map(element => Bound.deserialize(element.xywh));
const commonBound = getCommonBound(elemBounds);
const { zoom, centerX, centerY } = viewport.getFitToScreenData(
commonBound,
options.padding,
ZOOM_INITIAL,
FIT_TO_SCREEN_PADDING
);
viewport.setViewport(zoom, [centerX, centerY], options.smooth);
}
@@ -1,5 +0,0 @@
export type ZoomAction = 'fit' | 'out' | 'reset' | 'in';
export const ZOOM_MAX = 6.0;
export const ZOOM_MIN = 0.1;
export const ZOOM_STEP = 0.25;
export const ZOOM_INITIAL = 1.0;
@@ -4,6 +4,7 @@ import {
ViewBarIcon,
} from '@blocksuite/affine-components/icons';
import { stopPropagation } from '@blocksuite/affine-shared/utils';
import { ZOOM_STEP } from '@blocksuite/block-std/gfx';
import { WithDisposable } from '@blocksuite/global/utils';
import { effect } from '@preact/signals-core';
import { baseTheme } from '@toeverything/theme';
@@ -11,7 +12,6 @@ import { css, html, LitElement, nothing, unsafeCSS } from 'lit';
import { property } from 'lit/decorators.js';
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
import { ZOOM_STEP } from '../../edgeless/utils/zoom.js';
export class EdgelessZoomToolbar extends WithDisposable(LitElement) {
static override styles = css`
@@ -87,6 +87,10 @@ export class EdgelessZoomToolbar extends WithDisposable(LitElement) {
return this.edgeless.service;
}
get gfx() {
return this.edgeless.gfx;
}
get edgelessTool() {
return this.edgeless.gfx.tool.currentToolOption$.peek();
}
@@ -162,7 +166,7 @@ export class EdgelessZoomToolbar extends WithDisposable(LitElement) {
.tooltip=${'Fit to screen'}
.tipPosition=${this._isVerticalBar() ? 'right' : 'top-end'}
.arrow=${!this._isVerticalBar()}
@click=${() => this.edgelessService.zoomToFit()}
@click=${() => this.gfx.fitToScreen()}
.iconContainerPadding=${4}
.disabled=${locked}
>
@@ -4,7 +4,10 @@ import {
type ElementGetFeedbackArgs,
monitorForElements,
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { centerUnderPointer } from '@atlaskit/pragmatic-drag-and-drop/element/center-under-pointer';
import { disableNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/disable-native-drag-preview';
import { pointerOutsideOfPreview } from '@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview';
import { preserveOffsetOnSource } from '@atlaskit/pragmatic-drag-and-drop/element/preserve-offset-on-source';
import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview';
import type { DropTargetRecord } from '@atlaskit/pragmatic-drag-and-drop/types';
import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element';
@@ -89,7 +92,7 @@ export type DraggableOption<
* If you want to completely disable the drag preview, just set `setDragPreview` to `false`.
*
* @example
* dnd.draggable{
* dnd.draggable({
* // ...
* setDragPreview: ({ container }) => {
* const preview = document.createElement('div');
@@ -98,8 +101,12 @@ export type DraggableOption<
* preview.style.backgroundColor = 'red';
* preview.innerText = 'Custom Drag Preview';
* container.appendChild(preview);
*
* return () => {
* // do some cleanup
* }
* }
* }
* })
*
* @param callback - callback to set custom drag preview
* @returns
@@ -116,8 +123,11 @@ export type DraggableOption<
*/
nativeSetDragImage: DataTransfer['setDragImage'] | null;
container: HTMLElement;
setOffset: (
offset: 'preserve' | 'center' | { x: number; y: number }
) => void;
}
) => void);
) => void | (() => void));
} & ElementDragEventMap<DragPayload<PayloadEntity, PayloadFrom>, DropPayload>;
export type DropTargetOption<
@@ -228,9 +238,55 @@ export class DndController extends LifeCycleWatcher {
dragHandle,
onGenerateDragPreview: options => {
if (setDragPreview) {
let state: typeof centerUnderPointer | { x: number; y: number };
const setOffset = (
offset: 'preserve' | 'center' | { x: number; y: number }
) => {
if (offset === 'center') {
state = centerUnderPointer;
} else if (offset === 'preserve') {
state = preserveOffsetOnSource({
element: options.source.element,
input: options.location.current.input,
});
} else if (typeof offset === 'object') {
if (
offset.x < 0 ||
offset.y < 0 ||
typeof offset.x === 'string' ||
typeof offset.y === 'string'
) {
state = pointerOutsideOfPreview({
x:
typeof offset.x === 'number'
? `${Math.abs(offset.x)}px`
: offset.x,
y:
typeof offset.y === 'number'
? `${Math.abs(offset.y)}px`
: offset.y,
});
}
state = offset;
}
};
setCustomNativeDragPreview({
getOffset: (...args) => {
if (!state) {
setOffset('center');
}
if (typeof state === 'function') {
return state(...args);
}
return state;
},
render: renderOption => {
setDragPreview({
setOffset,
...options,
...renderOption,
});
@@ -2,6 +2,7 @@ import {
assertType,
Bound,
DisposableGroup,
getCommonBound,
getCommonBoundWithRotation,
type IBound,
last,
@@ -30,7 +31,7 @@ import {
GfxPrimitiveElementModel,
} from './model/surface/element-model.js';
import type { SurfaceBlockModel } from './model/surface/surface-model.js';
import { Viewport } from './viewport.js';
import { FIT_TO_SCREEN_PADDING, Viewport, ZOOM_INITIAL } from './viewport.js';
export class GfxController extends LifeCycleWatcher {
static override key = gfxControllerKey;
@@ -300,4 +301,28 @@ export class GfxController extends LifeCycleWatcher {
block && this.doc.updateBlock(block.model, props);
}
}
fitToScreen(
options: {
bounds?: Bound[];
smooth?: boolean;
padding?: [number, number, number, number];
} = {
smooth: false,
padding: [0, 0, 0, 0],
}
) {
const elemBounds =
options.bounds ??
this.gfxElements.map(element => Bound.deserialize(element.xywh));
const commonBound = getCommonBound(elemBounds);
const { zoom, centerX, centerY } = this.viewport.getFitToScreenData(
commonBound,
options.padding,
ZOOM_INITIAL,
FIT_TO_SCREEN_PADDING
);
this.viewport.setViewport(zoom, [centerX, centerY], options.smooth);
}
}
@@ -60,9 +60,12 @@ export class ViewManager extends GfxExtension {
this._disposable.add(
surface.elementAdded.on(payload => {
const model = surface.getElementById(payload.id)!;
const View = this._viewCtorMap.get(model.type) ?? GfxElementModelView;
const ViewCtor =
this._viewCtorMap.get(model.type) ?? GfxElementModelView;
const view = new ViewCtor(model, this.gfx);
this._viewMap.set(model.id, new View(model, this.gfx));
this._viewMap.set(model.id, view);
view.onCreated();
})
);
@@ -72,7 +72,6 @@ export class GfxElementModelView<
readonly gfx: GfxController
) {
this.model = model;
this.onCreated();
}
static setup(di: Container): void {
@@ -15,6 +15,10 @@ function cutoff(value: number, ref: number, sign: number) {
export const ZOOM_MAX = 6.0;
export const ZOOM_MIN = 0.1;
export const ZOOM_STEP = 0.25;
export const ZOOM_INITIAL = 1.0;
export const FIT_TO_SCREEN_PADDING = 100;
export class Viewport {
private _cachedBoundingClientRect: DOMRect | null = null;
@@ -51,8 +51,6 @@ const internalExtensions = [
export class BlockStdScope {
static internalExtensions = internalExtensions;
private _getHost: () => EditorHost;
readonly container: Container;
readonly store: Store;
@@ -65,6 +63,8 @@ export class BlockStdScope {
return this.provider.getAll(LifeCycleWatcherIdentifier);
}
private _host!: EditorHost;
get dnd() {
return this.get(DndController);
}
@@ -94,7 +94,14 @@ export class BlockStdScope {
}
get host() {
return this._getHost();
if (!this._host) {
throw new BlockSuiteError(
ErrorCode.ValueNotExists,
'Host is not ready to use, the `render` method should be called first'
);
}
return this._host;
}
get range() {
@@ -110,12 +117,6 @@ export class BlockStdScope {
}
constructor(options: BlockStdOptions) {
this._getHost = () => {
throw new BlockSuiteError(
ErrorCode.ValueNotExists,
'Host is not ready to use, the `render` method should be called first'
);
};
this.store = options.store;
this.userExtensions = options.extensions;
this.container = new Container();
@@ -190,7 +191,7 @@ export class BlockStdScope {
const element = new EditorHost();
element.std = this;
element.doc = this.store;
this._getHost = () => element;
this._host = element;
this._lifeCycleWatchers.forEach(watcher => {
watcher.rendered.call(watcher);
});
@@ -202,7 +203,6 @@ export class BlockStdScope {
this._lifeCycleWatchers.forEach(watcher => {
watcher.unmounted.call(watcher);
});
this._getHost = () => null as unknown as EditorHost;
}
}
@@ -255,13 +255,13 @@ export class Transformer {
this._flattenSnapshot(tmpRootSnapshot, flatSnapshots, parent, index);
const blockTree = await this._convertFlatSnapshots(flatSnapshots);
const first = content[0];
// check if the slice is already in the doc
if (first && doc.hasBlock(first.id)) {
// if the slice is already in the doc, we need to move the blocks instead of adding them
const models = flatSnapshots
.map(flat => doc.getBlock(flat.snapshot.id)?.model)
const models = content
.map(block => doc.getBlock(block.id)?.model)
.filter(Boolean) as BlockModel[];
const parentModel = parent ? doc.getBlock(parent)?.model : undefined;
if (!parentModel) {
@@ -117,13 +117,11 @@ function createNewNote(host: EditorHost): AIItemConfig {
const newNote = doc.getBlock(noteBlockId)?.model;
if (!newNote || !matchFlavours(newNote, ['affine:note'])) return;
const newNoteBound = Bound.deserialize(newNote.xywh);
const bounds = [bound, newNoteBound];
const { zoom, centerX, centerY } = service.getFitToScreenData(
[20, 20, 20, 20],
bounds
);
service.viewport.setViewport(zoom, [centerX, centerY]);
service.gfx.fitToScreen({
bounds,
padding: [20, 20, 20, 20],
});
})
.catch(err => {
console.error(err);
@@ -62,8 +62,7 @@ export const PPTBuilder = (host: EditorHost) => {
const block = snapshot.snapshot.content[0];
for (const child of block.children) {
await addDoc(child);
const { centerX, centerY, zoom } = service.getFitToScreenData();
service.viewport.setViewport(zoom, [centerX, centerY]);
service.gfx.fitToScreen();
}
} catch (e) {
console.error(e);
@@ -64,12 +64,9 @@ function fitViewport(
false
);
} else {
const data = rootService.getFitToScreenData();
rootService.viewport.setViewport(
data.zoom,
[data.centerX, data.centerY],
false
);
rootService.gfx.fitToScreen({
smooth: false,
});
}
} catch (e) {
logger.warn('failed to fitViewPort', e);