mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 21:27:20 +00:00
feat(editor): add gfx pointer extension (#12006)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Introduced a new pointer graphics module with tools and quick tool integration for edgeless surfaces. - Added a quick tool button for pointer interactions in edgeless mode. - Exposed new extension points for pointer graphics and effects. - **Improvements** - Integrated pointer graphics as a dependency into related packages. - Enhanced toolbar context to support additional surface alignment modes. - Added conditional clipboard configuration registrations for edgeless contexts across multiple block types. - **Removals** - Removed legacy tool and effect definitions and related quick tool exports from edgeless components. - Streamlined extension arrays and removed unused exports for a cleaner codebase. - Deleted obsolete utility functions and component registrations. - **Chores** - Updated workspace and TypeScript project references to include the new pointer graphics module. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
DefaultModeDragType,
|
||||
DefaultTool,
|
||||
} from '@blocksuite/affine-gfx-pointer';
|
||||
import type { RootBlockModel } from '@blocksuite/affine-model';
|
||||
import { WidgetComponent } from '@blocksuite/std';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
|
||||
@@ -5,11 +9,6 @@ import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { css, html, nothing, unsafeCSS } from 'lit';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import {
|
||||
DefaultModeDragType,
|
||||
DefaultTool,
|
||||
} from '../../gfx-tool/default-tool.js';
|
||||
|
||||
export const EDGELESS_DRAGGING_AREA_WIDGET = 'edgeless-dragging-area-rect';
|
||||
|
||||
export class EdgelessDraggingAreaRectWidget extends WidgetComponent<RootBlockModel> {
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import { QuickToolMixin } from '@blocksuite/affine-widget-edgeless-toolbar';
|
||||
import { HandIcon, SelectIcon } from '@blocksuite/icons/lit';
|
||||
import type { GfxToolsFullOptionValue } from '@blocksuite/std/gfx';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
import { query } from 'lit/decorators.js';
|
||||
|
||||
export class EdgelessDefaultToolButton extends QuickToolMixin(LitElement) {
|
||||
static override styles = css`
|
||||
.current-icon {
|
||||
transition: 100ms;
|
||||
}
|
||||
.current-icon > svg {
|
||||
display: block;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
`;
|
||||
|
||||
override type: GfxToolsFullOptionValue['type'][] = ['default', 'pan'];
|
||||
|
||||
private _changeTool() {
|
||||
if (this.toolbar.activePopper) {
|
||||
// click manually always closes the popper
|
||||
this.toolbar.activePopper.dispose();
|
||||
}
|
||||
const type = this.edgelessTool?.type;
|
||||
if (type !== 'default' && type !== 'pan') {
|
||||
if (localStorage.defaultTool === 'default') {
|
||||
this.setEdgelessTool('default');
|
||||
} else if (localStorage.defaultTool === 'pan') {
|
||||
this.setEdgelessTool('pan', { panning: false });
|
||||
}
|
||||
return;
|
||||
}
|
||||
this._fadeOut();
|
||||
// wait for animation to finish
|
||||
setTimeout(() => {
|
||||
if (type === 'default') {
|
||||
this.setEdgelessTool('pan', { panning: false });
|
||||
} else if (type === 'pan') {
|
||||
this.setEdgelessTool('default');
|
||||
}
|
||||
this._fadeIn();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
private _fadeIn() {
|
||||
this.currentIcon.style.opacity = '1';
|
||||
this.currentIcon.style.transform = `translateY(0px)`;
|
||||
}
|
||||
|
||||
private _fadeOut() {
|
||||
this.currentIcon.style.opacity = '0';
|
||||
this.currentIcon.style.transform = `translateY(-5px)`;
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
if (!localStorage.defaultTool) {
|
||||
localStorage.defaultTool = 'default';
|
||||
}
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
const tool = this.gfx.tool.currentToolName$.value;
|
||||
if (tool === 'default' || tool === 'pan') {
|
||||
localStorage.defaultTool = tool;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override render() {
|
||||
const type = this.edgelessTool?.type;
|
||||
const { active } = this;
|
||||
const tipInfo =
|
||||
type === 'pan'
|
||||
? { tip: 'Hand', shortcut: 'H' }
|
||||
: { tip: 'Select', shortcut: 'V' };
|
||||
return html`
|
||||
<edgeless-tool-icon-button
|
||||
class="edgeless-default-button ${type}"
|
||||
.tooltip=${html`<affine-tooltip-content-with-shortcut
|
||||
data-tip="${tipInfo.tip}"
|
||||
data-shortcut="${tipInfo.shortcut}"
|
||||
></affine-tooltip-content-with-shortcut>`}
|
||||
.tooltipOffset=${17}
|
||||
.active=${active}
|
||||
.iconContainerPadding=${6}
|
||||
.iconSize=${'24px'}
|
||||
@click=${this._changeTool}
|
||||
>
|
||||
<div class="current-icon">
|
||||
${localStorage.defaultTool === 'default' ? SelectIcon() : HandIcon()}
|
||||
</div>
|
||||
<toolbar-arrow-up-icon></toolbar-arrow-up-icon>
|
||||
</edgeless-tool-icon-button>
|
||||
`;
|
||||
}
|
||||
|
||||
@query('.current-icon')
|
||||
accessor currentIcon!: HTMLInputElement;
|
||||
}
|
||||
@@ -3,16 +3,6 @@ import { html } from 'lit';
|
||||
|
||||
import { buildLinkDenseMenu } from './link/link-dense-menu.js';
|
||||
|
||||
const defaultQuickTool = QuickToolExtension('default', ({ block }) => {
|
||||
return {
|
||||
priority: 100,
|
||||
type: 'default',
|
||||
content: html`<edgeless-default-tool-button
|
||||
.edgeless=${block}
|
||||
></edgeless-default-tool-button>`,
|
||||
};
|
||||
});
|
||||
|
||||
const linkQuickTool = QuickToolExtension('link', ({ block, gfx }) => {
|
||||
return {
|
||||
content: html`<edgeless-link-tool-button
|
||||
@@ -22,4 +12,4 @@ const linkQuickTool = QuickToolExtension('link', ({ block, gfx }) => {
|
||||
};
|
||||
});
|
||||
|
||||
export const quickTools = [defaultQuickTool, linkQuickTool];
|
||||
export const quickTools = [linkQuickTool];
|
||||
|
||||
@@ -9,21 +9,6 @@ export function generateCursorUrl(
|
||||
return `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 32 32'%3E%3Cg transform='rotate(${angle} 16 16)'%3E%3Cpath fill='white' d='M13.7,18.5h3.9l0-1.5c0-1.4-1.2-2.6-2.6-2.6h-1.5v3.9l-5.8-5.8l5.8-5.8v3.9h2.3c3.1,0,5.6,2.5,5.6,5.6v2.3h3.9l-5.8,5.8L13.7,18.5z'/%3E%3Cpath d='M20.4,19.4v-3.2c0-2.6-2.1-4.7-4.7-4.7h-3.2l0,0V9L9,12.6l3.6,3.6v-2.6l0,0H15c1.9,0,3.5,1.6,3.5,3.5v2.4l0,0h-2.6l3.6,3.6l3.6-3.6L20.4,19.4L20.4,19.4z'/%3E%3C/g%3E%3C/svg%3E") 16 16, ${fallback}`;
|
||||
}
|
||||
|
||||
export function getCommonRectStyle(
|
||||
rect: DOMRect,
|
||||
active = false,
|
||||
selected = false,
|
||||
rotate = 0
|
||||
) {
|
||||
return {
|
||||
'--affine-border-width': `${active ? 2 : 1}px`,
|
||||
width: `${rect.width}px`,
|
||||
height: `${rect.height}px`,
|
||||
transform: `translate(${rect.x}px, ${rect.y}px) rotate(${rotate}deg)`,
|
||||
backgroundColor: !active && selected ? 'var(--affine-hover-color)' : '',
|
||||
};
|
||||
}
|
||||
|
||||
const RESIZE_CURSORS: CursorType[] = [
|
||||
'ew-resize',
|
||||
'nwse-resize',
|
||||
|
||||
@@ -1,45 +1,19 @@
|
||||
import type * as BrushEffect from '@blocksuite/affine-gfx-brush';
|
||||
import type * as NoteEffect from '@blocksuite/affine-gfx-note';
|
||||
import type * as ShapeEffect from '@blocksuite/affine-gfx-shape';
|
||||
import { InteractivityManager } from '@blocksuite/std/gfx';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
|
||||
import { EdgelessElementToolbarExtension } from './configs/toolbar';
|
||||
import { EdgelessRootBlockSpec } from './edgeless-root-spec.js';
|
||||
import { DefaultTool } from './gfx-tool/default-tool.js';
|
||||
import { EmptyTool } from './gfx-tool/empty-tool.js';
|
||||
import { PanTool } from './gfx-tool/pan-tool.js';
|
||||
import { AltCloneExtension } from './interact-extensions/clone-ext.js';
|
||||
import { SnapExtension } from './interact-extensions/snap-manager.js';
|
||||
import { EditPropsMiddlewareBuilder } from './middlewares/base.js';
|
||||
import { SnapOverlay } from './utils/snap-manager.js';
|
||||
|
||||
declare type _GLOBAL_ =
|
||||
| typeof NoteEffect
|
||||
| typeof BrushEffect
|
||||
| typeof ShapeEffect;
|
||||
|
||||
export const EdgelessToolExtension: ExtensionType[] = [
|
||||
DefaultTool,
|
||||
PanTool,
|
||||
EmptyTool,
|
||||
];
|
||||
|
||||
export const EdgelessEditExtensions: ExtensionType[] = [
|
||||
InteractivityManager,
|
||||
SnapExtension,
|
||||
];
|
||||
export const EdgelessEditExtensions: ExtensionType[] = [InteractivityManager];
|
||||
|
||||
export const EdgelessBuiltInManager: ExtensionType[] = [
|
||||
SnapOverlay,
|
||||
AltCloneExtension,
|
||||
EditPropsMiddlewareBuilder,
|
||||
EdgelessElementToolbarExtension,
|
||||
].flat();
|
||||
|
||||
export const EdgelessBuiltInSpecs: ExtensionType[] = [
|
||||
EdgelessRootBlockSpec,
|
||||
EdgelessToolExtension,
|
||||
EdgelessBuiltInManager,
|
||||
EdgelessEditExtensions,
|
||||
].flat();
|
||||
|
||||
@@ -1,19 +1,3 @@
|
||||
import { EdgelessClipboardAttachmentConfig } from '@blocksuite/affine-block-attachment';
|
||||
import { EdgelessClipboardBookmarkConfig } from '@blocksuite/affine-block-bookmark';
|
||||
import { EdgelessClipboardEdgelessTextConfig } from '@blocksuite/affine-block-edgeless-text';
|
||||
import {
|
||||
EdgelessClipboardEmbedFigmaConfig,
|
||||
EdgelessClipboardEmbedGithubConfig,
|
||||
EdgelessClipboardEmbedHtmlConfig,
|
||||
EdgelessClipboardEmbedIframeConfig,
|
||||
EdgelessClipboardEmbedLinkedDocConfig,
|
||||
EdgelessClipboardEmbedLoomConfig,
|
||||
EdgelessClipboardEmbedSyncedDocConfig,
|
||||
EdgelessClipboardEmbedYoutubeConfig,
|
||||
} from '@blocksuite/affine-block-embed';
|
||||
import { EdgelessClipboardFrameConfig } from '@blocksuite/affine-block-frame';
|
||||
import { EdgelessClipboardImageConfig } from '@blocksuite/affine-block-image';
|
||||
import { EdgelessClipboardNoteConfig } from '@blocksuite/affine-block-note';
|
||||
import { ViewportElementExtension } from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
BlockViewExtension,
|
||||
@@ -63,30 +47,12 @@ class EdgelessLocker extends LifeCycleWatcher {
|
||||
}
|
||||
}
|
||||
|
||||
const EdgelessClipboardConfigs: ExtensionType[] = [
|
||||
EdgelessClipboardNoteConfig,
|
||||
EdgelessClipboardEdgelessTextConfig,
|
||||
EdgelessClipboardImageConfig,
|
||||
EdgelessClipboardFrameConfig,
|
||||
EdgelessClipboardAttachmentConfig,
|
||||
EdgelessClipboardBookmarkConfig,
|
||||
EdgelessClipboardEmbedFigmaConfig,
|
||||
EdgelessClipboardEmbedGithubConfig,
|
||||
EdgelessClipboardEmbedHtmlConfig,
|
||||
EdgelessClipboardEmbedLoomConfig,
|
||||
EdgelessClipboardEmbedYoutubeConfig,
|
||||
EdgelessClipboardEmbedIframeConfig,
|
||||
EdgelessClipboardEmbedLinkedDocConfig,
|
||||
EdgelessClipboardEmbedSyncedDocConfig,
|
||||
];
|
||||
|
||||
const EdgelessCommonExtension: ExtensionType[] = [
|
||||
CommonSpecs,
|
||||
ToolController,
|
||||
EdgelessRootService,
|
||||
ViewportElementExtension('.affine-edgeless-viewport'),
|
||||
...quickTools,
|
||||
...EdgelessClipboardConfigs,
|
||||
].flat();
|
||||
|
||||
export const EdgelessRootBlockSpec: ExtensionType[] = [
|
||||
|
||||
@@ -1,424 +0,0 @@
|
||||
import { resetNativeSelection } from '@blocksuite/affine-shared/utils';
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import type { IVec } from '@blocksuite/global/gfx';
|
||||
import type { PointerEventState } from '@blocksuite/std';
|
||||
import {
|
||||
BaseTool,
|
||||
type GfxModel,
|
||||
InteractivityIdentifier,
|
||||
isGfxGroupCompatibleModel,
|
||||
} from '@blocksuite/std/gfx';
|
||||
import { effect } from '@preact/signals-core';
|
||||
|
||||
import { calPanDelta } from '../utils/panning-utils.js';
|
||||
|
||||
export enum DefaultModeDragType {
|
||||
/** Moving selected contents */
|
||||
ContentMoving = 'content-moving',
|
||||
/** Native range dragging inside active note block */
|
||||
NativeEditing = 'native-editing',
|
||||
/** Default void state */
|
||||
None = 'none',
|
||||
/** Expanding the dragging area, select the content covered inside */
|
||||
Selecting = 'selecting',
|
||||
}
|
||||
|
||||
export class DefaultTool extends BaseTool {
|
||||
static override toolName: string = 'default';
|
||||
|
||||
private _accumulateDelta: IVec = [0, 0];
|
||||
|
||||
private _autoPanTimer: number | null = null;
|
||||
|
||||
private readonly _clearDisposable = () => {
|
||||
if (this._disposables) {
|
||||
this._disposables.dispose();
|
||||
this._disposables = null;
|
||||
}
|
||||
};
|
||||
|
||||
private readonly _clearSelectingState = () => {
|
||||
this._stopAutoPanning();
|
||||
this._clearDisposable();
|
||||
};
|
||||
|
||||
private _disposables: DisposableGroup | null = null;
|
||||
|
||||
private _panViewport(delta: IVec) {
|
||||
this._accumulateDelta[0] += delta[0];
|
||||
this._accumulateDelta[1] += delta[1];
|
||||
this.gfx.viewport.applyDeltaCenter(delta[0], delta[1]);
|
||||
}
|
||||
|
||||
private _selectionRectTransition: null | {
|
||||
w: number;
|
||||
h: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
endX: number;
|
||||
endY: number;
|
||||
} = null;
|
||||
|
||||
private readonly _startAutoPanning = (delta: IVec) => {
|
||||
this._panViewport(delta);
|
||||
this._updateSelectingState(delta);
|
||||
this._stopAutoPanning();
|
||||
|
||||
this._autoPanTimer = window.setInterval(() => {
|
||||
this._panViewport(delta);
|
||||
this._updateSelectingState(delta);
|
||||
}, 30);
|
||||
};
|
||||
|
||||
private readonly _stopAutoPanning = () => {
|
||||
if (this._autoPanTimer) {
|
||||
clearTimeout(this._autoPanTimer);
|
||||
this._autoPanTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
private _toBeMoved: GfxModel[] = [];
|
||||
|
||||
private readonly _updateSelectingState = (delta: IVec = [0, 0]) => {
|
||||
const { gfx } = this;
|
||||
|
||||
if (gfx.keyboard.spaceKey$.peek() && this._selectionRectTransition) {
|
||||
/* Move the selection if space is pressed */
|
||||
const curDraggingViewArea = this.controller.draggingViewArea$.peek();
|
||||
const { w, h, startX, startY, endX, endY } =
|
||||
this._selectionRectTransition;
|
||||
const { endX: lastX, endY: lastY } = curDraggingViewArea;
|
||||
|
||||
const dx = lastX + delta[0] - endX + this._accumulateDelta[0];
|
||||
const dy = lastY + delta[1] - endY + this._accumulateDelta[1];
|
||||
|
||||
this.controller.draggingViewArea$.value = {
|
||||
...curDraggingViewArea,
|
||||
x: Math.min(startX + dx, lastX),
|
||||
y: Math.min(startY + dy, lastY),
|
||||
w,
|
||||
h,
|
||||
startX: startX + dx,
|
||||
startY: startY + dy,
|
||||
};
|
||||
} else {
|
||||
const curDraggingArea = this.controller.draggingViewArea$.peek();
|
||||
const newStartX = curDraggingArea.startX - delta[0];
|
||||
const newStartY = curDraggingArea.startY - delta[1];
|
||||
|
||||
this.controller.draggingViewArea$.value = {
|
||||
...curDraggingArea,
|
||||
startX: newStartX,
|
||||
startY: newStartY,
|
||||
x: Math.min(newStartX, curDraggingArea.endX),
|
||||
y: Math.min(newStartY, curDraggingArea.endY),
|
||||
w: Math.abs(curDraggingArea.endX - newStartX),
|
||||
h: Math.abs(curDraggingArea.endY - newStartY),
|
||||
};
|
||||
}
|
||||
|
||||
const elements = this.interactivity?.handleBoxSelection({
|
||||
box: this.controller.draggingArea$.peek(),
|
||||
});
|
||||
|
||||
if (!elements) return;
|
||||
|
||||
this.selection.set({
|
||||
elements: elements.map(el => el.id),
|
||||
editing: false,
|
||||
});
|
||||
};
|
||||
|
||||
dragType = DefaultModeDragType.None;
|
||||
|
||||
movementDragging = false;
|
||||
|
||||
/**
|
||||
* Get the end position of the dragging area in the model coordinate
|
||||
*/
|
||||
get dragLastPos() {
|
||||
const { endX, endY } = this.controller.draggingArea$.peek();
|
||||
|
||||
return [endX, endY] as IVec;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the start position of the dragging area in the model coordinate
|
||||
*/
|
||||
get dragStartPos() {
|
||||
const { startX, startY } = this.controller.draggingArea$.peek();
|
||||
|
||||
return [startX, startY] as IVec;
|
||||
}
|
||||
|
||||
get selection() {
|
||||
return this.gfx.selection;
|
||||
}
|
||||
|
||||
get interactivity() {
|
||||
return this.std.getOptional(InteractivityIdentifier);
|
||||
}
|
||||
|
||||
private async _cloneContent() {
|
||||
const clonedResult = await this.interactivity?.requestElementClone({
|
||||
elements: this._toBeMoved,
|
||||
});
|
||||
|
||||
if (!clonedResult) return;
|
||||
|
||||
this._toBeMoved = clonedResult.elements;
|
||||
this.selection.set({
|
||||
elements: this._toBeMoved.map(e => e.id),
|
||||
editing: false,
|
||||
});
|
||||
}
|
||||
|
||||
private _determineDragType(evt: PointerEventState): DefaultModeDragType {
|
||||
const { x, y } = this.controller.lastMouseModelPos$.peek();
|
||||
if (this.selection.isInSelectedRect(x, y)) {
|
||||
if (this.selection.selectedElements.length === 1) {
|
||||
const currentHoveredElem = this._getElementInGroup(x, y);
|
||||
let curSelected = this.selection.selectedElements[0];
|
||||
|
||||
// If one of the following condition is true, keep the selection:
|
||||
// 1. if group is currently selected
|
||||
// 2. if the selected element is descendant of the hovered element
|
||||
// 3. not hovering any element or hovering the same element
|
||||
//
|
||||
// Otherwise, we update the selection to the current hovered element
|
||||
const shouldKeepSelection =
|
||||
isGfxGroupCompatibleModel(curSelected) ||
|
||||
(isGfxGroupCompatibleModel(currentHoveredElem) &&
|
||||
currentHoveredElem.hasDescendant(curSelected)) ||
|
||||
!currentHoveredElem ||
|
||||
currentHoveredElem === curSelected;
|
||||
|
||||
if (!shouldKeepSelection) {
|
||||
curSelected = currentHoveredElem;
|
||||
this.selection.set({
|
||||
elements: [curSelected.id],
|
||||
editing: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return this.selection.editing
|
||||
? DefaultModeDragType.NativeEditing
|
||||
: DefaultModeDragType.ContentMoving;
|
||||
} else {
|
||||
const checked = this.interactivity?.handleElementSelection(evt);
|
||||
|
||||
if (checked) {
|
||||
return DefaultModeDragType.ContentMoving;
|
||||
} else {
|
||||
return DefaultModeDragType.Selecting;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _getElementInGroup(modelX: number, modelY: number) {
|
||||
const tryGetLockedAncestor = (e: GfxModel | null) => {
|
||||
if (e?.isLockedByAncestor()) {
|
||||
return e.groups.findLast(group => group.isLocked());
|
||||
}
|
||||
return e;
|
||||
};
|
||||
|
||||
return tryGetLockedAncestor(this.gfx.getElementInGroup(modelX, modelY));
|
||||
}
|
||||
|
||||
private initializeDragState(
|
||||
dragType: DefaultModeDragType,
|
||||
event: PointerEventState
|
||||
) {
|
||||
this.dragType = dragType;
|
||||
|
||||
this._clearDisposable();
|
||||
this._disposables = new DisposableGroup();
|
||||
|
||||
// If the drag type is selecting, set up the dragging area disposable group
|
||||
// If the viewport updates when dragging, should update the dragging area and selection
|
||||
if (this.dragType === DefaultModeDragType.Selecting) {
|
||||
this._disposables.add(
|
||||
this.gfx.viewport.viewportUpdated.subscribe(() => {
|
||||
if (
|
||||
this.dragType === DefaultModeDragType.Selecting &&
|
||||
this.controller.dragging$.peek() &&
|
||||
!this._autoPanTimer
|
||||
) {
|
||||
this._updateSelectingState();
|
||||
}
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.dragType === DefaultModeDragType.ContentMoving) {
|
||||
if (this.interactivity) {
|
||||
this.doc.captureSync();
|
||||
this.interactivity.handleElementMove({
|
||||
movingElements: this._toBeMoved,
|
||||
event: event.raw,
|
||||
onDragEnd: () => {
|
||||
this.doc.captureSync();
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
override click(e: PointerEventState) {
|
||||
if (this.doc.readonly) return;
|
||||
|
||||
if (!this.interactivity?.handleElementSelection(e)) {
|
||||
this.selection.clear();
|
||||
resetNativeSelection(null);
|
||||
}
|
||||
|
||||
this.interactivity?.dispatchEvent('click', e);
|
||||
}
|
||||
|
||||
override deactivate() {
|
||||
this._stopAutoPanning();
|
||||
this._clearDisposable();
|
||||
this._accumulateDelta = [0, 0];
|
||||
}
|
||||
|
||||
override doubleClick(e: PointerEventState) {
|
||||
if (this.doc.readonly) {
|
||||
const viewport = this.gfx.viewport;
|
||||
if (viewport.zoom === 1) {
|
||||
this.gfx.fitToScreen();
|
||||
} else {
|
||||
// Zoom to 100% and Center
|
||||
const [x, y] = viewport.toModelCoord(e.x, e.y);
|
||||
viewport.setViewport(1, [x, y], true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.interactivity?.dispatchEvent('dblclick', e);
|
||||
}
|
||||
|
||||
override dragEnd(e: PointerEventState) {
|
||||
this.interactivity?.dispatchEvent('dragend', e);
|
||||
|
||||
if (this.selection.editing || !this.movementDragging) return;
|
||||
|
||||
this.movementDragging = false;
|
||||
this._toBeMoved = [];
|
||||
this._clearSelectingState();
|
||||
this.dragType = DefaultModeDragType.None;
|
||||
}
|
||||
|
||||
override dragMove(e: PointerEventState) {
|
||||
this.interactivity?.dispatchEvent('dragmove', e);
|
||||
|
||||
if (!this.movementDragging) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { viewport } = this.gfx;
|
||||
switch (this.dragType) {
|
||||
case DefaultModeDragType.Selecting: {
|
||||
// Record the last drag pointer position for auto panning and view port updating
|
||||
|
||||
this._updateSelectingState();
|
||||
const moveDelta = calPanDelta(viewport, e);
|
||||
if (moveDelta) {
|
||||
this._startAutoPanning(moveDelta);
|
||||
} else {
|
||||
this._stopAutoPanning();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case DefaultModeDragType.ContentMoving: {
|
||||
break;
|
||||
}
|
||||
case DefaultModeDragType.NativeEditing: {
|
||||
// TODO reset if drag out of note
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
override async dragStart(e: PointerEventState) {
|
||||
const { preventDefaultState, handledByView } =
|
||||
this.interactivity?.dispatchEvent('dragstart', e) ?? {};
|
||||
|
||||
if (this.selection.editing || preventDefaultState || handledByView) return;
|
||||
|
||||
this.movementDragging = true;
|
||||
|
||||
// Determine the drag type based on the current state and event
|
||||
let dragType = this._determineDragType(e);
|
||||
|
||||
const elements = this.selection.selectedElements;
|
||||
if (elements.some(e => e.isLocked())) return;
|
||||
|
||||
const toBeMoved = new Set(elements);
|
||||
|
||||
elements.forEach(element => {
|
||||
if (isGfxGroupCompatibleModel(element)) {
|
||||
element.descendantElements.forEach(ele => {
|
||||
toBeMoved.add(ele);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this._toBeMoved = Array.from(toBeMoved);
|
||||
|
||||
// If alt key is pressed and content is moving, clone the content
|
||||
if (dragType === DefaultModeDragType.ContentMoving && e.keys.alt) {
|
||||
await this._cloneContent();
|
||||
}
|
||||
|
||||
// Set up drag state
|
||||
this.initializeDragState(dragType, e);
|
||||
}
|
||||
|
||||
override mounted() {
|
||||
this.disposable.add(
|
||||
effect(() => {
|
||||
const pressed = this.gfx.keyboard.spaceKey$.value;
|
||||
|
||||
if (pressed) {
|
||||
const currentDraggingArea = this.controller.draggingViewArea$.peek();
|
||||
|
||||
this._selectionRectTransition = {
|
||||
w: currentDraggingArea.w,
|
||||
h: currentDraggingArea.h,
|
||||
startX: currentDraggingArea.startX,
|
||||
startY: currentDraggingArea.startY,
|
||||
endX: currentDraggingArea.endX,
|
||||
endY: currentDraggingArea.endY,
|
||||
};
|
||||
} else {
|
||||
this._selectionRectTransition = null;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override pointerDown(e: PointerEventState): void {
|
||||
this.interactivity?.dispatchEvent('pointerdown', e);
|
||||
}
|
||||
|
||||
override pointerMove(e: PointerEventState) {
|
||||
this.interactivity?.dispatchEvent('pointermove', e);
|
||||
}
|
||||
|
||||
override pointerUp(e: PointerEventState) {
|
||||
this.interactivity?.dispatchEvent('pointerup', e);
|
||||
}
|
||||
|
||||
override unmounted(): void {}
|
||||
}
|
||||
|
||||
declare module '@blocksuite/std/gfx' {
|
||||
interface GfxToolsMap {
|
||||
default: DefaultTool;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { BaseTool } from '@blocksuite/std/gfx';
|
||||
|
||||
/**
|
||||
* Empty tool that does nothing.
|
||||
*/
|
||||
export class EmptyTool extends BaseTool {
|
||||
static override toolName: string = 'empty';
|
||||
}
|
||||
|
||||
declare module '@blocksuite/std/gfx' {
|
||||
interface GfxToolsMap {
|
||||
empty: EmptyTool;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export { DefaultTool } from './default-tool.js';
|
||||
export { EmptyTool } from './empty-tool.js';
|
||||
export { PanTool, type PanToolOption } from './pan-tool.js';
|
||||
@@ -1,87 +0,0 @@
|
||||
import { on } from '@blocksuite/affine-shared/utils';
|
||||
import type { PointerEventState } from '@blocksuite/std';
|
||||
import { BaseTool, MouseButton } from '@blocksuite/std/gfx';
|
||||
import { Signal } from '@preact/signals-core';
|
||||
|
||||
export type PanToolOption = {
|
||||
panning: boolean;
|
||||
};
|
||||
|
||||
export class PanTool extends BaseTool<PanToolOption> {
|
||||
static override toolName = 'pan';
|
||||
|
||||
private _lastPoint: [number, number] | null = null;
|
||||
|
||||
readonly panning$ = new Signal<boolean>(false);
|
||||
|
||||
override get allowDragWithRightButton(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
override dragEnd(_: PointerEventState): void {
|
||||
this._lastPoint = null;
|
||||
this.panning$.value = false;
|
||||
}
|
||||
|
||||
override dragMove(e: PointerEventState): void {
|
||||
if (!this._lastPoint) return;
|
||||
|
||||
const { viewport } = this.gfx;
|
||||
const { zoom } = viewport;
|
||||
|
||||
const [lastX, lastY] = this._lastPoint;
|
||||
const deltaX = lastX - e.x;
|
||||
const deltaY = lastY - e.y;
|
||||
|
||||
this._lastPoint = [e.x, e.y];
|
||||
|
||||
viewport.applyDeltaCenter(deltaX / zoom, deltaY / zoom);
|
||||
}
|
||||
|
||||
override dragStart(e: PointerEventState): void {
|
||||
this._lastPoint = [e.x, e.y];
|
||||
this.panning$.value = true;
|
||||
}
|
||||
|
||||
override mounted(): void {
|
||||
this.addHook('pointerDown', evt => {
|
||||
const shouldPanWithMiddle = evt.raw.button === MouseButton.MIDDLE;
|
||||
|
||||
if (!shouldPanWithMiddle) {
|
||||
return;
|
||||
}
|
||||
|
||||
evt.raw.preventDefault();
|
||||
|
||||
const selection = this.gfx.selection.surfaceSelections;
|
||||
const currentTool = this.controller.currentToolOption$.peek();
|
||||
const restoreToPrevious = () => {
|
||||
this.controller.setTool(currentTool);
|
||||
this.gfx.selection.set(selection);
|
||||
};
|
||||
|
||||
this.controller.setTool('pan', {
|
||||
panning: true,
|
||||
});
|
||||
|
||||
const dispose = on(document, 'pointerup', evt => {
|
||||
if (evt.button === MouseButton.MIDDLE) {
|
||||
restoreToPrevious();
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@blocksuite/std/gfx' {
|
||||
interface GfxToolsMap {
|
||||
pan: PanTool;
|
||||
}
|
||||
|
||||
interface GfxToolsOption {
|
||||
pan: PanToolOption;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ export * from './clipboard/command';
|
||||
export * from './edgeless-root-block.js';
|
||||
export { EdgelessRootPreviewBlockComponent } from './edgeless-root-preview-block.js';
|
||||
export { EdgelessRootService } from './edgeless-root-service.js';
|
||||
export * from './gfx-tool';
|
||||
export * from './utils/clipboard-utils.js';
|
||||
export { sortEdgelessElements } from './utils/clone-utils.js';
|
||||
export { isCanvasElement } from './utils/query.js';
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { OverlayIdentifier } from '@blocksuite/affine-block-surface';
|
||||
import { MindmapElementModel } from '@blocksuite/affine-model';
|
||||
import type { Bound } from '@blocksuite/global/gfx';
|
||||
import {
|
||||
type DragExtensionInitializeContext,
|
||||
type ExtensionDragMoveContext,
|
||||
type GfxModel,
|
||||
InteractivityExtension,
|
||||
} from '@blocksuite/std/gfx';
|
||||
|
||||
import type { SnapOverlay } from '../utils/snap-manager';
|
||||
|
||||
export class SnapExtension extends InteractivityExtension {
|
||||
static override key = 'snap-manager';
|
||||
|
||||
get snapOverlay() {
|
||||
return this.std.getOptional(
|
||||
OverlayIdentifier('snap-manager')
|
||||
) as SnapOverlay;
|
||||
}
|
||||
|
||||
override mounted(): void {
|
||||
this.action.onDragInitialize(
|
||||
(initContext: DragExtensionInitializeContext) => {
|
||||
const snapOverlay = this.snapOverlay;
|
||||
|
||||
if (!snapOverlay) {
|
||||
return {};
|
||||
}
|
||||
|
||||
let alignBound: Bound;
|
||||
|
||||
return {
|
||||
onDragStart() {
|
||||
alignBound = snapOverlay.setMovingElements(
|
||||
initContext.elements,
|
||||
initContext.elements.reduce((pre, elem) => {
|
||||
if (elem.group instanceof MindmapElementModel) {
|
||||
pre.push(elem.group);
|
||||
}
|
||||
|
||||
return pre;
|
||||
}, [] as GfxModel[])
|
||||
);
|
||||
},
|
||||
onDragMove(context: ExtensionDragMoveContext) {
|
||||
if (
|
||||
context.elements.length === 0 ||
|
||||
alignBound.w === 0 ||
|
||||
alignBound.h === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentBound = alignBound.moveDelta(context.dx, context.dy);
|
||||
const alignRst = snapOverlay.align(currentBound);
|
||||
|
||||
context.dx = alignRst.dx + context.dx;
|
||||
context.dy = alignRst.dy + context.dy;
|
||||
},
|
||||
onDragEnd() {
|
||||
snapOverlay.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { getLastPropsKey } from '@blocksuite/affine-block-surface';
|
||||
import { EditPropsStore } from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
type SurfaceMiddleware,
|
||||
SurfaceMiddlewareBuilder,
|
||||
} from '@blocksuite/std/gfx';
|
||||
|
||||
export class EditPropsMiddlewareBuilder extends SurfaceMiddlewareBuilder {
|
||||
static override key = 'editProps';
|
||||
|
||||
middleware: SurfaceMiddleware = ctx => {
|
||||
if (ctx.type === 'beforeAdd') {
|
||||
const { type, props } = ctx.payload;
|
||||
const key = getLastPropsKey(type, props);
|
||||
const nProps = key
|
||||
? this.std.get(EditPropsStore).applyLastProps(key, ctx.payload.props)
|
||||
: null;
|
||||
|
||||
ctx.payload.props = {
|
||||
...(nProps ?? props),
|
||||
index: props.index ?? this.gfx.layer.generateIndex(),
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import type { IVec } from '@blocksuite/global/gfx';
|
||||
import type { PointerEventState } from '@blocksuite/std';
|
||||
import type { Viewport } from '@blocksuite/std/gfx';
|
||||
|
||||
const PANNING_DISTANCE = 30;
|
||||
|
||||
export function calPanDelta(
|
||||
viewport: Viewport,
|
||||
e: PointerEventState,
|
||||
edgeDistance = 20
|
||||
): IVec | null {
|
||||
// Get viewport edge
|
||||
const { left, top } = viewport;
|
||||
const { width, height } = viewport;
|
||||
// Get pointer position
|
||||
let { x, y } = e;
|
||||
const { containerOffset } = e;
|
||||
x += containerOffset.x;
|
||||
y += containerOffset.y;
|
||||
// Check if pointer is near viewport edge
|
||||
const nearLeft = x < left + edgeDistance;
|
||||
const nearRight = x > left + width - edgeDistance;
|
||||
const nearTop = y < top + edgeDistance;
|
||||
const nearBottom = y > top + height - edgeDistance;
|
||||
// If pointer is not near viewport edge, return false
|
||||
if (!(nearLeft || nearRight || nearTop || nearBottom)) return null;
|
||||
|
||||
// Calculate move delta
|
||||
let deltaX = 0;
|
||||
let deltaY = 0;
|
||||
|
||||
// Use PANNING_DISTANCE to limit the max delta, avoid panning too fast
|
||||
if (nearLeft) {
|
||||
deltaX = Math.max(-PANNING_DISTANCE, x - (left + edgeDistance));
|
||||
} else if (nearRight) {
|
||||
deltaX = Math.min(PANNING_DISTANCE, x - (left + width - edgeDistance));
|
||||
}
|
||||
|
||||
if (nearTop) {
|
||||
deltaY = Math.max(-PANNING_DISTANCE, y - (top + edgeDistance));
|
||||
} else if (nearBottom) {
|
||||
deltaY = Math.min(PANNING_DISTANCE, y - (top + height - edgeDistance));
|
||||
}
|
||||
|
||||
return [deltaX, deltaY];
|
||||
}
|
||||
@@ -27,7 +27,6 @@ import type {
|
||||
GfxModel,
|
||||
GfxPrimitiveElementModel,
|
||||
GfxToolsFullOptionValue,
|
||||
Viewport,
|
||||
} from '@blocksuite/std/gfx';
|
||||
import type { BlockModel } from '@blocksuite/store';
|
||||
|
||||
@@ -105,14 +104,6 @@ export function isEmbedIframeBlock(element: BlockModel | GfxModel | null) {
|
||||
);
|
||||
}
|
||||
|
||||
export function isEmbeddedLinkBlock(element: BlockModel | GfxModel | null) {
|
||||
return (
|
||||
isEmbeddedBlock(element) &&
|
||||
!isEmbedSyncedDocBlock(element) &&
|
||||
!isEmbedLinkedDocBlock(element)
|
||||
);
|
||||
}
|
||||
|
||||
export function isEmbedGithubBlock(
|
||||
element: BlockModel | GfxModel | null
|
||||
): element is EmbedGithubModel {
|
||||
@@ -199,12 +190,6 @@ export function isConnectable(
|
||||
return !!element && element.connectable;
|
||||
}
|
||||
|
||||
export function getSelectionBoxBound(viewport: Viewport, bound: Bound) {
|
||||
const { w, h } = bound;
|
||||
const [x, y] = viewport.toViewCoord(bound.x, bound.y);
|
||||
return new DOMRect(x, y, w * viewport.zoom, h * viewport.zoom);
|
||||
}
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/CSS/cursor
|
||||
export function getCursorMode(edgelessTool: GfxToolsFullOptionValue | null) {
|
||||
if (!edgelessTool) {
|
||||
|
||||
@@ -1,762 +0,0 @@
|
||||
import { Overlay } from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
ConnectorElementModel,
|
||||
MindmapElementModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { almostEqual, Bound, Point } from '@blocksuite/global/gfx';
|
||||
import type { GfxModel } from '@blocksuite/std/gfx';
|
||||
|
||||
interface Distance {
|
||||
horiz?: {
|
||||
/**
|
||||
* the minimum x moving distance to align with other bound
|
||||
*/
|
||||
distance: number;
|
||||
|
||||
/**
|
||||
* the indices of the align position
|
||||
*/
|
||||
alignPositionIndices: number[];
|
||||
};
|
||||
|
||||
vert?: {
|
||||
/**
|
||||
* the minimum y moving distance to align with other bound
|
||||
*/
|
||||
distance: number;
|
||||
|
||||
/**
|
||||
* the indices of the align position
|
||||
*/
|
||||
alignPositionIndices: number[];
|
||||
};
|
||||
}
|
||||
|
||||
const ALIGN_THRESHOLD = 8;
|
||||
const DISTRIBUTION_LINE_OFFSET = 1;
|
||||
const STROKE_WIDTH = 2;
|
||||
|
||||
export class SnapOverlay extends Overlay {
|
||||
static override overlayName: string = 'snap-manager';
|
||||
|
||||
private _skippedElements: Set<GfxModel> = new Set();
|
||||
|
||||
private _referenceBounds: {
|
||||
vertical: Bound[];
|
||||
horizontal: Bound[];
|
||||
all: Bound[];
|
||||
} = {
|
||||
vertical: [],
|
||||
horizontal: [],
|
||||
all: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* This variable contains reference lines that are
|
||||
* generated by the 'Distribute Alignment' function. This alignment is achieved
|
||||
* by evenly distributing elements based on specified alignment rules.
|
||||
* These lines serve as a guide for achieving equal spacing or distribution
|
||||
* among multiple graphics or design elements.
|
||||
*/
|
||||
private _distributedAlignLines: [Point, Point][] = [];
|
||||
|
||||
/**
|
||||
* This variable holds reference lines that are calculated
|
||||
* based on the self-alignment of the graphics. This alignment is determined
|
||||
* according to various aspects of the graphic itself, such as the center, edges,
|
||||
* corners, etc. It essentially represents the guidelines for the positioning
|
||||
* and alignment within the individual graphic elements.
|
||||
*/
|
||||
private _intraGraphicAlignLines: {
|
||||
horizontal: [Point, Point][];
|
||||
vertical: [Point, Point][];
|
||||
} = {
|
||||
horizontal: [],
|
||||
vertical: [],
|
||||
};
|
||||
|
||||
override clear() {
|
||||
this._referenceBounds = {
|
||||
vertical: [],
|
||||
horizontal: [],
|
||||
all: [],
|
||||
};
|
||||
this._intraGraphicAlignLines = {
|
||||
horizontal: [],
|
||||
vertical: [],
|
||||
};
|
||||
this._distributedAlignLines = [];
|
||||
this._skippedElements.clear();
|
||||
|
||||
super.clear();
|
||||
}
|
||||
|
||||
private _alignDistributeHorizontally(
|
||||
rst: { dx: number; dy: number },
|
||||
bound: Bound,
|
||||
threshold: number,
|
||||
viewport: { zoom: number }
|
||||
) {
|
||||
const wBoxes: Bound[] = [];
|
||||
this._referenceBounds.horizontal.forEach(box => {
|
||||
if (box.isHorizontalCross(bound)) {
|
||||
wBoxes.push(box);
|
||||
}
|
||||
});
|
||||
|
||||
wBoxes.sort((a, b) => a.center[0] - b.center[0]);
|
||||
|
||||
let dif = Infinity;
|
||||
let min = Infinity;
|
||||
let aveDis = Number.MAX_SAFE_INTEGER;
|
||||
let curBound!: {
|
||||
leftIdx: number;
|
||||
rightIdx: number;
|
||||
spacing: number;
|
||||
points: [Point, Point][];
|
||||
};
|
||||
for (let i = 0; i < wBoxes.length; i++) {
|
||||
for (let j = i + 1; j < wBoxes.length; j++) {
|
||||
let lb = wBoxes[i],
|
||||
rb = wBoxes[j];
|
||||
// it means these bound need to be horizontally across
|
||||
if (!lb.isHorizontalCross(rb) || lb.isIntersectWithBound(rb)) continue;
|
||||
|
||||
let switchFlag = false;
|
||||
// exchange lb and rb to make sure lb is on the left of rb
|
||||
if (rb.maxX < lb.minX) {
|
||||
const temp = rb;
|
||||
rb = lb;
|
||||
lb = temp;
|
||||
switchFlag = true;
|
||||
}
|
||||
|
||||
let _centerX = 0;
|
||||
const updateDif = () => {
|
||||
dif = Math.abs(bound.center[0] - _centerX);
|
||||
const curAveDis =
|
||||
(Math.abs(lb.center[0] - bound.center[0]) +
|
||||
Math.abs(rb.center[0] - bound.center[0])) /
|
||||
2;
|
||||
if (
|
||||
dif <= threshold &&
|
||||
(dif < min || (almostEqual(dif, min) && curAveDis < aveDis))
|
||||
) {
|
||||
min = dif;
|
||||
aveDis = curAveDis;
|
||||
rst.dx = _centerX - bound.center[0];
|
||||
/**
|
||||
* calculate points to draw
|
||||
*/
|
||||
const ys = [lb.minY, lb.maxY, rb.minY, rb.maxY].sort(
|
||||
(a, b) => a - b
|
||||
);
|
||||
const y = (ys[1] + ys[2]) / 2;
|
||||
const offset = DISTRIBUTION_LINE_OFFSET / viewport.zoom;
|
||||
const xs = [
|
||||
_centerX - bound.w / 2,
|
||||
_centerX + bound.w / 2,
|
||||
rb.minX,
|
||||
rb.maxX,
|
||||
lb.minX,
|
||||
lb.maxX,
|
||||
].sort((a, b) => a - b);
|
||||
|
||||
curBound = {
|
||||
leftIdx: switchFlag ? j : i,
|
||||
rightIdx: switchFlag ? i : j,
|
||||
spacing: xs[2] - xs[1],
|
||||
points: [
|
||||
[new Point(xs[1] + offset, y), new Point(xs[2] - offset, y)],
|
||||
[new Point(xs[3] + offset, y), new Point(xs[4] - offset, y)],
|
||||
],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* align between left and right bound
|
||||
*/
|
||||
if (lb.horizontalDistance(rb) > bound.w) {
|
||||
_centerX = (lb.maxX + rb.minX) / 2;
|
||||
updateDif();
|
||||
}
|
||||
|
||||
/**
|
||||
* align to the left bounds
|
||||
*/
|
||||
_centerX = lb.minX - (rb.minX - lb.maxX) - bound.w / 2;
|
||||
updateDif();
|
||||
|
||||
/** align right */
|
||||
_centerX = rb.minX - lb.maxX + rb.maxX + bound.w / 2;
|
||||
updateDif();
|
||||
}
|
||||
}
|
||||
|
||||
// find the boxes that has same spacing
|
||||
if (curBound) {
|
||||
const { leftIdx, rightIdx, spacing, points } = curBound;
|
||||
|
||||
this._distributedAlignLines.push(...points);
|
||||
|
||||
{
|
||||
let curLeftBound = wBoxes[leftIdx];
|
||||
|
||||
for (let i = leftIdx - 1; i >= 0; i--) {
|
||||
if (almostEqual(wBoxes[i].maxX, curLeftBound.minX - spacing)) {
|
||||
const targetBound = wBoxes[i];
|
||||
const ys = [
|
||||
targetBound.minY,
|
||||
targetBound.maxY,
|
||||
curLeftBound.minY,
|
||||
curLeftBound.maxY,
|
||||
].sort((a, b) => a - b);
|
||||
const y = (ys[1] + ys[2]) / 2;
|
||||
|
||||
this._distributedAlignLines.push([
|
||||
new Point(wBoxes[i].maxX, y),
|
||||
new Point(curLeftBound.minX, y),
|
||||
]);
|
||||
|
||||
curLeftBound = wBoxes[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let curRightBound = wBoxes[rightIdx];
|
||||
|
||||
for (let i = rightIdx + 1; i < wBoxes.length; i++) {
|
||||
if (almostEqual(wBoxes[i].minX, curRightBound.maxX + spacing)) {
|
||||
const targetBound = wBoxes[i];
|
||||
const ys = [
|
||||
targetBound.minY,
|
||||
targetBound.maxY,
|
||||
curRightBound.minY,
|
||||
curRightBound.maxY,
|
||||
].sort((a, b) => a - b);
|
||||
const y = (ys[1] + ys[2]) / 2;
|
||||
|
||||
this._distributedAlignLines.push([
|
||||
new Point(curRightBound.maxX, y),
|
||||
new Point(wBoxes[i].minX, y),
|
||||
]);
|
||||
|
||||
curRightBound = wBoxes[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _alignDistributeVertically(
|
||||
rst: { dx: number; dy: number },
|
||||
bound: Bound,
|
||||
threshold: number,
|
||||
viewport: { zoom: number }
|
||||
) {
|
||||
const hBoxes: Bound[] = [];
|
||||
this._referenceBounds.vertical.forEach(box => {
|
||||
if (box.isVerticalCross(bound)) {
|
||||
hBoxes.push(box);
|
||||
}
|
||||
});
|
||||
|
||||
hBoxes.sort((a, b) => a.center[0] - b.center[0]);
|
||||
|
||||
let dif = Infinity;
|
||||
let min = Infinity;
|
||||
let aveDis = Number.MAX_SAFE_INTEGER;
|
||||
let curBound!: {
|
||||
upperIdx: number;
|
||||
lowerIdx: number;
|
||||
spacing: number;
|
||||
points: [Point, Point][];
|
||||
};
|
||||
for (let i = 0; i < hBoxes.length; i++) {
|
||||
for (let j = i + 1; j < hBoxes.length; j++) {
|
||||
let ub = hBoxes[i],
|
||||
db = hBoxes[j];
|
||||
if (!ub.isVerticalCross(db) || ub.isIntersectWithBound(db)) continue;
|
||||
|
||||
let switchFlag = false;
|
||||
if (db.maxY < ub.minX) {
|
||||
const temp = ub;
|
||||
ub = db;
|
||||
db = temp;
|
||||
switchFlag = true;
|
||||
}
|
||||
|
||||
/** align middle */
|
||||
let _centerY = 0;
|
||||
const updateDiff = () => {
|
||||
dif = Math.abs(bound.center[1] - _centerY);
|
||||
const curAveDis =
|
||||
(Math.abs(ub.center[1] - bound.center[1]) +
|
||||
Math.abs(db.center[1] - bound.center[1])) /
|
||||
2;
|
||||
|
||||
if (
|
||||
dif <= threshold &&
|
||||
(dif < min || (almostEqual(dif, min) && curAveDis < aveDis))
|
||||
) {
|
||||
min = dif;
|
||||
rst.dy = _centerY - bound.center[1];
|
||||
/**
|
||||
* calculate points to draw
|
||||
*/
|
||||
const xs = [ub.minX, ub.maxX, db.minX, db.maxX].sort(
|
||||
(a, b) => a - b
|
||||
);
|
||||
const x = (xs[1] + xs[2]) / 2;
|
||||
const offset = DISTRIBUTION_LINE_OFFSET / viewport.zoom;
|
||||
const ys = [
|
||||
_centerY - bound.h / 2,
|
||||
_centerY + bound.h / 2,
|
||||
db.minY,
|
||||
db.maxY,
|
||||
ub.minY,
|
||||
ub.maxY,
|
||||
].sort((a, b) => a - b);
|
||||
|
||||
curBound = {
|
||||
upperIdx: switchFlag ? j : i,
|
||||
lowerIdx: switchFlag ? i : j,
|
||||
spacing: ys[2] - ys[1],
|
||||
points: [
|
||||
[new Point(x, ys[1] + offset), new Point(x, ys[2] - offset)],
|
||||
[new Point(x, ys[3] + offset), new Point(x, ys[4] - offset)],
|
||||
],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if (ub.verticalDistance(db) > bound.h) {
|
||||
_centerY = (ub.maxY + db.minY) / 2;
|
||||
updateDiff();
|
||||
}
|
||||
|
||||
/** align upper */
|
||||
_centerY = ub.minY - (db.minY - ub.maxY) - bound.h / 2;
|
||||
updateDiff();
|
||||
/** align lower */
|
||||
_centerY = db.minY - ub.maxY + db.maxY + bound.h / 2;
|
||||
updateDiff();
|
||||
}
|
||||
}
|
||||
|
||||
// find the boxes that has same spacing
|
||||
if (curBound) {
|
||||
const { upperIdx, lowerIdx, spacing, points } = curBound;
|
||||
|
||||
this._distributedAlignLines.push(...points);
|
||||
|
||||
{
|
||||
let curUpperBound = hBoxes[upperIdx];
|
||||
|
||||
for (let i = upperIdx - 1; i >= 0; i--) {
|
||||
if (almostEqual(hBoxes[i].maxY, curUpperBound.minY - spacing)) {
|
||||
const targetBound = hBoxes[i];
|
||||
const xs = [
|
||||
targetBound.minX,
|
||||
targetBound.maxX,
|
||||
curUpperBound.minX,
|
||||
curUpperBound.maxX,
|
||||
].sort((a, b) => a - b);
|
||||
const x = (xs[1] + xs[2]) / 2;
|
||||
|
||||
this._distributedAlignLines.push([
|
||||
new Point(x, hBoxes[i].maxY),
|
||||
new Point(x, curUpperBound.minY),
|
||||
]);
|
||||
|
||||
curUpperBound = hBoxes[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let curLowerBound = hBoxes[lowerIdx];
|
||||
|
||||
for (let i = lowerIdx + 1; i < hBoxes.length; i++) {
|
||||
if (almostEqual(hBoxes[i].minY, curLowerBound.maxY + spacing)) {
|
||||
const targetBound = hBoxes[i];
|
||||
const xs = [
|
||||
targetBound.minX,
|
||||
targetBound.maxX,
|
||||
curLowerBound.minX,
|
||||
curLowerBound.maxX,
|
||||
].sort((a, b) => a - b);
|
||||
const x = (xs[1] + xs[2]) / 2;
|
||||
|
||||
this._distributedAlignLines.push([
|
||||
new Point(x, curLowerBound.maxY),
|
||||
new Point(x, hBoxes[i].minY),
|
||||
]);
|
||||
|
||||
curLowerBound = hBoxes[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _calculateClosestDistances(bound: Bound, other: Bound): Distance {
|
||||
// Calculate center-to-center and center-to-side distances
|
||||
const centerXDistance = other.center[0] - bound.center[0];
|
||||
const centerYDistance = other.center[1] - bound.center[1];
|
||||
|
||||
// Calculate center-to-side distances
|
||||
const leftDistance = other.minX - bound.center[0];
|
||||
const rightDistance = other.maxX - bound.center[0];
|
||||
const topDistance = other.minY - bound.center[1];
|
||||
const bottomDistance = other.maxY - bound.center[1];
|
||||
|
||||
// Calculate side-to-side distances
|
||||
const leftToLeft = other.minX - bound.minX;
|
||||
const leftToRight = other.maxX - bound.minX;
|
||||
const rightToLeft = other.minX - bound.maxX;
|
||||
const rightToRight = other.maxX - bound.maxX;
|
||||
|
||||
const topToTop = other.minY - bound.minY;
|
||||
const topToBottom = other.maxY - bound.minY;
|
||||
const bottomToTop = other.minY - bound.maxY;
|
||||
const bottomToBottom = other.maxY - bound.maxY;
|
||||
|
||||
// calculate side-to-center distances
|
||||
const rightToCenter = other.center[0] - bound.maxX;
|
||||
const leftToCenter = other.center[0] - bound.minX;
|
||||
const topToCenter = other.center[1] - bound.minY;
|
||||
const bottomToCenter = other.center[1] - bound.maxY;
|
||||
|
||||
const xDistances = [
|
||||
centerXDistance,
|
||||
leftDistance,
|
||||
rightDistance,
|
||||
leftToLeft,
|
||||
leftToRight,
|
||||
rightToLeft,
|
||||
rightToRight,
|
||||
rightToCenter,
|
||||
leftToCenter,
|
||||
];
|
||||
|
||||
const yDistances = [
|
||||
centerYDistance,
|
||||
topDistance,
|
||||
bottomDistance,
|
||||
topToTop,
|
||||
topToBottom,
|
||||
bottomToTop,
|
||||
bottomToBottom,
|
||||
topToCenter,
|
||||
bottomToCenter,
|
||||
];
|
||||
|
||||
// Get absolute distances
|
||||
const xDistancesAbs = xDistances.map(Math.abs);
|
||||
const yDistancesAbs = yDistances.map(Math.abs);
|
||||
|
||||
// Get closest distances
|
||||
const closestX = Math.min(...xDistancesAbs);
|
||||
const closestY = Math.min(...yDistancesAbs);
|
||||
|
||||
const threshold = ALIGN_THRESHOLD / this.gfx.viewport.zoom;
|
||||
|
||||
// the x and y distances will be useful for locating the align point
|
||||
return {
|
||||
horiz:
|
||||
closestX <= threshold
|
||||
? {
|
||||
distance: xDistances[xDistancesAbs.indexOf(closestX)],
|
||||
get alignPositionIndices() {
|
||||
const indices: number[] = [];
|
||||
xDistancesAbs.forEach(
|
||||
(val, idx) => almostEqual(val, closestX) && indices.push(idx)
|
||||
);
|
||||
return indices;
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
vert:
|
||||
closestY <= threshold
|
||||
? {
|
||||
distance: yDistances[yDistancesAbs.indexOf(closestY)],
|
||||
get alignPositionIndices() {
|
||||
const indices: number[] = [];
|
||||
yDistancesAbs.forEach(
|
||||
(val, idx) => almostEqual(val, closestY) && indices.push(idx)
|
||||
);
|
||||
return indices;
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update horizontal moving distance `rst.dx` to align with other bound.
|
||||
* Also, update the align points to draw.
|
||||
* @param rst
|
||||
* @param bound
|
||||
* @param other
|
||||
* @param distance
|
||||
*/
|
||||
private _updateXAlignPoint(
|
||||
rst: { dx: number; dy: number },
|
||||
bound: Bound,
|
||||
other: Bound,
|
||||
distance: Distance
|
||||
) {
|
||||
if (!distance.horiz) return;
|
||||
|
||||
const { distance: dx, alignPositionIndices: distanceIndices } =
|
||||
distance.horiz;
|
||||
const offset = STROKE_WIDTH / this.gfx.viewport.zoom / 2;
|
||||
const alignXPosition = [
|
||||
other.center[0],
|
||||
other.minX + offset,
|
||||
other.maxX - offset,
|
||||
bound.minX + dx + offset,
|
||||
bound.minX + dx + offset,
|
||||
bound.maxX + dx - offset,
|
||||
bound.maxX + dx - offset,
|
||||
other.center[0] - offset,
|
||||
other.center[0] + offset,
|
||||
];
|
||||
|
||||
rst.dx = dx;
|
||||
|
||||
const dy = distance.vert?.distance ?? 0;
|
||||
const top = Math.min(bound.minY + dy, other.minY);
|
||||
const down = Math.max(bound.maxY + dy, other.maxY);
|
||||
|
||||
this._intraGraphicAlignLines.horizontal = distanceIndices.map(
|
||||
idx =>
|
||||
[
|
||||
new Point(alignXPosition[idx], top),
|
||||
new Point(alignXPosition[idx], down),
|
||||
] as [Point, Point]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update vertical moving distance `rst.dy` to align with other bound.
|
||||
* Also, update the align points to draw.
|
||||
* @param rst
|
||||
* @param bound
|
||||
* @param other
|
||||
* @param distance
|
||||
*/
|
||||
private _updateYAlignPoint(
|
||||
rst: { dx: number; dy: number },
|
||||
bound: Bound,
|
||||
other: Bound,
|
||||
distance: Distance
|
||||
) {
|
||||
if (!distance.vert) return;
|
||||
|
||||
const { distance: dy, alignPositionIndices } = distance.vert;
|
||||
const offset = STROKE_WIDTH / this.gfx.viewport.zoom / 2;
|
||||
const alignXPosition = [
|
||||
other.center[1] - offset,
|
||||
other.minY + offset,
|
||||
other.maxY - offset,
|
||||
bound.minY + dy + offset,
|
||||
bound.minY + dy + offset,
|
||||
bound.maxY + dy - offset,
|
||||
bound.maxY + dy - offset,
|
||||
other.center[1] + offset,
|
||||
other.center[1] - offset,
|
||||
];
|
||||
|
||||
rst.dy = dy;
|
||||
|
||||
const dx = distance.horiz?.distance ?? 0;
|
||||
const left = Math.min(bound.minX + dx, other.minX);
|
||||
const right = Math.max(bound.maxX + dx, other.maxX);
|
||||
|
||||
this._intraGraphicAlignLines.vertical = alignPositionIndices.map(
|
||||
idx =>
|
||||
[
|
||||
new Point(left, alignXPosition[idx]),
|
||||
new Point(right, alignXPosition[idx]),
|
||||
] as [Point, Point]
|
||||
);
|
||||
}
|
||||
|
||||
align(bound: Bound): { dx: number; dy: number } {
|
||||
const rst = { dx: 0, dy: 0 };
|
||||
const threshold = ALIGN_THRESHOLD / this.gfx.viewport.zoom;
|
||||
|
||||
const { viewport } = this.gfx;
|
||||
|
||||
this._intraGraphicAlignLines = {
|
||||
horizontal: [],
|
||||
vertical: [],
|
||||
};
|
||||
this._distributedAlignLines = [];
|
||||
this._updateAlignCandidates(bound);
|
||||
|
||||
for (const other of this._referenceBounds.all) {
|
||||
const closestDistances = this._calculateClosestDistances(bound, other);
|
||||
|
||||
if (
|
||||
closestDistances.horiz &&
|
||||
(!this._intraGraphicAlignLines.horizontal.length ||
|
||||
Math.abs(closestDistances.horiz.distance) < Math.abs(rst.dx))
|
||||
) {
|
||||
this._updateXAlignPoint(rst, bound, other, closestDistances);
|
||||
}
|
||||
|
||||
if (
|
||||
closestDistances.vert &&
|
||||
(!this._intraGraphicAlignLines.vertical.length ||
|
||||
Math.abs(closestDistances.vert.distance) < Math.abs(rst.dy))
|
||||
) {
|
||||
this._updateYAlignPoint(rst, bound, other, closestDistances);
|
||||
}
|
||||
}
|
||||
|
||||
// point align priority is higher than distribute align
|
||||
if (rst.dx === 0) {
|
||||
this._alignDistributeHorizontally(rst, bound, threshold, viewport);
|
||||
}
|
||||
|
||||
if (rst.dy === 0) {
|
||||
this._alignDistributeVertically(rst, bound, threshold, viewport);
|
||||
}
|
||||
|
||||
this._renderer?.refresh();
|
||||
|
||||
return rst;
|
||||
}
|
||||
|
||||
override render(ctx: CanvasRenderingContext2D) {
|
||||
if (
|
||||
this._intraGraphicAlignLines.vertical.length === 0 &&
|
||||
this._intraGraphicAlignLines.horizontal.length === 0 &&
|
||||
this._distributedAlignLines.length === 0
|
||||
)
|
||||
return;
|
||||
const { viewport } = this.gfx;
|
||||
const strokeWidth = STROKE_WIDTH / viewport.zoom;
|
||||
|
||||
ctx.strokeStyle = '#8B5CF6';
|
||||
ctx.lineWidth = strokeWidth;
|
||||
ctx.beginPath();
|
||||
|
||||
[
|
||||
...this._intraGraphicAlignLines.horizontal,
|
||||
...this._intraGraphicAlignLines.vertical,
|
||||
].forEach(line => {
|
||||
let d = '';
|
||||
if (line[0].x === line[1].x) {
|
||||
const x = line[0].x;
|
||||
const minY = Math.min(line[0].y, line[1].y);
|
||||
const maxY = Math.max(line[0].y, line[1].y);
|
||||
d = `M${x},${minY}L${x},${maxY}`;
|
||||
} else {
|
||||
const y = line[0].y;
|
||||
const minX = Math.min(line[0].x, line[1].x);
|
||||
const maxX = Math.max(line[0].x, line[1].x);
|
||||
d = `M${minX},${y}L${maxX},${y}`;
|
||||
}
|
||||
ctx.stroke(new Path2D(d));
|
||||
});
|
||||
|
||||
ctx.strokeStyle = '#CC4187';
|
||||
this._distributedAlignLines.forEach(line => {
|
||||
const bar = 10 / viewport.zoom;
|
||||
let d = '';
|
||||
if (line[0].x === line[1].x) {
|
||||
const x = line[0].x;
|
||||
const minY = Math.min(line[0].y, line[1].y);
|
||||
const maxY = Math.max(line[0].y, line[1].y);
|
||||
d = `M${x},${minY}L${x},${maxY}
|
||||
M${x - bar},${minY}L${x + bar},${minY}
|
||||
M${x - bar},${maxY}L${x + bar},${maxY} `;
|
||||
} else {
|
||||
const y = line[0].y;
|
||||
const minX = Math.min(line[0].x, line[1].x);
|
||||
const maxX = Math.max(line[0].x, line[1].x);
|
||||
d = `M${minX},${y}L${maxX},${y}
|
||||
M${minX},${y - bar}L${minX},${y + bar}
|
||||
M${maxX},${y - bar}L${maxX},${y + bar}`;
|
||||
}
|
||||
ctx.stroke(new Path2D(d));
|
||||
});
|
||||
}
|
||||
|
||||
private _isSkippedElement(element: GfxModel) {
|
||||
return (
|
||||
element instanceof ConnectorElementModel ||
|
||||
element.group instanceof MindmapElementModel
|
||||
);
|
||||
}
|
||||
|
||||
private _updateAlignCandidates(movingBound: Bound) {
|
||||
movingBound = movingBound.expand(ALIGN_THRESHOLD * this.gfx.viewport.zoom);
|
||||
|
||||
const viewportBound = this.gfx.viewport.viewportBounds;
|
||||
const horizAreaBound = new Bound(
|
||||
Math.min(movingBound.x, viewportBound.x),
|
||||
movingBound.y,
|
||||
Math.max(movingBound.w, viewportBound.w),
|
||||
movingBound.h
|
||||
);
|
||||
const vertAreaBound = new Bound(
|
||||
movingBound.x,
|
||||
Math.min(movingBound.y, viewportBound.y),
|
||||
movingBound.w,
|
||||
Math.max(movingBound.h, viewportBound.h)
|
||||
);
|
||||
|
||||
const { _skippedElements: skipped } = this;
|
||||
const vertCandidates = this.gfx.grid.search(vertAreaBound, {
|
||||
useSet: true,
|
||||
});
|
||||
const horizCandidates = this.gfx.grid.search(horizAreaBound, {
|
||||
useSet: true,
|
||||
});
|
||||
const verticalBounds: Bound[] = [];
|
||||
const horizBounds: Bound[] = [];
|
||||
const allBounds: Bound[] = [];
|
||||
|
||||
vertCandidates.forEach(candidate => {
|
||||
if (skipped.has(candidate) || this._isSkippedElement(candidate)) return;
|
||||
verticalBounds.push(candidate.elementBound);
|
||||
allBounds.push(candidate.elementBound);
|
||||
});
|
||||
|
||||
horizCandidates.forEach(candidate => {
|
||||
if (skipped.has(candidate) || this._isSkippedElement(candidate)) return;
|
||||
horizBounds.push(candidate.elementBound);
|
||||
allBounds.push(candidate.elementBound);
|
||||
});
|
||||
|
||||
this._referenceBounds = {
|
||||
horizontal: horizBounds,
|
||||
vertical: verticalBounds,
|
||||
all: allBounds,
|
||||
};
|
||||
}
|
||||
|
||||
setMovingElements(
|
||||
movingElements: GfxModel[],
|
||||
excludes: GfxModel[] = []
|
||||
): Bound {
|
||||
if (movingElements.length === 0) return new Bound();
|
||||
|
||||
const skipped = new Set(movingElements);
|
||||
excludes.forEach(e => skipped.add(e));
|
||||
|
||||
this._skippedElements = skipped;
|
||||
|
||||
return movingElements.reduce(
|
||||
(prev, element) => prev.unite(element.elementBound),
|
||||
movingElements[0].elementBound
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
} from './edgeless/components/rects/edgeless-selected-rect.js';
|
||||
import { EdgelessSlideMenu } from './edgeless/components/toolbar/common/slide-menu.js';
|
||||
import { ToolbarArrowUpIcon } from './edgeless/components/toolbar/common/toolbar-arrow-up-icon.js';
|
||||
import { EdgelessDefaultToolButton } from './edgeless/components/toolbar/default/default-tool-button.js';
|
||||
import { EdgelessLinkToolButton } from './edgeless/components/toolbar/link/link-tool-button.js';
|
||||
import {
|
||||
EdgelessRootBlockComponent,
|
||||
@@ -72,10 +71,6 @@ function registerWidgets() {
|
||||
|
||||
function registerEdgelessToolbarComponents() {
|
||||
// Tool buttons
|
||||
customElements.define(
|
||||
'edgeless-default-tool-button',
|
||||
EdgelessDefaultToolButton
|
||||
);
|
||||
customElements.define('edgeless-link-tool-button', EdgelessLinkToolButton);
|
||||
|
||||
// Menus
|
||||
@@ -122,7 +117,6 @@ declare global {
|
||||
'edgeless-selected-rect': EdgelessSelectedRectWidget;
|
||||
'edgeless-slide-menu': EdgelessSlideMenu;
|
||||
'toolbar-arrow-up-icon': ToolbarArrowUpIcon;
|
||||
'edgeless-default-tool-button': EdgelessDefaultToolButton;
|
||||
'edgeless-link-tool-button': EdgelessLinkToolButton;
|
||||
'affine-page-root': PageRootBlockComponent;
|
||||
'zoom-bar-toggle-button': ZoomBarToggleButton;
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import type * as BrushEffect from '@blocksuite/affine-gfx-brush';
|
||||
import type * as NoteEffect from '@blocksuite/affine-gfx-note';
|
||||
import type * as PointerEffect from '@blocksuite/affine-gfx-pointer';
|
||||
import type * as ShapeEffect from '@blocksuite/affine-gfx-shape';
|
||||
|
||||
export * from './adapters';
|
||||
export * from './clipboard/index.js';
|
||||
export * from './common-specs/index.js';
|
||||
@@ -12,3 +17,9 @@ export { RootService } from './root-service.js';
|
||||
export * from './types.js';
|
||||
export * from './utils/index.js';
|
||||
export * from './widgets/index.js';
|
||||
|
||||
declare type _GLOBAL_ =
|
||||
| typeof PointerEffect
|
||||
| typeof NoteEffect
|
||||
| typeof BrushEffect
|
||||
| typeof ShapeEffect;
|
||||
|
||||
Reference in New Issue
Block a user