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:
Saul-Mirone
2025-04-27 04:46:44 +00:00
parent 59d4942d9b
commit 81b439c4e1
49 changed files with 290 additions and 183 deletions

View File

@@ -0,0 +1,8 @@
import { EdgelessDefaultToolButton } from './quick-tool/default-tool-button';
export function effects() {
customElements.define(
'edgeless-default-tool-button',
EdgelessDefaultToolButton
);
}

View File

@@ -0,0 +1 @@
export * from './tools';

View File

@@ -0,0 +1,103 @@
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;
}

View File

@@ -0,0 +1,12 @@
import { QuickToolExtension } from '@blocksuite/affine-widget-edgeless-toolbar';
import { html } from 'lit';
export const defaultQuickTool = QuickToolExtension('default', ({ block }) => {
return {
priority: 100,
type: 'default',
content: html`<edgeless-default-tool-button
.edgeless=${block}
></edgeless-default-tool-button>`,
};
});

View File

@@ -0,0 +1,68 @@
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 './snap-overlay';
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();
},
};
}
);
}
}

View File

@@ -0,0 +1,762 @@
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
);
}
}

View File

@@ -0,0 +1,424 @@
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;
}
}

View File

@@ -0,0 +1,14 @@
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;
}
}

View File

@@ -0,0 +1,3 @@
export * from './default-tool.js';
export * from './empty-tool.js';
export * from './pan-tool.js';

View File

@@ -0,0 +1,87 @@
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;
}
}

View File

@@ -0,0 +1,46 @@
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];
}

View File

@@ -0,0 +1,31 @@
import {
type ViewExtensionContext,
ViewExtensionProvider,
} from '@blocksuite/affine-ext-loader';
import { effects } from './effects';
import { defaultQuickTool } from './quick-tool/quick-tool';
import { SnapExtension } from './snap/snap-manager';
import { SnapOverlay } from './snap/snap-overlay';
import { DefaultTool, EmptyTool, PanTool } from './tools';
export class PointerViewExtension extends ViewExtensionProvider {
override name = 'affine-pointer-gfx';
override effect() {
super.effect();
effects();
}
override setup(context: ViewExtensionContext) {
super.setup(context);
context.register(EmptyTool);
context.register(DefaultTool);
context.register(PanTool);
if (this.isEdgeless(context.scope)) {
context.register(defaultQuickTool);
context.register(SnapExtension);
context.register(SnapOverlay);
}
}
}