snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 3s
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 3s
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped
This commit is contained in:
36
blocksuite/affine/gfx/brush/package.json
Normal file
36
blocksuite/affine/gfx/brush/package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "@blocksuite/affine-gfx-brush",
|
||||
"description": "Gfx brush for BlockSuite.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "yarn run -T tsc"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"keywords": [],
|
||||
"author": "toeverything",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@blocksuite/affine-block-surface": "workspace:*",
|
||||
"@blocksuite/affine-components": "workspace:*",
|
||||
"@blocksuite/affine-ext-loader": "workspace:*",
|
||||
"@blocksuite/affine-model": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/affine-widget-edgeless-toolbar": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"lit": "^3.2.0"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./store": "./src/store.ts",
|
||||
"./view": "./src/view.ts"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.27.5"
|
||||
}
|
||||
2
blocksuite/affine/gfx/brush/src/adapter/index.ts
Normal file
2
blocksuite/affine/gfx/brush/src/adapter/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './markdown';
|
||||
export * from './plain-text';
|
||||
18
blocksuite/affine/gfx/brush/src/adapter/markdown/index.ts
Normal file
18
blocksuite/affine/gfx/brush/src/adapter/markdown/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { ElementToMarkdownAdapterExtension } from '@blocksuite/affine-block-surface';
|
||||
|
||||
export const brushToMarkdownAdapterMatcher = ElementToMarkdownAdapterExtension({
|
||||
name: 'brush',
|
||||
match: elementModel => elementModel.type === 'brush',
|
||||
toAST: () => {
|
||||
const content = `Brush Stroke`;
|
||||
return {
|
||||
type: 'paragraph',
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
value: content,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
11
blocksuite/affine/gfx/brush/src/adapter/plain-text/index.ts
Normal file
11
blocksuite/affine/gfx/brush/src/adapter/plain-text/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { ElementToPlainTextAdapterExtension } from '@blocksuite/affine-block-surface';
|
||||
|
||||
export const brushToPlainTextAdapterMatcher =
|
||||
ElementToPlainTextAdapterExtension({
|
||||
name: 'brush',
|
||||
match: elementModel => elementModel.type === 'brush',
|
||||
toAST: () => {
|
||||
const content = `Brush Stroke`;
|
||||
return { content };
|
||||
},
|
||||
});
|
||||
200
blocksuite/affine/gfx/brush/src/brush-tool.ts
Normal file
200
blocksuite/affine/gfx/brush/src/brush-tool.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import {
|
||||
CanvasElementType,
|
||||
EdgelessCRUDIdentifier,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import type { BrushElementModel } from '@blocksuite/affine-model';
|
||||
import { TelemetryProvider } from '@blocksuite/affine-shared/services';
|
||||
import type { IVec } from '@blocksuite/global/gfx';
|
||||
import type { PointerEventState } from '@blocksuite/std';
|
||||
import { BaseTool } from '@blocksuite/std/gfx';
|
||||
|
||||
export class BrushTool extends BaseTool {
|
||||
static BRUSH_POP_GAP = 20;
|
||||
|
||||
static override toolName: string = 'brush';
|
||||
|
||||
private _draggingElement: BrushElementModel | null = null;
|
||||
|
||||
private _draggingElementId: string | null = null;
|
||||
|
||||
private _lastPoint: IVec | null = null;
|
||||
|
||||
private _lastPopLength = 0;
|
||||
|
||||
private readonly _pressureSupportedPointerIds = new Set<number>();
|
||||
|
||||
private _straightLineType: 'horizontal' | 'vertical' | null = null;
|
||||
|
||||
protected _draggingPathPoints: number[][] | null = null;
|
||||
|
||||
protected _draggingPathPressures: number[] | null = null;
|
||||
|
||||
private _getStraightLineType(currentPoint: IVec) {
|
||||
const lastPoint = this._lastPoint;
|
||||
if (!lastPoint) return null;
|
||||
|
||||
// check angle to determine if the line is horizontal or vertical
|
||||
const dx = currentPoint[0] - lastPoint[0];
|
||||
const dy = currentPoint[1] - lastPoint[1];
|
||||
const absAngleRadius = Math.abs(Math.atan2(dy, dx));
|
||||
return absAngleRadius < Math.PI / 4 || absAngleRadius > 3 * (Math.PI / 4)
|
||||
? 'horizontal'
|
||||
: 'vertical';
|
||||
}
|
||||
|
||||
private _tryGetPressurePoints(e: PointerEventState): number[][] {
|
||||
if (!this._draggingPathPressures) {
|
||||
return [];
|
||||
}
|
||||
const pressures = [...this._draggingPathPressures, e.pressure];
|
||||
this._draggingPathPressures = pressures;
|
||||
|
||||
// we do not use the `e.raw.pointerType` to detect because it is not reliable,
|
||||
// such as some digital pens do not support pressure even thought the `e.raw.pointerType` is equal to `'pen'`
|
||||
const pointerId = e.raw.pointerId;
|
||||
const pressureChanged = pressures.some(
|
||||
pressure => pressure !== pressures[0]
|
||||
);
|
||||
|
||||
if (pressureChanged) {
|
||||
this._pressureSupportedPointerIds.add(pointerId);
|
||||
}
|
||||
|
||||
const points = this._draggingPathPoints;
|
||||
if (!points) {
|
||||
return [];
|
||||
}
|
||||
if (this._pressureSupportedPointerIds.has(pointerId)) {
|
||||
return points.map(([x, y], i) => [x, y, pressures[i]]);
|
||||
} else {
|
||||
return points;
|
||||
}
|
||||
}
|
||||
|
||||
override dragEnd() {
|
||||
if (this._draggingElement) {
|
||||
const { _draggingElement } = this;
|
||||
this.doc.withoutTransact(() => {
|
||||
_draggingElement.pop('points');
|
||||
_draggingElement.pop('xywh');
|
||||
});
|
||||
}
|
||||
this._draggingElement = null;
|
||||
this._draggingElementId = null;
|
||||
this._draggingPathPoints = null;
|
||||
this._draggingPathPressures = null;
|
||||
this._lastPoint = null;
|
||||
this._straightLineType = null;
|
||||
this.doc.captureSync();
|
||||
}
|
||||
|
||||
override dragMove(e: PointerEventState) {
|
||||
if (
|
||||
!this._draggingElementId ||
|
||||
!this._draggingElement ||
|
||||
!this.gfx.surface ||
|
||||
!this._draggingPathPoints
|
||||
)
|
||||
return;
|
||||
|
||||
let pointX = e.point.x;
|
||||
let pointY = e.point.y;
|
||||
const holdingShiftKey = e.keys.shift || this.gfx.keyboard.shiftKey$.peek();
|
||||
if (holdingShiftKey) {
|
||||
if (!this._straightLineType) {
|
||||
this._straightLineType = this._getStraightLineType([pointX, pointY]);
|
||||
}
|
||||
|
||||
if (this._straightLineType === 'horizontal') {
|
||||
pointY = this._lastPoint?.[1] ?? pointY;
|
||||
} else if (this._straightLineType === 'vertical') {
|
||||
pointX = this._lastPoint?.[0] ?? pointX;
|
||||
}
|
||||
} else if (this._straightLineType) {
|
||||
this._straightLineType = null;
|
||||
}
|
||||
|
||||
const [modelX, modelY] = this.gfx.viewport.toModelCoord(pointX, pointY);
|
||||
|
||||
const points = [...this._draggingPathPoints, [modelX, modelY]];
|
||||
|
||||
this._lastPoint = [pointX, pointY];
|
||||
this._draggingPathPoints = points;
|
||||
|
||||
this.gfx.updateElement(this._draggingElement, {
|
||||
points: this._tryGetPressurePoints(e),
|
||||
});
|
||||
|
||||
if (
|
||||
this._lastPopLength + BrushTool.BRUSH_POP_GAP <
|
||||
this._draggingElement!.points.length
|
||||
) {
|
||||
this._lastPopLength = this._draggingElement!.points.length;
|
||||
this.doc.withoutTransact(() => {
|
||||
this._draggingElement!.pop('points');
|
||||
this._draggingElement!.pop('xywh');
|
||||
});
|
||||
|
||||
this._draggingElement!.stash('points');
|
||||
this._draggingElement!.stash('xywh');
|
||||
}
|
||||
}
|
||||
|
||||
override dragStart(e: PointerEventState) {
|
||||
if (!this.gfx.surface) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.doc.captureSync();
|
||||
|
||||
const { viewport } = this.gfx;
|
||||
|
||||
// create a shape block when drag start
|
||||
const [modelX, modelY] = viewport.toModelCoord(e.point.x, e.point.y);
|
||||
const points = [[modelX, modelY]];
|
||||
const id = this.gfx.surface.addElement({
|
||||
type: CanvasElementType.BRUSH,
|
||||
points,
|
||||
});
|
||||
|
||||
const element = this.gfx.getElementById(id) as BrushElementModel;
|
||||
|
||||
element.stash('points');
|
||||
element.stash('xywh');
|
||||
|
||||
this._lastPoint = [e.point.x, e.point.y];
|
||||
this._draggingElementId = id;
|
||||
this._draggingElement = element;
|
||||
this._draggingPathPoints = points;
|
||||
this._draggingPathPressures = [e.pressure];
|
||||
this._lastPopLength = 0;
|
||||
}
|
||||
|
||||
override click(e: PointerEventState) {
|
||||
this.doc.captureSync();
|
||||
|
||||
const [modelX, modelY] = this.gfx.viewport.toModelCoord(
|
||||
e.point.x,
|
||||
e.point.y
|
||||
);
|
||||
|
||||
const points = this._pressureSupportedPointerIds.has(e.raw.pointerId)
|
||||
? [[modelX, modelY, e.pressure]]
|
||||
: [[modelX, modelY]];
|
||||
|
||||
const crud = this.std.get(EdgelessCRUDIdentifier);
|
||||
crud.addElement(CanvasElementType.BRUSH, {
|
||||
points,
|
||||
});
|
||||
}
|
||||
|
||||
override activate() {
|
||||
this.std.getOptional(TelemetryProvider)?.track('EdgelessToolPicked', {
|
||||
page: 'whiteboard editor',
|
||||
module: 'global toolbar',
|
||||
segment: 'global toolbar',
|
||||
control: 'drawing',
|
||||
type: CanvasElementType.BRUSH,
|
||||
});
|
||||
}
|
||||
}
|
||||
20
blocksuite/affine/gfx/brush/src/effects.ts
Normal file
20
blocksuite/affine/gfx/brush/src/effects.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { EdgelessEraserToolButton } from './toolbar/components/eraser/eraser-tool-button';
|
||||
import { EdgelessPenMenu } from './toolbar/components/pen/pen-menu';
|
||||
import { EdgelessPenToolButton } from './toolbar/components/pen/pen-tool-button';
|
||||
|
||||
export function effects() {
|
||||
customElements.define(
|
||||
'edgeless-eraser-tool-button',
|
||||
EdgelessEraserToolButton
|
||||
);
|
||||
customElements.define('edgeless-pen-tool-button', EdgelessPenToolButton);
|
||||
customElements.define('edgeless-pen-menu', EdgelessPenMenu);
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-pen-menu': EdgelessPenMenu;
|
||||
'edgeless-pen-tool-button': EdgelessPenToolButton;
|
||||
'edgeless-eraser-tool-button': EdgelessEraserToolButton;
|
||||
}
|
||||
}
|
||||
158
blocksuite/affine/gfx/brush/src/eraser-tool.ts
Normal file
158
blocksuite/affine/gfx/brush/src/eraser-tool.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import {
|
||||
EdgelessCRUDIdentifier,
|
||||
Overlay,
|
||||
type SurfaceBlockComponent,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import { isTopLevelBlock } from '@blocksuite/affine-shared/utils';
|
||||
import {
|
||||
Bound,
|
||||
getStroke,
|
||||
getSvgPathFromStroke,
|
||||
type IVec,
|
||||
linePolygonIntersects,
|
||||
} from '@blocksuite/global/gfx';
|
||||
import type { PointerEventState } from '@blocksuite/std';
|
||||
import { BaseTool, type GfxModel } from '@blocksuite/std/gfx';
|
||||
|
||||
class EraserOverlay extends Overlay {
|
||||
d = '';
|
||||
|
||||
override render(ctx: CanvasRenderingContext2D): void {
|
||||
ctx.globalAlpha = 0.33;
|
||||
const path = new Path2D(this.d);
|
||||
ctx.fillStyle = '#aaa';
|
||||
ctx.fill(path);
|
||||
}
|
||||
}
|
||||
|
||||
export class EraserTool extends BaseTool {
|
||||
static override toolName = 'eraser';
|
||||
|
||||
private _erasable = new Set<GfxModel>();
|
||||
|
||||
private _eraserPoints: IVec[] = [];
|
||||
|
||||
private readonly _eraseTargets = new Set<GfxModel>();
|
||||
|
||||
private get _surfaceComponent() {
|
||||
return this.gfx.surfaceComponent as SurfaceBlockComponent | null;
|
||||
}
|
||||
|
||||
private readonly _loop = () => {
|
||||
const now = Date.now();
|
||||
const elapsed = now - this._timestamp;
|
||||
|
||||
let didUpdate = false;
|
||||
|
||||
if (this._prevEraserPoint !== this._prevPoint) {
|
||||
didUpdate = true;
|
||||
this._eraserPoints.push(this._prevPoint);
|
||||
this._prevEraserPoint = this._prevPoint;
|
||||
}
|
||||
if (elapsed > 32 && this._eraserPoints.length > 1) {
|
||||
didUpdate = true;
|
||||
this._eraserPoints.splice(0, Math.ceil(this._eraserPoints.length * 0.1));
|
||||
this._timestamp = now;
|
||||
}
|
||||
if (didUpdate) {
|
||||
const zoom = this.gfx.viewport.zoom;
|
||||
const d = getSvgPathFromStroke(
|
||||
getStroke(this._eraserPoints, {
|
||||
size: 16 / zoom,
|
||||
start: { taper: true },
|
||||
})
|
||||
);
|
||||
this._overlay.d = d;
|
||||
this._surfaceComponent?.refresh();
|
||||
}
|
||||
this._timer = requestAnimationFrame(this._loop);
|
||||
};
|
||||
|
||||
private readonly _overlay = new EraserOverlay(this.gfx);
|
||||
|
||||
private _prevEraserPoint: IVec = [0, 0];
|
||||
|
||||
private _prevPoint: IVec = [0, 0];
|
||||
|
||||
private _timer = 0;
|
||||
|
||||
private _timestamp = 0;
|
||||
|
||||
private _reset() {
|
||||
cancelAnimationFrame(this._timer);
|
||||
|
||||
if (!this.gfx.surface) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._surfaceComponent?.renderer.removeOverlay(this._overlay);
|
||||
this._erasable.clear();
|
||||
this._eraseTargets.clear();
|
||||
}
|
||||
|
||||
override activate(): void {
|
||||
this._eraseTargets.forEach(erasable => {
|
||||
if (isTopLevelBlock(erasable)) {
|
||||
const ele = this.std.view.getBlock(erasable.id);
|
||||
ele && ((ele as HTMLElement).style.opacity = '1');
|
||||
} else {
|
||||
erasable.opacity = 1;
|
||||
}
|
||||
});
|
||||
this._reset();
|
||||
}
|
||||
|
||||
override dragEnd(_: PointerEventState): void {
|
||||
this.gfx.std
|
||||
.get(EdgelessCRUDIdentifier)
|
||||
.deleteElements(Array.from(this._eraseTargets));
|
||||
this._reset();
|
||||
this.doc.captureSync();
|
||||
}
|
||||
|
||||
override dragMove(e: PointerEventState): void {
|
||||
const currentPoint = this.gfx.viewport.toModelCoord(e.point.x, e.point.y);
|
||||
this._erasable.forEach(erasable => {
|
||||
if (erasable.isLocked()) return;
|
||||
if (this._eraseTargets.has(erasable)) return;
|
||||
if (isTopLevelBlock(erasable)) {
|
||||
const bound = Bound.deserialize(erasable.xywh);
|
||||
if (
|
||||
linePolygonIntersects(this._prevPoint, currentPoint, bound.points)
|
||||
) {
|
||||
this._eraseTargets.add(erasable);
|
||||
const ele = this.std.view.getBlock(erasable.id);
|
||||
ele && ((ele as HTMLElement).style.opacity = '0.3');
|
||||
}
|
||||
} else {
|
||||
if (
|
||||
erasable.getLineIntersections(
|
||||
this._prevPoint as IVec,
|
||||
currentPoint as IVec
|
||||
)
|
||||
) {
|
||||
this._eraseTargets.add(erasable);
|
||||
erasable.opacity = 0.3;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this._prevPoint = currentPoint;
|
||||
}
|
||||
|
||||
override dragStart(e: PointerEventState): void {
|
||||
this.doc.captureSync();
|
||||
|
||||
const { point } = e;
|
||||
const [x, y] = this.gfx.viewport.toModelCoord(point.x, point.y);
|
||||
this._eraserPoints = [[x, y]];
|
||||
this._prevPoint = [x, y];
|
||||
this._erasable = new Set([
|
||||
...this.gfx.layer.canvasElements,
|
||||
...this.gfx.layer.blocks,
|
||||
]);
|
||||
this._loop();
|
||||
|
||||
this._surfaceComponent?.renderer.addOverlay(this._overlay);
|
||||
}
|
||||
}
|
||||
179
blocksuite/affine/gfx/brush/src/highlighter-tool.ts
Normal file
179
blocksuite/affine/gfx/brush/src/highlighter-tool.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { CanvasElementType } from '@blocksuite/affine-block-surface';
|
||||
import type { HighlighterElementModel } from '@blocksuite/affine-model';
|
||||
import { TelemetryProvider } from '@blocksuite/affine-shared/services';
|
||||
import type { IVec } from '@blocksuite/global/gfx';
|
||||
import type { PointerEventState } from '@blocksuite/std';
|
||||
import { BaseTool } from '@blocksuite/std/gfx';
|
||||
|
||||
export class HighlighterTool extends BaseTool {
|
||||
static HIGHLIGHTER_POP_GAP = 20;
|
||||
|
||||
static override toolName: string = 'highlighter';
|
||||
|
||||
private _draggingElement: HighlighterElementModel | null = null;
|
||||
|
||||
private _draggingElementId: string | null = null;
|
||||
|
||||
private _lastPoint: IVec | null = null;
|
||||
|
||||
private _lastPopLength = 0;
|
||||
|
||||
private readonly _pressureSupportedPointerIds = new Set<number>();
|
||||
|
||||
private _straightLineType: 'horizontal' | 'vertical' | null = null;
|
||||
|
||||
protected _draggingPathPoints: number[][] | null = null;
|
||||
|
||||
protected _draggingPathPressures: number[] | null = null;
|
||||
|
||||
private _getStraightLineType(currentPoint: IVec) {
|
||||
const lastPoint = this._lastPoint;
|
||||
if (!lastPoint) return null;
|
||||
|
||||
// check angle to determine if the line is horizontal or vertical
|
||||
const dx = currentPoint[0] - lastPoint[0];
|
||||
const dy = currentPoint[1] - lastPoint[1];
|
||||
const absAngleRadius = Math.abs(Math.atan2(dy, dx));
|
||||
return absAngleRadius < Math.PI / 4 || absAngleRadius > 3 * (Math.PI / 4)
|
||||
? 'horizontal'
|
||||
: 'vertical';
|
||||
}
|
||||
|
||||
private _tryGetPressurePoints(e: PointerEventState): number[][] {
|
||||
if (!this._draggingPathPressures) {
|
||||
return [];
|
||||
}
|
||||
const pressures = [...this._draggingPathPressures, e.pressure];
|
||||
this._draggingPathPressures = pressures;
|
||||
|
||||
// we do not use the `e.raw.pointerType` to detect because it is not reliable,
|
||||
// such as some digital pens do not support pressure even thought the `e.raw.pointerType` is equal to `'pen'`
|
||||
const pointerId = e.raw.pointerId;
|
||||
const pressureChanged = pressures.some(
|
||||
pressure => pressure !== pressures[0]
|
||||
);
|
||||
|
||||
if (pressureChanged) {
|
||||
this._pressureSupportedPointerIds.add(pointerId);
|
||||
}
|
||||
|
||||
const points = this._draggingPathPoints;
|
||||
if (!points) {
|
||||
return [];
|
||||
}
|
||||
if (this._pressureSupportedPointerIds.has(pointerId)) {
|
||||
return points.map(([x, y], i) => [x, y, pressures[i]]);
|
||||
} else {
|
||||
return points;
|
||||
}
|
||||
}
|
||||
|
||||
override dragEnd() {
|
||||
if (this._draggingElement) {
|
||||
const { _draggingElement } = this;
|
||||
this.doc.withoutTransact(() => {
|
||||
_draggingElement.pop('points');
|
||||
_draggingElement.pop('xywh');
|
||||
});
|
||||
}
|
||||
this._draggingElement = null;
|
||||
this._draggingElementId = null;
|
||||
this._draggingPathPoints = null;
|
||||
this._draggingPathPressures = null;
|
||||
this._lastPoint = null;
|
||||
this._straightLineType = null;
|
||||
this.doc.captureSync();
|
||||
}
|
||||
|
||||
override dragMove(e: PointerEventState) {
|
||||
if (
|
||||
!this._draggingElementId ||
|
||||
!this._draggingElement ||
|
||||
!this.gfx.surface ||
|
||||
!this._draggingPathPoints
|
||||
)
|
||||
return;
|
||||
|
||||
let pointX = e.point.x;
|
||||
let pointY = e.point.y;
|
||||
const holdingShiftKey = e.keys.shift || this.gfx.keyboard.shiftKey$.peek();
|
||||
if (holdingShiftKey) {
|
||||
if (!this._straightLineType) {
|
||||
this._straightLineType = this._getStraightLineType([pointX, pointY]);
|
||||
}
|
||||
|
||||
if (this._straightLineType === 'horizontal') {
|
||||
pointY = this._lastPoint?.[1] ?? pointY;
|
||||
} else if (this._straightLineType === 'vertical') {
|
||||
pointX = this._lastPoint?.[0] ?? pointX;
|
||||
}
|
||||
} else if (this._straightLineType) {
|
||||
this._straightLineType = null;
|
||||
}
|
||||
|
||||
const [modelX, modelY] = this.gfx.viewport.toModelCoord(pointX, pointY);
|
||||
|
||||
const points = [...this._draggingPathPoints, [modelX, modelY]];
|
||||
|
||||
this._lastPoint = [pointX, pointY];
|
||||
this._draggingPathPoints = points;
|
||||
|
||||
this.gfx.updateElement(this._draggingElement!, {
|
||||
points: this._tryGetPressurePoints(e),
|
||||
});
|
||||
|
||||
if (
|
||||
this._lastPopLength + HighlighterTool.HIGHLIGHTER_POP_GAP <
|
||||
this._draggingElement!.points.length
|
||||
) {
|
||||
this._lastPopLength = this._draggingElement!.points.length;
|
||||
this.doc.withoutTransact(() => {
|
||||
this._draggingElement!.pop('points');
|
||||
this._draggingElement!.pop('xywh');
|
||||
});
|
||||
|
||||
this._draggingElement!.stash('points');
|
||||
this._draggingElement!.stash('xywh');
|
||||
}
|
||||
}
|
||||
|
||||
override dragStart(e: PointerEventState) {
|
||||
if (!this.gfx.surface) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.doc.captureSync();
|
||||
|
||||
const { viewport } = this.gfx;
|
||||
|
||||
// create a shape block when drag start
|
||||
const [modelX, modelY] = viewport.toModelCoord(e.point.x, e.point.y);
|
||||
const points = [[modelX, modelY]];
|
||||
const id = this.gfx.surface.addElement({
|
||||
type: CanvasElementType.HIGHLIGHTER,
|
||||
points,
|
||||
});
|
||||
|
||||
const element = this.gfx.getElementById(id) as HighlighterElementModel;
|
||||
|
||||
element.stash('points');
|
||||
element.stash('xywh');
|
||||
|
||||
this._lastPoint = [e.point.x, e.point.y];
|
||||
this._draggingElementId = id;
|
||||
this._draggingElement = element;
|
||||
this._draggingPathPoints = points;
|
||||
this._draggingPathPressures = [e.pressure];
|
||||
this._lastPopLength = 0;
|
||||
}
|
||||
|
||||
override activate() {
|
||||
this.std.getOptional(TelemetryProvider)?.track('EdgelessToolPicked', {
|
||||
page: 'whiteboard editor',
|
||||
module: 'global toolbar',
|
||||
segment: 'global toolbar',
|
||||
control: 'drawing',
|
||||
type: CanvasElementType.HIGHLIGHTER,
|
||||
});
|
||||
}
|
||||
}
|
||||
7
blocksuite/affine/gfx/brush/src/index.ts
Normal file
7
blocksuite/affine/gfx/brush/src/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from './adapter';
|
||||
export * from './brush-tool';
|
||||
export * from './eraser-tool';
|
||||
export * from './highlighter-tool';
|
||||
export * from './renderer';
|
||||
export * from './toolbar/configs';
|
||||
export * from './toolbar/senior-tool';
|
||||
24
blocksuite/affine/gfx/brush/src/renderer/dom/brush.ts
Normal file
24
blocksuite/affine/gfx/brush/src/renderer/dom/brush.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
DomElementRendererExtension,
|
||||
type DomRenderer,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import type { BrushElementModel } from '@blocksuite/affine-model';
|
||||
import { DefaultTheme } from '@blocksuite/affine-model';
|
||||
|
||||
import { renderBrushLikeDom } from './shared';
|
||||
|
||||
export const BrushDomRendererExtension = DomElementRendererExtension(
|
||||
'brush',
|
||||
(
|
||||
model: BrushElementModel,
|
||||
domElement: HTMLElement,
|
||||
renderer: DomRenderer
|
||||
) => {
|
||||
renderBrushLikeDom({
|
||||
model,
|
||||
domElement,
|
||||
renderer,
|
||||
color: renderer.getColorValue(model.color, DefaultTheme.black, true),
|
||||
});
|
||||
}
|
||||
);
|
||||
28
blocksuite/affine/gfx/brush/src/renderer/dom/highlighter.ts
Normal file
28
blocksuite/affine/gfx/brush/src/renderer/dom/highlighter.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import {
|
||||
DomElementRendererExtension,
|
||||
type DomRenderer,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import type { HighlighterElementModel } from '@blocksuite/affine-model';
|
||||
import { DefaultTheme } from '@blocksuite/affine-model';
|
||||
|
||||
import { renderBrushLikeDom } from './shared';
|
||||
|
||||
export const HighlighterDomRendererExtension = DomElementRendererExtension(
|
||||
'highlighter',
|
||||
(
|
||||
model: HighlighterElementModel,
|
||||
domElement: HTMLElement,
|
||||
renderer: DomRenderer
|
||||
) => {
|
||||
renderBrushLikeDom({
|
||||
model,
|
||||
domElement,
|
||||
renderer,
|
||||
color: renderer.getColorValue(
|
||||
model.color,
|
||||
DefaultTheme.hightlighterColor,
|
||||
true
|
||||
),
|
||||
});
|
||||
}
|
||||
);
|
||||
2
blocksuite/affine/gfx/brush/src/renderer/dom/index.ts
Normal file
2
blocksuite/affine/gfx/brush/src/renderer/dom/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { BrushDomRendererExtension } from './brush';
|
||||
export { HighlighterDomRendererExtension } from './highlighter';
|
||||
82
blocksuite/affine/gfx/brush/src/renderer/dom/shared.ts
Normal file
82
blocksuite/affine/gfx/brush/src/renderer/dom/shared.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { DomRenderer } from '@blocksuite/affine-block-surface';
|
||||
import type {
|
||||
BrushElementModel,
|
||||
HighlighterElementModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
|
||||
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||
|
||||
type BrushLikeModel = BrushElementModel | HighlighterElementModel;
|
||||
|
||||
type RetainedBrushDom = {
|
||||
path: SVGPathElement;
|
||||
svg: SVGSVGElement;
|
||||
};
|
||||
|
||||
const retainedBrushDom = new WeakMap<HTMLElement, RetainedBrushDom>();
|
||||
|
||||
function clearBrushLikeDom(domElement: HTMLElement) {
|
||||
retainedBrushDom.delete(domElement);
|
||||
domElement.replaceChildren();
|
||||
}
|
||||
|
||||
function getRetainedBrushDom(domElement: HTMLElement) {
|
||||
const existing = retainedBrushDom.get(domElement);
|
||||
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const svg = document.createElementNS(SVG_NS, 'svg');
|
||||
svg.style.position = 'absolute';
|
||||
svg.style.left = '0';
|
||||
svg.style.top = '0';
|
||||
svg.style.overflow = 'visible';
|
||||
svg.style.pointerEvents = 'none';
|
||||
|
||||
const path = document.createElementNS(SVG_NS, 'path');
|
||||
path.setAttribute('stroke', 'none');
|
||||
svg.append(path);
|
||||
|
||||
const retained = { svg, path };
|
||||
retainedBrushDom.set(domElement, retained);
|
||||
domElement.replaceChildren(svg);
|
||||
|
||||
return retained;
|
||||
}
|
||||
|
||||
export function renderBrushLikeDom({
|
||||
color,
|
||||
domElement,
|
||||
model,
|
||||
renderer,
|
||||
}: {
|
||||
color: string;
|
||||
domElement: HTMLElement;
|
||||
model: BrushLikeModel;
|
||||
renderer: DomRenderer;
|
||||
}) {
|
||||
const { zoom } = renderer.viewport;
|
||||
const [, , w, h] = model.deserializedXYWH;
|
||||
|
||||
if (w <= 0 || h <= 0 || !model.commands) {
|
||||
clearBrushLikeDom(domElement);
|
||||
return;
|
||||
}
|
||||
|
||||
const { path, svg } = getRetainedBrushDom(domElement);
|
||||
|
||||
svg.style.width = `${w * zoom}px`;
|
||||
svg.style.height = `${h * zoom}px`;
|
||||
svg.style.transform = model.rotate === 0 ? '' : `rotate(${model.rotate}deg)`;
|
||||
svg.style.transformOrigin = model.rotate === 0 ? '' : 'center';
|
||||
svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
|
||||
|
||||
path.setAttribute('d', model.commands);
|
||||
path.setAttribute('fill', color);
|
||||
|
||||
domElement.style.width = `${w * zoom}px`;
|
||||
domElement.style.height = `${h * zoom}px`;
|
||||
domElement.style.overflow = 'visible';
|
||||
domElement.style.pointerEvents = 'none';
|
||||
}
|
||||
32
blocksuite/affine/gfx/brush/src/renderer/element/brush.ts
Normal file
32
blocksuite/affine/gfx/brush/src/renderer/element/brush.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
type ElementRenderer,
|
||||
ElementRendererExtension,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import { type BrushElementModel, DefaultTheme } from '@blocksuite/affine-model';
|
||||
|
||||
export const brush: ElementRenderer<BrushElementModel> = (
|
||||
model,
|
||||
ctx,
|
||||
matrix,
|
||||
renderer
|
||||
) => {
|
||||
const { rotate } = model;
|
||||
const [, , w, h] = model.deserializedXYWH;
|
||||
const cx = w / 2;
|
||||
const cy = h / 2;
|
||||
|
||||
ctx.setTransform(
|
||||
matrix.translateSelf(cx, cy).rotateSelf(rotate).translateSelf(-cx, -cy)
|
||||
);
|
||||
|
||||
const color = renderer.getColorValue(model.color, DefaultTheme.black, true);
|
||||
|
||||
ctx.fillStyle = color;
|
||||
|
||||
ctx.fill(new Path2D(model.commands));
|
||||
};
|
||||
|
||||
export const BrushElementRendererExtension = ElementRendererExtension(
|
||||
'brush',
|
||||
brush
|
||||
);
|
||||
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
type ElementRenderer,
|
||||
ElementRendererExtension,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
DefaultTheme,
|
||||
type HighlighterElementModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
|
||||
export const highlighter: ElementRenderer<HighlighterElementModel> = (
|
||||
model,
|
||||
ctx,
|
||||
matrix,
|
||||
renderer
|
||||
) => {
|
||||
const {
|
||||
rotate,
|
||||
deserializedXYWH: [, , w, h],
|
||||
} = model;
|
||||
const cx = w / 2;
|
||||
const cy = h / 2;
|
||||
|
||||
ctx.setTransform(
|
||||
matrix.translateSelf(cx, cy).rotateSelf(rotate).translateSelf(-cx, -cy)
|
||||
);
|
||||
|
||||
const color = renderer.getColorValue(
|
||||
model.color,
|
||||
DefaultTheme.hightlighterColor,
|
||||
true
|
||||
);
|
||||
|
||||
ctx.fillStyle = color;
|
||||
|
||||
ctx.fill(new Path2D(model.commands));
|
||||
};
|
||||
|
||||
export const HighlighterElementRendererExtension = ElementRendererExtension(
|
||||
'highlighter',
|
||||
highlighter
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
export { BrushElementRendererExtension } from './brush';
|
||||
export { HighlighterElementRendererExtension } from './highlighter';
|
||||
2
blocksuite/affine/gfx/brush/src/renderer/index.ts
Normal file
2
blocksuite/affine/gfx/brush/src/renderer/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './dom';
|
||||
export * from './element';
|
||||
19
blocksuite/affine/gfx/brush/src/store.ts
Normal file
19
blocksuite/affine/gfx/brush/src/store.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import {
|
||||
type StoreExtensionContext,
|
||||
StoreExtensionProvider,
|
||||
} from '@blocksuite/affine-ext-loader';
|
||||
|
||||
import {
|
||||
brushToMarkdownAdapterMatcher,
|
||||
brushToPlainTextAdapterMatcher,
|
||||
} from './adapter';
|
||||
|
||||
export class BrushStoreExtension extends StoreExtensionProvider {
|
||||
override name = 'affine-brush-gfx';
|
||||
|
||||
override setup(context: StoreExtensionContext) {
|
||||
super.setup(context);
|
||||
context.register(brushToMarkdownAdapterMatcher);
|
||||
context.register(brushToPlainTextAdapterMatcher);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { DefaultTool } from '@blocksuite/affine-block-surface';
|
||||
import { ThemeProvider } from '@blocksuite/affine-shared/services';
|
||||
import { EdgelessToolbarToolMixin } from '@blocksuite/affine-widget-edgeless-toolbar';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
|
||||
import { EraserTool } from '../../../eraser-tool';
|
||||
import { EdgelessEraserDarkIcon, EdgelessEraserLightIcon } from './icons.js';
|
||||
|
||||
export class EdgelessEraserToolButton extends EdgelessToolbarToolMixin(
|
||||
LitElement
|
||||
) {
|
||||
static override styles = css`
|
||||
:host {
|
||||
height: 100%;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
.eraser-button {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-end;
|
||||
position: relative;
|
||||
width: 49px;
|
||||
height: 64px;
|
||||
}
|
||||
#edgeless-eraser-icon {
|
||||
transition: transform 0.3s ease-in-out;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
.eraser-button:hover #edgeless-eraser-icon,
|
||||
.eraser-button.active #edgeless-eraser-icon {
|
||||
transform: translateY(0);
|
||||
}
|
||||
`;
|
||||
|
||||
override enableActiveBackground = true;
|
||||
|
||||
override type = EraserTool;
|
||||
|
||||
override firstUpdated() {
|
||||
this.disposables.add(
|
||||
this.edgeless.bindHotKey(
|
||||
{
|
||||
Escape: () => {
|
||||
if (this.edgelessTool.toolType === EraserTool) {
|
||||
this.setEdgelessTool(DefaultTool);
|
||||
}
|
||||
},
|
||||
},
|
||||
{ global: true }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
override render() {
|
||||
const type = this.edgelessTool?.toolType;
|
||||
const appTheme = this.edgeless.std.get(ThemeProvider).app$.value;
|
||||
const icon =
|
||||
appTheme === 'dark' ? EdgelessEraserDarkIcon : EdgelessEraserLightIcon;
|
||||
|
||||
return html`
|
||||
<edgeless-toolbar-button
|
||||
class="edgeless-eraser-button"
|
||||
.tooltip=${html`<affine-tooltip-content-with-shortcut
|
||||
data-tip="${'Eraser'}"
|
||||
data-shortcut="${'E'}"
|
||||
></affine-tooltip-content-with-shortcut>`}
|
||||
.tooltipOffset=${4}
|
||||
.active=${type === EraserTool}
|
||||
@click=${() => this.setEdgelessTool(EraserTool)}
|
||||
>
|
||||
<div class="eraser-button">${icon}</div>
|
||||
</edgeless-toolbar-button>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
import { html } from 'lit';
|
||||
|
||||
export const EdgelessEraserLightIcon = html`<svg
|
||||
width="44"
|
||||
height="49"
|
||||
viewBox="0 0 44 49"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
id="edgeless-eraser-icon"
|
||||
>
|
||||
<g filter="url(#filter0_d_5310_64451)">
|
||||
<rect x="6" y="2" width="32" height="59" rx="5.75" fill="#F1F1F1" />
|
||||
<rect x="6.5" y="2.5" width="31" height="58" rx="5.25" stroke="#E3E2E4" />
|
||||
</g>
|
||||
<g filter="url(#filter1_f_5310_64451)">
|
||||
<rect
|
||||
x="36.2002"
|
||||
y="4.44995"
|
||||
width="18.4"
|
||||
height="3.45"
|
||||
rx="1.725"
|
||||
transform="rotate(90 36.2002 4.44995)"
|
||||
fill="white"
|
||||
fill-opacity="0.78"
|
||||
/>
|
||||
</g>
|
||||
<g filter="url(#filter2_f_5310_64451)">
|
||||
<path
|
||||
d="M4 60H40V22C40 18.6863 37.3137 16 34 16H10C6.68629 16 4 18.6863 4 22V60Z"
|
||||
fill="#343434"
|
||||
fill-opacity="0.22"
|
||||
/>
|
||||
</g>
|
||||
<path
|
||||
d="M16.5 16.5H11H9.37046C8.79521 16.5 8.29403 16.8921 8.15566 17.4505L8.14174 17.5067C7.93847 18.3269 7.27583 18.9534 6.44553 19.1105C5.89708 19.2142 5.5 19.6934 5.5 20.2516V21.75V27.5V39V61.5H16.5V16.5Z"
|
||||
fill="#173654"
|
||||
/>
|
||||
<path
|
||||
d="M16.5 16.5H11H9.37046C8.79521 16.5 8.29403 16.8921 8.15566 17.4505L8.14174 17.5067C7.93847 18.3269 7.27583 18.9534 6.44553 19.1105C5.89708 19.2142 5.5 19.6934 5.5 20.2516V21.75V27.5V39V61.5H16.5V16.5Z"
|
||||
fill="url(#paint0_linear_5310_64451)"
|
||||
fill-opacity="0.2"
|
||||
/>
|
||||
<path
|
||||
d="M16.5 16.5H11H9.37046C8.79521 16.5 8.29403 16.8921 8.15566 17.4505L8.14174 17.5067C7.93847 18.3269 7.27583 18.9534 6.44553 19.1105C5.89708 19.2142 5.5 19.6934 5.5 20.2516V21.75V27.5V39V61.5H16.5V16.5Z"
|
||||
stroke="#E7E7E7"
|
||||
/>
|
||||
<path
|
||||
d="M27.5 16.5H33H34.6295C35.2048 16.5 35.706 16.8921 35.8443 17.4505L35.8583 17.5067C36.0615 18.3269 36.7242 18.9534 37.5545 19.1105C38.1029 19.2142 38.5 19.6934 38.5 20.2516V21.75V27.5V39V61.5H27.5V16.5Z"
|
||||
fill="#1E96EB"
|
||||
/>
|
||||
<path
|
||||
d="M27.5 16.5H33H34.6295C35.2048 16.5 35.706 16.8921 35.8443 17.4505L35.8583 17.5067C36.0615 18.3269 36.7242 18.9534 37.5545 19.1105C38.1029 19.2142 38.5 19.6934 38.5 20.2516V21.75V27.5V39V61.5H27.5V16.5Z"
|
||||
fill="url(#paint1_linear_5310_64451)"
|
||||
fill-opacity="0.2"
|
||||
/>
|
||||
<path
|
||||
d="M27.5 16.5H33H34.6295C35.2048 16.5 35.706 16.8921 35.8443 17.4505L35.8583 17.5067C36.0615 18.3269 36.7242 18.9534 37.5545 19.1105C38.1029 19.2142 38.5 19.6934 38.5 20.2516V21.75V27.5V39V61.5H27.5V16.5Z"
|
||||
stroke="#E7E7E7"
|
||||
/>
|
||||
<rect
|
||||
x="-0.5"
|
||||
y="0.5"
|
||||
width="11"
|
||||
height="45"
|
||||
transform="matrix(-1 0 0 1 27 16)"
|
||||
fill="#EFFAFF"
|
||||
/>
|
||||
<rect
|
||||
x="-0.5"
|
||||
y="0.5"
|
||||
width="11"
|
||||
height="45"
|
||||
transform="matrix(-1 0 0 1 27 16)"
|
||||
fill="url(#paint2_linear_5310_64451)"
|
||||
fill-opacity="0.2"
|
||||
/>
|
||||
<rect
|
||||
x="-0.5"
|
||||
y="0.5"
|
||||
width="11"
|
||||
height="45"
|
||||
transform="matrix(-1 0 0 1 27 16)"
|
||||
stroke="#E7E7E7"
|
||||
/>
|
||||
<defs>
|
||||
<filter
|
||||
id="filter0_d_5310_64451"
|
||||
x="2"
|
||||
y="2"
|
||||
width="40"
|
||||
height="67"
|
||||
filterUnits="userSpaceOnUse"
|
||||
color-interpolation-filters="sRGB"
|
||||
>
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
result="hardAlpha"
|
||||
/>
|
||||
<feOffset dy="4" />
|
||||
<feGaussianBlur stdDeviation="2" />
|
||||
<feComposite in2="hardAlpha" operator="out" />
|
||||
<feColorMatrix
|
||||
type="matrix"
|
||||
values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"
|
||||
/>
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in2="BackgroundImageFix"
|
||||
result="effect1_dropShadow_5310_64451"
|
||||
/>
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in="SourceGraphic"
|
||||
in2="effect1_dropShadow_5310_64451"
|
||||
result="shape"
|
||||
/>
|
||||
</filter>
|
||||
<filter
|
||||
id="filter1_f_5310_64451"
|
||||
x="30.45"
|
||||
y="2.14995"
|
||||
width="8.0502"
|
||||
height="23"
|
||||
filterUnits="userSpaceOnUse"
|
||||
color-interpolation-filters="sRGB"
|
||||
>
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in="SourceGraphic"
|
||||
in2="BackgroundImageFix"
|
||||
result="shape"
|
||||
/>
|
||||
<feGaussianBlur
|
||||
stdDeviation="1.15"
|
||||
result="effect1_foregroundBlur_5310_64451"
|
||||
/>
|
||||
</filter>
|
||||
<filter
|
||||
id="filter2_f_5310_64451"
|
||||
x="0"
|
||||
y="12"
|
||||
width="44"
|
||||
height="52"
|
||||
filterUnits="userSpaceOnUse"
|
||||
color-interpolation-filters="sRGB"
|
||||
>
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in="SourceGraphic"
|
||||
in2="BackgroundImageFix"
|
||||
result="shape"
|
||||
/>
|
||||
<feGaussianBlur
|
||||
stdDeviation="2"
|
||||
result="effect1_foregroundBlur_5310_64451"
|
||||
/>
|
||||
</filter>
|
||||
<linearGradient
|
||||
id="paint0_linear_5310_64451"
|
||||
x1="11"
|
||||
y1="16"
|
||||
x2="11"
|
||||
y2="62"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-opacity="0" />
|
||||
<stop offset="1" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint1_linear_5310_64451"
|
||||
x1="33"
|
||||
y1="16"
|
||||
x2="33"
|
||||
y2="62"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="white" />
|
||||
<stop offset="1" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint2_linear_5310_64451"
|
||||
x1="6"
|
||||
y1="0"
|
||||
x2="6"
|
||||
y2="46"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#FFF8F8" stop-opacity="0" />
|
||||
<stop offset="1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>`;
|
||||
export const EdgelessEraserDarkIcon = html`<svg
|
||||
width="44"
|
||||
height="49"
|
||||
viewBox="0 0 44 49"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
id="edgeless-eraser-icon"
|
||||
>
|
||||
<g filter="url(#filter0_d_5310_64471)">
|
||||
<path
|
||||
d="M6 7.75C6 4.57436 8.57436 2 11.75 2H32.25C35.4256 2 38 4.57436 38 7.75V61H6V7.75Z"
|
||||
fill="#C1C1C1"
|
||||
/>
|
||||
<path
|
||||
d="M6.5 7.75C6.5 4.85051 8.85051 2.5 11.75 2.5H32.25C35.1495 2.5 37.5 4.85051 37.5 7.75V60.5H6.5V7.75Z"
|
||||
stroke="#DDDDDD"
|
||||
/>
|
||||
<path
|
||||
d="M6.5 7.75C6.5 4.85051 8.85051 2.5 11.75 2.5H32.25C35.1495 2.5 37.5 4.85051 37.5 7.75V60.5H6.5V7.75Z"
|
||||
stroke="black"
|
||||
stroke-opacity="0.3"
|
||||
/>
|
||||
</g>
|
||||
<g filter="url(#filter1_f_5310_64471)">
|
||||
<rect
|
||||
x="36.2002"
|
||||
y="4.44995"
|
||||
width="18.4"
|
||||
height="3.45"
|
||||
rx="1.725"
|
||||
transform="rotate(90 36.2002 4.44995)"
|
||||
fill="white"
|
||||
fill-opacity="0.38"
|
||||
/>
|
||||
</g>
|
||||
<g filter="url(#filter2_f_5310_64471)">
|
||||
<path
|
||||
d="M4 62H40V23C40 19.6863 37.3137 17 34 17H10C6.68629 17 4 19.6863 4 23V62Z"
|
||||
fill="black"
|
||||
fill-opacity="0.44"
|
||||
/>
|
||||
</g>
|
||||
<path
|
||||
d="M16.5 16.5H11H9.37046C8.79521 16.5 8.29403 16.8921 8.15566 17.4505L8.14174 17.5067C7.93847 18.3269 7.27583 18.9534 6.44553 19.1105C5.89708 19.2142 5.5 19.6934 5.5 20.2516V21.75V27.5V39V61.5H16.5V16.5Z"
|
||||
fill="#0D2338"
|
||||
/>
|
||||
<path
|
||||
d="M16.5 16.5H11H9.37046C8.79521 16.5 8.29403 16.8921 8.15566 17.4505L8.14174 17.5067C7.93847 18.3269 7.27583 18.9534 6.44553 19.1105C5.89708 19.2142 5.5 19.6934 5.5 20.2516V21.75V27.5V39V61.5H16.5V16.5Z"
|
||||
fill="url(#paint0_linear_5310_64471)"
|
||||
fill-opacity="0.2"
|
||||
/>
|
||||
<path
|
||||
d="M16.5 16.5H11H9.37046C8.79521 16.5 8.29403 16.8921 8.15566 17.4505L8.14174 17.5067C7.93847 18.3269 7.27583 18.9534 6.44553 19.1105C5.89708 19.2142 5.5 19.6934 5.5 20.2516V21.75V27.5V39V61.5H16.5V16.5Z"
|
||||
stroke="#D3D3D3"
|
||||
/>
|
||||
<path
|
||||
d="M16.5 16.5H11H9.37046C8.79521 16.5 8.29403 16.8921 8.15566 17.4505L8.14174 17.5067C7.93847 18.3269 7.27583 18.9534 6.44553 19.1105C5.89708 19.2142 5.5 19.6934 5.5 20.2516V21.75V27.5V39V61.5H16.5V16.5Z"
|
||||
stroke="black"
|
||||
stroke-opacity="0.4"
|
||||
/>
|
||||
<path
|
||||
d="M27.5 16.5H33H34.6295C35.2048 16.5 35.706 16.8921 35.8443 17.4505L35.8583 17.5067C36.0615 18.3269 36.7242 18.9534 37.5545 19.1105C38.1029 19.2142 38.5 19.6934 38.5 20.2516V21.75V27.5V39V61.5H27.5V16.5Z"
|
||||
fill="#1A7CC1"
|
||||
/>
|
||||
<path
|
||||
d="M27.5 16.5H33H34.6295C35.2048 16.5 35.706 16.8921 35.8443 17.4505L35.8583 17.5067C36.0615 18.3269 36.7242 18.9534 37.5545 19.1105C38.1029 19.2142 38.5 19.6934 38.5 20.2516V21.75V27.5V39V61.5H27.5V16.5Z"
|
||||
fill="url(#paint1_linear_5310_64471)"
|
||||
fill-opacity="0.2"
|
||||
/>
|
||||
<path
|
||||
d="M27.5 16.5H33H34.6295C35.2048 16.5 35.706 16.8921 35.8443 17.4505L35.8583 17.5067C36.0615 18.3269 36.7242 18.9534 37.5545 19.1105C38.1029 19.2142 38.5 19.6934 38.5 20.2516V21.75V27.5V39V61.5H27.5V16.5Z"
|
||||
stroke="#D3D3D3"
|
||||
/>
|
||||
<path
|
||||
d="M27.5 16.5H33H34.6295C35.2048 16.5 35.706 16.8921 35.8443 17.4505L35.8583 17.5067C36.0615 18.3269 36.7242 18.9534 37.5545 19.1105C38.1029 19.2142 38.5 19.6934 38.5 20.2516V21.75V27.5V39V61.5H27.5V16.5Z"
|
||||
stroke="black"
|
||||
stroke-opacity="0.4"
|
||||
/>
|
||||
<rect
|
||||
x="-0.5"
|
||||
y="0.5"
|
||||
width="11"
|
||||
height="45"
|
||||
transform="matrix(-1 0 0 1 27 16)"
|
||||
fill="#D0DFE5"
|
||||
/>
|
||||
<rect
|
||||
x="-0.5"
|
||||
y="0.5"
|
||||
width="11"
|
||||
height="45"
|
||||
transform="matrix(-1 0 0 1 27 16)"
|
||||
fill="url(#paint2_linear_5310_64471)"
|
||||
fill-opacity="0.2"
|
||||
/>
|
||||
<rect
|
||||
x="-0.5"
|
||||
y="0.5"
|
||||
width="11"
|
||||
height="45"
|
||||
transform="matrix(-1 0 0 1 27 16)"
|
||||
stroke="#D3D3D3"
|
||||
/>
|
||||
<rect
|
||||
x="-0.5"
|
||||
y="0.5"
|
||||
width="11"
|
||||
height="45"
|
||||
transform="matrix(-1 0 0 1 27 16)"
|
||||
stroke="black"
|
||||
stroke-opacity="0.4"
|
||||
/>
|
||||
<defs>
|
||||
<filter
|
||||
id="filter0_d_5310_64471"
|
||||
x="2"
|
||||
y="0"
|
||||
width="40"
|
||||
height="67"
|
||||
filterUnits="userSpaceOnUse"
|
||||
color-interpolation-filters="sRGB"
|
||||
>
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
result="hardAlpha"
|
||||
/>
|
||||
<feOffset dy="2" />
|
||||
<feGaussianBlur stdDeviation="2" />
|
||||
<feComposite in2="hardAlpha" operator="out" />
|
||||
<feColorMatrix
|
||||
type="matrix"
|
||||
values="0 0 0 0 0.195003 0 0 0 0 0.0133398 0 0 0 0 0.0133398 0 0 0 0.66 0"
|
||||
/>
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in2="BackgroundImageFix"
|
||||
result="effect1_dropShadow_5310_64471"
|
||||
/>
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in="SourceGraphic"
|
||||
in2="effect1_dropShadow_5310_64471"
|
||||
result="shape"
|
||||
/>
|
||||
</filter>
|
||||
<filter
|
||||
id="filter1_f_5310_64471"
|
||||
x="30.45"
|
||||
y="2.14995"
|
||||
width="8.0502"
|
||||
height="23"
|
||||
filterUnits="userSpaceOnUse"
|
||||
color-interpolation-filters="sRGB"
|
||||
>
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in="SourceGraphic"
|
||||
in2="BackgroundImageFix"
|
||||
result="shape"
|
||||
/>
|
||||
<feGaussianBlur
|
||||
stdDeviation="1.15"
|
||||
result="effect1_foregroundBlur_5310_64471"
|
||||
/>
|
||||
</filter>
|
||||
<filter
|
||||
id="filter2_f_5310_64471"
|
||||
x="0"
|
||||
y="13"
|
||||
width="44"
|
||||
height="53"
|
||||
filterUnits="userSpaceOnUse"
|
||||
color-interpolation-filters="sRGB"
|
||||
>
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in="SourceGraphic"
|
||||
in2="BackgroundImageFix"
|
||||
result="shape"
|
||||
/>
|
||||
<feGaussianBlur
|
||||
stdDeviation="2"
|
||||
result="effect1_foregroundBlur_5310_64471"
|
||||
/>
|
||||
</filter>
|
||||
<linearGradient
|
||||
id="paint0_linear_5310_64471"
|
||||
x1="11"
|
||||
y1="16"
|
||||
x2="11"
|
||||
y2="62"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-opacity="0" />
|
||||
<stop offset="1" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint1_linear_5310_64471"
|
||||
x1="33"
|
||||
y1="16"
|
||||
x2="33"
|
||||
y2="62"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="white" />
|
||||
<stop offset="1" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint2_linear_5310_64471"
|
||||
x1="6"
|
||||
y1="0"
|
||||
x2="6"
|
||||
y2="46"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#FFF8F8" stop-opacity="0" />
|
||||
<stop offset="1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>`;
|
||||
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
EdgelessBrushDarkIcon,
|
||||
EdgelessBrushLightIcon,
|
||||
EdgelessHighlighterDarkIcon,
|
||||
EdgelessHighlighterLightIcon,
|
||||
} from './icons';
|
||||
import type { Pen } from './types';
|
||||
|
||||
export const penIconMap = {
|
||||
dark: {
|
||||
brush: EdgelessBrushDarkIcon,
|
||||
highlighter: EdgelessHighlighterDarkIcon,
|
||||
},
|
||||
light: {
|
||||
brush: EdgelessBrushLightIcon,
|
||||
highlighter: EdgelessHighlighterLightIcon,
|
||||
},
|
||||
};
|
||||
|
||||
export const penInfoMap: { [k in Pen]: { tip: string; shortcut: string } } = {
|
||||
brush: {
|
||||
tip: 'Pen',
|
||||
shortcut: 'P',
|
||||
},
|
||||
highlighter: {
|
||||
tip: 'Highlighter',
|
||||
shortcut: '⇧ P',
|
||||
},
|
||||
};
|
||||
608
blocksuite/affine/gfx/brush/src/toolbar/components/pen/icons.ts
Normal file
608
blocksuite/affine/gfx/brush/src/toolbar/components/pen/icons.ts
Normal file
@@ -0,0 +1,608 @@
|
||||
import { html } from 'lit';
|
||||
|
||||
export const EdgelessBrushLightIcon = html`
|
||||
<svg
|
||||
width="36"
|
||||
height="60"
|
||||
viewBox="0 0 36 60"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g filter="url(#filter0_d_5310_64454)">
|
||||
<path
|
||||
d="M8 38.8965L12.2828 37.4689V106.538H8V38.8965Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M8 38.8965L12.2828 37.4689V106.538H8V38.8965Z"
|
||||
fill="white"
|
||||
fill-opacity="0.1"
|
||||
/>
|
||||
<path
|
||||
d="M12.2832 36.993H17.5177V106.538H12.2832V36.993Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M17.5176 36.993H22.7521V106.538H17.5176V36.993Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M17.5176 36.993H22.7521V106.538H17.5176V36.993Z"
|
||||
fill="black"
|
||||
fill-opacity="0.1"
|
||||
/>
|
||||
<path
|
||||
d="M22.752 30.9448L27.0347 38.8965V106.538H22.752V30.9448Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M22.752 30.9448L27.0347 38.8965V106.538H22.752V30.9448Z"
|
||||
fill="black"
|
||||
fill-opacity="0.2"
|
||||
/>
|
||||
<path
|
||||
d="M16.5909 2.88078C16.8233 1.90625 18.2099 1.90623 18.4423 2.88075L19.896 8.97414L22.2755 18.9483L27.0345 38.8965L23.9871 38.0231C23.1982 37.7969 22.3511 37.9039 21.6431 38.3189L18.023 40.4414C17.7107 40.6245 17.3238 40.6245 17.0115 40.4414L13.0218 38.1023C12.5499 37.8256 11.9851 37.7543 11.4592 37.905L8 38.8965L12.7583 18.9483L15.1374 8.97414L16.5909 2.88078Z"
|
||||
fill="#F1F1F1"
|
||||
/>
|
||||
<path
|
||||
d="M16.5909 2.88078C16.8233 1.90625 18.2099 1.90623 18.4423 2.88075L19.896 8.97414L22.2755 18.9483L27.0345 38.8965L23.9871 38.0231C23.1982 37.7969 22.3511 37.9039 21.6431 38.3189L18.023 40.4414C17.7107 40.6245 17.3238 40.6245 17.0115 40.4414L13.0218 38.1023C12.5499 37.8256 11.9851 37.7543 11.4592 37.905L8 38.8965L12.7583 18.9483L15.1374 8.97414L16.5909 2.88078Z"
|
||||
fill="url(#paint0_linear_5310_64454)"
|
||||
fill-opacity="0.1"
|
||||
/>
|
||||
<g filter="url(#filter1_b_5310_64454)">
|
||||
<path
|
||||
d="M16.7391 2.26209C16.9345 1.44293 18.1 1.44293 18.2954 2.26209L20.3725 10.969H14.6621L16.7391 2.26209Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<filter
|
||||
id="filter0_d_5310_64454"
|
||||
x="0"
|
||||
y="-5"
|
||||
width="35.0352"
|
||||
height="124"
|
||||
filterUnits="userSpaceOnUse"
|
||||
color-interpolation-filters="sRGB"
|
||||
>
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
result="hardAlpha"
|
||||
/>
|
||||
<feOffset dy="4" />
|
||||
<feGaussianBlur stdDeviation="4" />
|
||||
<feComposite in2="hardAlpha" operator="out" />
|
||||
<feColorMatrix
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.2 0"
|
||||
/>
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in2="BackgroundImageFix"
|
||||
result="effect1_dropShadow_5310_64454"
|
||||
/>
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in="SourceGraphic"
|
||||
in2="effect1_dropShadow_5310_64454"
|
||||
result="shape"
|
||||
/>
|
||||
</filter>
|
||||
<filter
|
||||
id="filter1_b_5310_64454"
|
||||
x="12.7587"
|
||||
y="-0.255743"
|
||||
width="9.51686"
|
||||
height="13.1282"
|
||||
filterUnits="userSpaceOnUse"
|
||||
color-interpolation-filters="sRGB"
|
||||
>
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||
<feGaussianBlur in="BackgroundImageFix" stdDeviation="0.951724" />
|
||||
<feComposite
|
||||
in2="SourceAlpha"
|
||||
operator="in"
|
||||
result="effect1_backgroundBlur_5310_64454"
|
||||
/>
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in="SourceGraphic"
|
||||
in2="effect1_backgroundBlur_5310_64454"
|
||||
result="shape"
|
||||
/>
|
||||
</filter>
|
||||
<linearGradient
|
||||
id="paint0_linear_5310_64454"
|
||||
x1="22.1949"
|
||||
y1="19.2552"
|
||||
x2="11.0983"
|
||||
y2="21.5941"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop />
|
||||
<stop offset="0.3125" stop-opacity="0" />
|
||||
<stop offset="1" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
`;
|
||||
export const EdgelessBrushDarkIcon = html`<svg
|
||||
width="34"
|
||||
height="60"
|
||||
viewBox="0 0 34 60"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g filter="url(#filter0_d_5310_64464)">
|
||||
<path
|
||||
d="M7 38.8965L11.2828 37.4689V106.538H7V38.8965Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M7 38.8965L11.2828 37.4689V106.538H7V38.8965Z"
|
||||
fill="black"
|
||||
fill-opacity="0.1"
|
||||
/>
|
||||
<path
|
||||
d="M11.2832 36.993H16.5177V106.538H11.2832V36.993Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M11.2832 36.993H16.5177V106.538H11.2832V36.993Z"
|
||||
fill="black"
|
||||
fill-opacity="0.26"
|
||||
/>
|
||||
<path
|
||||
d="M16.5176 36.993H21.7521V106.538H16.5176V36.993Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M16.5176 36.993H21.7521V106.538H16.5176V36.993Z"
|
||||
fill="black"
|
||||
fill-opacity="0.4"
|
||||
/>
|
||||
<path
|
||||
d="M21.752 30.9448L26.0347 38.8965V106.538H21.752V30.9448Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M21.752 30.9448L26.0347 38.8965V106.538H21.752V30.9448Z"
|
||||
fill="black"
|
||||
fill-opacity="0.6"
|
||||
/>
|
||||
<path
|
||||
d="M15.5909 2.88078C15.8233 1.90625 17.2099 1.90623 17.4423 2.88075L18.896 8.97414L21.2755 18.9483L26.0345 38.8965L22.9871 38.0231C22.1982 37.7969 21.3511 37.9039 20.6431 38.3189L17.023 40.4414C16.7107 40.6245 16.3238 40.6245 16.0115 40.4414L12.0218 38.1023C11.5499 37.8256 10.9851 37.7543 10.4592 37.905L7 38.8965L11.7583 18.9483L14.1374 8.97414L15.5909 2.88078Z"
|
||||
fill="#C1C1C1"
|
||||
/>
|
||||
<path
|
||||
d="M15.5909 2.88078C15.8233 1.90625 17.2099 1.90623 17.4423 2.88075L18.896 8.97414L21.2755 18.9483L26.0345 38.8965L22.9871 38.0231C22.1982 37.7969 21.3511 37.9039 20.6431 38.3189L17.023 40.4414C16.7107 40.6245 16.3238 40.6245 16.0115 40.4414L12.0218 38.1023C11.5499 37.8256 10.9851 37.7543 10.4592 37.905L7 38.8965L11.7583 18.9483L14.1374 8.97414L15.5909 2.88078Z"
|
||||
fill="url(#paint0_linear_5310_64464)"
|
||||
fill-opacity="0.1"
|
||||
/>
|
||||
<g filter="url(#filter1_b_5310_64464)">
|
||||
<path
|
||||
d="M15.7391 2.26209C15.9345 1.44293 17.1 1.44293 17.2954 2.26209L19.3725 10.969H13.6621L15.7391 2.26209Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M15.7391 2.26209C15.9345 1.44293 17.1 1.44293 17.2954 2.26209L19.3725 10.969H13.6621L15.7391 2.26209Z"
|
||||
fill="black"
|
||||
fill-opacity="0.2"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<filter
|
||||
id="filter0_d_5310_64464"
|
||||
x="0"
|
||||
y="-6"
|
||||
width="33.0352"
|
||||
height="122"
|
||||
filterUnits="userSpaceOnUse"
|
||||
color-interpolation-filters="sRGB"
|
||||
>
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
result="hardAlpha"
|
||||
/>
|
||||
<feOffset dy="2" />
|
||||
<feGaussianBlur stdDeviation="3.5" />
|
||||
<feComposite in2="hardAlpha" operator="out" />
|
||||
<feColorMatrix
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.78 0"
|
||||
/>
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in2="BackgroundImageFix"
|
||||
result="effect1_dropShadow_5310_64464"
|
||||
/>
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in="SourceGraphic"
|
||||
in2="effect1_dropShadow_5310_64464"
|
||||
result="shape"
|
||||
/>
|
||||
</filter>
|
||||
<filter
|
||||
id="filter1_b_5310_64464"
|
||||
x="11.7587"
|
||||
y="-0.255743"
|
||||
width="9.51686"
|
||||
height="13.1282"
|
||||
filterUnits="userSpaceOnUse"
|
||||
color-interpolation-filters="sRGB"
|
||||
>
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||
<feGaussianBlur in="BackgroundImageFix" stdDeviation="0.951724" />
|
||||
<feComposite
|
||||
in2="SourceAlpha"
|
||||
operator="in"
|
||||
result="effect1_backgroundBlur_5310_64464"
|
||||
/>
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in="SourceGraphic"
|
||||
in2="effect1_backgroundBlur_5310_64464"
|
||||
result="shape"
|
||||
/>
|
||||
</filter>
|
||||
<linearGradient
|
||||
id="paint0_linear_5310_64464"
|
||||
x1="21.1949"
|
||||
y1="19.2552"
|
||||
x2="11.5553"
|
||||
y2="21.8444"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop />
|
||||
<stop offset="0.302413" stop-opacity="0" />
|
||||
<stop offset="0.557292" stop-opacity="0" />
|
||||
<stop offset="1" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>`;
|
||||
|
||||
export const EdgelessHighlighterDarkIcon = html`
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="29"
|
||||
height="63"
|
||||
viewBox="0 0 29 63"
|
||||
fill="none"
|
||||
>
|
||||
<g filter="url(#filter0_d_4930_46244)">
|
||||
<g filter="url(#filter1_i_4930_46244)">
|
||||
<path
|
||||
d="M11.4922 5.7205C11.4922 5.2902 11.7675 4.90814 12.1756 4.77193L16.1689 3.43934C16.8165 3.22323 17.4855 3.70522 17.4855 4.38792V12H11.4922V5.7205Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</g>
|
||||
<rect x="4.5" y="32.5" width="20" height="75.5" fill="#303030" />
|
||||
<rect
|
||||
x="4.5"
|
||||
y="32.5"
|
||||
width="20"
|
||||
height="75.5"
|
||||
fill="url(#paint0_linear_4930_46244)"
|
||||
fill-opacity="0.1"
|
||||
/>
|
||||
<path
|
||||
d="M9.24747 11.5H19.7525V13.3774C19.7525 19.0605 20.9635 24.6784 23.3049 29.8568L24.5 32.5H4.5L5.69508 29.8568C8.03645 24.6784 9.24747 19.0605 9.24747 13.3774V11.5Z"
|
||||
fill="#3D3D3D"
|
||||
/>
|
||||
<path
|
||||
d="M9.24747 11.5H19.7525V13.3774C19.7525 19.0605 20.9635 24.6784 23.3049 29.8568L24.5 32.5H4.5L5.69508 29.8568C8.03645 24.6784 9.24747 19.0605 9.24747 13.3774V11.5Z"
|
||||
fill="url(#paint1_linear_4930_46244)"
|
||||
fill-opacity="0.1"
|
||||
/>
|
||||
<path
|
||||
d="M9.24747 11.5H19.7525V13.3774C19.7525 19.0605 20.9635 24.6784 23.3049 29.8568L24.5 32.5H4.5L5.69508 29.8568C8.03645 24.6784 9.24747 19.0605 9.24747 13.3774V11.5Z"
|
||||
fill="url(#paint2_linear_4930_46244)"
|
||||
fill-opacity="0.2"
|
||||
/>
|
||||
<rect x="4.5" y="37" width="20" height="8" fill="currentColor" />
|
||||
<rect
|
||||
x="4.5"
|
||||
y="37"
|
||||
width="20"
|
||||
height="8"
|
||||
fill="url(#paint3_linear_4930_46244)"
|
||||
fill-opacity="0.2"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter
|
||||
id="filter0_d_4930_46244"
|
||||
x="0.5"
|
||||
y="0"
|
||||
width="28"
|
||||
height="116"
|
||||
filterUnits="userSpaceOnUse"
|
||||
color-interpolation-filters="sRGB"
|
||||
>
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
result="hardAlpha"
|
||||
/>
|
||||
<feOffset dy="4" />
|
||||
<feGaussianBlur stdDeviation="2" />
|
||||
<feComposite in2="hardAlpha" operator="out" />
|
||||
<feColorMatrix
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"
|
||||
/>
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in2="BackgroundImageFix"
|
||||
result="effect1_dropShadow_4930_46244"
|
||||
/>
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in="SourceGraphic"
|
||||
in2="effect1_dropShadow_4930_46244"
|
||||
result="shape"
|
||||
/>
|
||||
</filter>
|
||||
<filter
|
||||
id="filter1_i_4930_46244"
|
||||
x="11.4922"
|
||||
y="3.38721"
|
||||
width="5.99316"
|
||||
height="8.61279"
|
||||
filterUnits="userSpaceOnUse"
|
||||
color-interpolation-filters="sRGB"
|
||||
>
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in="SourceGraphic"
|
||||
in2="BackgroundImageFix"
|
||||
result="shape"
|
||||
/>
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
result="hardAlpha"
|
||||
/>
|
||||
<feOffset dy="1.5" />
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
|
||||
<feColorMatrix
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.2 0"
|
||||
/>
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in2="shape"
|
||||
result="effect1_innerShadow_4930_46244"
|
||||
/>
|
||||
</filter>
|
||||
<linearGradient
|
||||
id="paint0_linear_4930_46244"
|
||||
x1="4.5"
|
||||
y1="57.6667"
|
||||
x2="24.5"
|
||||
y2="57.6667"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-opacity="0" />
|
||||
<stop offset="0.4" stop-opacity="0" />
|
||||
<stop offset="0.49" stop-opacity="0" />
|
||||
<stop offset="0.825" />
|
||||
<stop offset="0.9" stop-color="#797979" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint1_linear_4930_46244"
|
||||
x1="8.38889"
|
||||
y1="23.5"
|
||||
x2="20.6608"
|
||||
y2="21.5398"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0.112839" stop-opacity="0" />
|
||||
<stop offset="0.634555" stop-opacity="0" />
|
||||
<stop offset="1" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint2_linear_4930_46244"
|
||||
x1="1.5"
|
||||
y1="24.5"
|
||||
x2="28"
|
||||
y2="18.5"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-opacity="0" />
|
||||
<stop offset="0.44" stop-opacity="0.5" />
|
||||
<stop offset="0.59" />
|
||||
<stop offset="1" stop-color="white" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint3_linear_4930_46244"
|
||||
x1="4.5"
|
||||
y1="41"
|
||||
x2="24.5"
|
||||
y2="41"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-opacity="0" />
|
||||
<stop offset="0.835" />
|
||||
<stop offset="1" stop-opacity="0.77" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
export const EdgelessHighlighterLightIcon = html`
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="29"
|
||||
height="63"
|
||||
viewBox="0 0 29 63"
|
||||
fill="none"
|
||||
>
|
||||
<g filter="url(#filter0_d_4665_35356)">
|
||||
<g filter="url(#filter1_i_4665_35356)">
|
||||
<path
|
||||
d="M11.4922 5.7205C11.4922 5.2902 11.7675 4.90814 12.1756 4.77193L16.1689 3.43934C16.8165 3.22323 17.4855 3.70522 17.4855 4.38792V12H11.4922V5.7205Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</g>
|
||||
<rect x="4.5" y="32.5" width="20" height="75.5" fill="#F3F3F3" />
|
||||
<rect
|
||||
x="4.5"
|
||||
y="32.5"
|
||||
width="20"
|
||||
height="75.5"
|
||||
fill="url(#paint0_linear_4665_35356)"
|
||||
fill-opacity="0.1"
|
||||
/>
|
||||
<path
|
||||
d="M9.24747 11.5H19.7525V13.3774C19.7525 19.0605 20.9635 24.6784 23.3049 29.8568L24.5 32.5H4.5L5.69508 29.8568C8.03645 24.6784 9.24747 19.0605 9.24747 13.3774V11.5Z"
|
||||
fill="#FAFAFA"
|
||||
/>
|
||||
<path
|
||||
d="M9.24747 11.5H19.7525V13.3774C19.7525 19.0605 20.9635 24.6784 23.3049 29.8568L24.5 32.5H4.5L5.69508 29.8568C8.03645 24.6784 9.24747 19.0605 9.24747 13.3774V11.5Z"
|
||||
fill="url(#paint1_linear_4665_35356)"
|
||||
fill-opacity="0.1"
|
||||
/>
|
||||
<rect x="4.5" y="37" width="20" height="8" fill="currentColor" />
|
||||
<rect
|
||||
x="4.5"
|
||||
y="37"
|
||||
width="20"
|
||||
height="8"
|
||||
fill="url(#paint2_linear_4665_35356)"
|
||||
fill-opacity="0.2"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter
|
||||
id="filter0_d_4665_35356"
|
||||
x="0.5"
|
||||
y="0"
|
||||
width="28"
|
||||
height="116"
|
||||
filterUnits="userSpaceOnUse"
|
||||
color-interpolation-filters="sRGB"
|
||||
>
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
result="hardAlpha"
|
||||
/>
|
||||
<feOffset dy="4" />
|
||||
<feGaussianBlur stdDeviation="2" />
|
||||
<feComposite in2="hardAlpha" operator="out" />
|
||||
<feColorMatrix
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"
|
||||
/>
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in2="BackgroundImageFix"
|
||||
result="effect1_dropShadow_4665_35356"
|
||||
/>
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in="SourceGraphic"
|
||||
in2="effect1_dropShadow_4665_35356"
|
||||
result="shape"
|
||||
/>
|
||||
</filter>
|
||||
<filter
|
||||
id="filter1_i_4665_35356"
|
||||
x="11.4922"
|
||||
y="3.38721"
|
||||
width="5.99414"
|
||||
height="8.61279"
|
||||
filterUnits="userSpaceOnUse"
|
||||
color-interpolation-filters="sRGB"
|
||||
>
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in="SourceGraphic"
|
||||
in2="BackgroundImageFix"
|
||||
result="shape"
|
||||
/>
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
result="hardAlpha"
|
||||
/>
|
||||
<feOffset dy="1.5" />
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
|
||||
<feColorMatrix
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.2 0"
|
||||
/>
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in2="shape"
|
||||
result="effect1_innerShadow_4665_35356"
|
||||
/>
|
||||
</filter>
|
||||
<linearGradient
|
||||
id="paint0_linear_4665_35356"
|
||||
x1="4.5"
|
||||
y1="69.1558"
|
||||
x2="24.5"
|
||||
y2="69.1558"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-opacity="0" />
|
||||
<stop offset="0.53" stop-opacity="0" />
|
||||
<stop offset="1" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint1_linear_4665_35356"
|
||||
x1="8.38889"
|
||||
y1="23.5"
|
||||
x2="20.6608"
|
||||
y2="21.5398"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0.112839" stop-opacity="0" />
|
||||
<stop offset="0.634555" stop-opacity="0" />
|
||||
<stop offset="1" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint2_linear_4665_35356"
|
||||
x1="4.5"
|
||||
y1="41"
|
||||
x2="24.5"
|
||||
y2="41"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-opacity="0" />
|
||||
<stop offset="0.835" stop-opacity="0.99" />
|
||||
<stop offset="1" stop-opacity="0.77" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
export const penIconMap = {
|
||||
dark: {
|
||||
brush: EdgelessBrushDarkIcon,
|
||||
highlighter: EdgelessHighlighterDarkIcon,
|
||||
},
|
||||
light: {
|
||||
brush: EdgelessBrushLightIcon,
|
||||
highlighter: EdgelessHighlighterLightIcon,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,215 @@
|
||||
import { adjustColorAlpha } from '@blocksuite/affine-components/color-picker';
|
||||
import {
|
||||
BRUSH_LINE_WIDTHS,
|
||||
DefaultTheme,
|
||||
HIGHLIGHTER_LINE_WIDTHS,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
FeatureFlagService,
|
||||
ThemeProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import type { ColorEvent } from '@blocksuite/affine-shared/utils';
|
||||
import { EdgelessToolbarToolMixin } from '@blocksuite/affine-widget-edgeless-toolbar';
|
||||
import { SignalWatcher } from '@blocksuite/global/lit';
|
||||
import {
|
||||
computed,
|
||||
type ReadonlySignal,
|
||||
type Signal,
|
||||
} from '@preact/signals-core';
|
||||
import { css, html, LitElement, type TemplateResult } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { BrushTool } from '../../../brush-tool';
|
||||
import { HighlighterTool } from '../../../highlighter-tool';
|
||||
import { penInfoMap } from './consts';
|
||||
import type { Pen, PenMap } from './types';
|
||||
|
||||
export class EdgelessPenMenu extends EdgelessToolbarToolMixin(
|
||||
SignalWatcher(LitElement)
|
||||
) {
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.pens {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
padding: 0 4px;
|
||||
align-items: flex-end;
|
||||
|
||||
edgeless-tool-icon-button {
|
||||
display: flex;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.pen-wrapper {
|
||||
display: flex;
|
||||
min-width: 38px;
|
||||
height: 64px;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
transform: translateY(-2px);
|
||||
transition-property: color, transform;
|
||||
transition-duration: 300ms;
|
||||
transition-timing-function: ease-in-out;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pen-wrapper:hover,
|
||||
.pen-wrapper:active,
|
||||
.pen-wrapper[data-active] {
|
||||
transform: translateY(-22px);
|
||||
}
|
||||
}
|
||||
|
||||
.menu-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
menu-divider {
|
||||
display: flex;
|
||||
align-self: center;
|
||||
height: 24px;
|
||||
margin: 0 9px;
|
||||
}
|
||||
`;
|
||||
|
||||
private readonly _theme$ = computed(() => {
|
||||
return this.edgeless.std.get(ThemeProvider).theme$.value;
|
||||
});
|
||||
|
||||
private readonly _onPickPen = (tool: Pen) => {
|
||||
this.pen$.value = tool;
|
||||
if (tool === 'brush') {
|
||||
this.setEdgelessTool(BrushTool);
|
||||
} else {
|
||||
this.setEdgelessTool(HighlighterTool);
|
||||
}
|
||||
};
|
||||
|
||||
private readonly _onPickColor = (e: ColorEvent) => {
|
||||
let color = e.detail.value;
|
||||
if (this.pen$.peek() === 'highlighter') {
|
||||
color = adjustColorAlpha(color, 0.3);
|
||||
}
|
||||
this.onChange({ color });
|
||||
};
|
||||
|
||||
private readonly _onPickLineWidth = (e: CustomEvent<number>) => {
|
||||
e.stopPropagation();
|
||||
this.onChange({ lineWidth: e.detail });
|
||||
};
|
||||
|
||||
override type = [BrushTool, HighlighterTool];
|
||||
|
||||
override render() {
|
||||
const {
|
||||
_theme$: { value: theme },
|
||||
colors$: {
|
||||
value: { brush: brushColor, highlighter: highlighterColor },
|
||||
},
|
||||
penIconMap$: {
|
||||
value: { brush: brushIcon, highlighter: highlighterIcon },
|
||||
},
|
||||
penInfo$: {
|
||||
value: { type, color, lineWidth },
|
||||
},
|
||||
} = this;
|
||||
|
||||
const lineWidths =
|
||||
type === 'brush' ? BRUSH_LINE_WIDTHS : HIGHLIGHTER_LINE_WIDTHS;
|
||||
|
||||
return html`
|
||||
<edgeless-slide-menu>
|
||||
<div class="pens" slot="prefix">
|
||||
<edgeless-tool-icon-button
|
||||
class="edgeless-brush-button"
|
||||
.tooltip=${html`<affine-tooltip-content-with-shortcut
|
||||
data-tip="${penInfoMap.brush.tip}"
|
||||
data-shortcut="${penInfoMap.brush.shortcut}"
|
||||
></affine-tooltip-content-with-shortcut>`}
|
||||
.tooltipOffset=${20}
|
||||
.hover=${false}
|
||||
@click=${() => this._onPickPen('brush')}
|
||||
>
|
||||
<div
|
||||
class="pen-wrapper"
|
||||
style=${styleMap({ color: brushColor })}
|
||||
?data-active="${type === 'brush'}"
|
||||
>
|
||||
${brushIcon}
|
||||
</div>
|
||||
</edgeless-tool-icon-button>
|
||||
|
||||
<edgeless-tool-icon-button
|
||||
class="edgeless-highlighter-button"
|
||||
.tooltip=${html`<affine-tooltip-content-with-shortcut
|
||||
data-tip="${penInfoMap.highlighter.tip}"
|
||||
data-shortcut="${penInfoMap.highlighter.shortcut}"
|
||||
></affine-tooltip-content-with-shortcut>`}
|
||||
.tooltipOffset=${20}
|
||||
.hover=${false}
|
||||
@click=${() => this._onPickPen('highlighter')}
|
||||
>
|
||||
<div
|
||||
class="pen-wrapper"
|
||||
style=${styleMap({ color: highlighterColor })}
|
||||
?data-active="${type === 'highlighter'}"
|
||||
>
|
||||
${highlighterIcon}
|
||||
</div>
|
||||
</edgeless-tool-icon-button>
|
||||
<menu-divider .vertical=${true}></menu-divider>
|
||||
</div>
|
||||
<div class="menu-content">
|
||||
<edgeless-line-width-panel
|
||||
.selectedSize=${lineWidth}
|
||||
.lineWidths=${lineWidths}
|
||||
@select=${this._onPickLineWidth}
|
||||
>
|
||||
</edgeless-line-width-panel>
|
||||
<menu-divider .vertical=${true}></menu-divider>
|
||||
<edgeless-color-panel
|
||||
class="one-way"
|
||||
@select=${this._onPickColor}
|
||||
.value=${color}
|
||||
.theme=${theme}
|
||||
.palettes=${DefaultTheme.StrokeColorShortPalettes}
|
||||
.shouldKeepColor=${true}
|
||||
.hasTransparent=${!this.edgeless.store
|
||||
.get(FeatureFlagService)
|
||||
.getFlag('enable_color_picker')}
|
||||
></edgeless-color-panel>
|
||||
</div>
|
||||
</edgeless-slide-menu>
|
||||
`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onChange!: (props: Record<string, unknown>) => void;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor colors$!: ReadonlySignal<PenMap<string>>;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor penIconMap$!: ReadonlySignal<PenMap<TemplateResult>>;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor pen$!: Signal<Pen>;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor penInfo$!: ReadonlySignal<{
|
||||
type: Pen;
|
||||
color: string;
|
||||
icon: TemplateResult<1>;
|
||||
lineWidth: number;
|
||||
tip: string;
|
||||
shortcut: string;
|
||||
}>;
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { keepColor } from '@blocksuite/affine-components/color-picker';
|
||||
import {
|
||||
EditPropsStore,
|
||||
ThemeProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { EdgelessToolbarToolMixin } from '@blocksuite/affine-widget-edgeless-toolbar';
|
||||
import { SignalWatcher } from '@blocksuite/global/lit';
|
||||
import { computed, signal } from '@preact/signals-core';
|
||||
import { css, html, LitElement, nothing } from 'lit';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
import { when } from 'lit/directives/when.js';
|
||||
|
||||
import { BrushTool } from '../../../brush-tool';
|
||||
import { HighlighterTool } from '../../../highlighter-tool';
|
||||
import { penIconMap, penInfoMap } from './consts';
|
||||
import type { Pen } from './types';
|
||||
|
||||
export class EdgelessPenToolButton extends EdgelessToolbarToolMixin(
|
||||
SignalWatcher(LitElement)
|
||||
) {
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
.edgeless-pen-button {
|
||||
height: 100%;
|
||||
}
|
||||
.pen-wrapper {
|
||||
width: 35px;
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
}
|
||||
.pen-wrapper svg {
|
||||
transition-property: color, transform;
|
||||
transition-duration: 300ms;
|
||||
transition-timing-function: ease-in-out;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
.edgeless-pen-button:hover .pen-wrapper svg,
|
||||
.pen-wrapper.active svg {
|
||||
transform: translateY(0);
|
||||
}
|
||||
`;
|
||||
|
||||
get themeProvider() {
|
||||
return this.edgeless.std.get(ThemeProvider);
|
||||
}
|
||||
|
||||
get settings() {
|
||||
return this.edgeless.std.get(EditPropsStore);
|
||||
}
|
||||
|
||||
private readonly colors$ = computed(() => {
|
||||
const theme = this.themeProvider.theme$.value;
|
||||
const brush = this.settings.lastProps$.value.brush.color;
|
||||
const highlighter = this.settings.lastProps$.value.highlighter.color;
|
||||
return {
|
||||
brush: keepColor(
|
||||
this.themeProvider.generateColorProperty(brush, undefined, theme)
|
||||
),
|
||||
highlighter: keepColor(
|
||||
this.themeProvider.generateColorProperty(highlighter, undefined, theme)
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
private readonly color$ = computed(() => {
|
||||
const pen = this.pen$.value;
|
||||
return this.colors$.value[pen];
|
||||
});
|
||||
|
||||
private readonly lineWidths$ = computed(() => {
|
||||
const brush = this.settings.lastProps$.value.brush.lineWidth;
|
||||
const highlighter = this.settings.lastProps$.value.highlighter.lineWidth;
|
||||
return {
|
||||
brush,
|
||||
highlighter,
|
||||
};
|
||||
});
|
||||
|
||||
private readonly lineWidth$ = computed(() => {
|
||||
const pen = this.pen$.value;
|
||||
return this.lineWidths$.value[pen];
|
||||
});
|
||||
|
||||
private readonly penIconMap$ = computed(() => {
|
||||
const theme = this.themeProvider.app$.value;
|
||||
return penIconMap[theme];
|
||||
});
|
||||
|
||||
private readonly penIcon$ = computed(() => {
|
||||
const pen = this.pen$.value;
|
||||
return this.penIconMap$.value[pen];
|
||||
});
|
||||
|
||||
private readonly penInfo$ = computed(() => {
|
||||
const type = this.pen$.value;
|
||||
return {
|
||||
...penInfoMap[type],
|
||||
type: this.pen$.value,
|
||||
icon: this.penIcon$.value,
|
||||
color: this.color$.value,
|
||||
lineWidth: this.lineWidth$.value,
|
||||
};
|
||||
});
|
||||
|
||||
private readonly pen$ = signal<Pen>('brush');
|
||||
|
||||
override enableActiveBackground = true;
|
||||
|
||||
override type = [BrushTool, HighlighterTool];
|
||||
|
||||
override firstUpdated() {
|
||||
this.disposables.add(
|
||||
this.gfx.tool.currentToolName$.subscribe(name => {
|
||||
const tool = this.type.find(t => t.toolName === name);
|
||||
if (!tool) {
|
||||
this.tryDisposePopper();
|
||||
return;
|
||||
}
|
||||
|
||||
if (tool.toolName !== this.pen$.peek()) {
|
||||
this.pen$.value = tool.toolName as Pen;
|
||||
}
|
||||
|
||||
if (this.active) return;
|
||||
|
||||
this._togglePenMenu();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private _togglePenMenu() {
|
||||
if (this.tryDisposePopper()) return;
|
||||
const setPenByType = (pen: Pen) => {
|
||||
if (pen === 'brush') {
|
||||
this.setEdgelessTool(BrushTool);
|
||||
} else {
|
||||
this.setEdgelessTool(HighlighterTool);
|
||||
}
|
||||
};
|
||||
if (!this.active) {
|
||||
const pen = this.pen$.peek();
|
||||
setPenByType(pen);
|
||||
}
|
||||
const menu = this.createPopper('edgeless-pen-menu', this);
|
||||
Object.assign(menu.element, {
|
||||
colors$: this.colors$,
|
||||
penIconMap$: this.penIconMap$,
|
||||
pen$: this.pen$,
|
||||
penInfo$: this.penInfo$,
|
||||
edgeless: this.edgeless,
|
||||
onChange: (props: Record<string, unknown>) => {
|
||||
const pen = this.pen$.peek();
|
||||
this.edgeless.std.get(EditPropsStore).recordLastProps(pen, props);
|
||||
setPenByType(pen);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
override render() {
|
||||
const {
|
||||
active,
|
||||
penInfo$: {
|
||||
value: { type, color, icon, tip, shortcut },
|
||||
},
|
||||
} = this;
|
||||
|
||||
return html`
|
||||
<edgeless-toolbar-button
|
||||
class="edgeless-pen-button"
|
||||
data-drawing-tool="${type}"
|
||||
.tooltip=${when(
|
||||
this.popper,
|
||||
() => nothing,
|
||||
() =>
|
||||
html`<affine-tooltip-content-with-shortcut
|
||||
data-tip="${tip}"
|
||||
data-shortcut="${shortcut}"
|
||||
></affine-tooltip-content-with-shortcut>`
|
||||
)}
|
||||
.tooltipOffset=${4}
|
||||
.active=${active}
|
||||
.withHover=${true}
|
||||
@click=${() => this._togglePenMenu()}
|
||||
>
|
||||
<div style=${styleMap({ color })} class="pen-wrapper">${icon}</div>
|
||||
</edgeless-toolbar-button>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export type Pen = 'brush' | 'highlighter';
|
||||
|
||||
export type PenMap<T> = Record<Pen, T>;
|
||||
120
blocksuite/affine/gfx/brush/src/toolbar/configs/brush.ts
Normal file
120
blocksuite/affine/gfx/brush/src/toolbar/configs/brush.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { EdgelessCRUDIdentifier } from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
packColor,
|
||||
type PickColorEvent,
|
||||
} from '@blocksuite/affine-components/color-picker';
|
||||
import {
|
||||
BrushElementModel,
|
||||
DefaultTheme,
|
||||
LineWidth,
|
||||
resolveColor,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
type ToolbarModuleConfig,
|
||||
ToolbarModuleExtension,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
getMostCommonResolvedValue,
|
||||
getMostCommonValue,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import { BlockFlavourIdentifier } from '@blocksuite/std';
|
||||
import { html } from 'lit';
|
||||
|
||||
export const brushToolbarConfig = {
|
||||
actions: [
|
||||
{
|
||||
id: 'a.line-width',
|
||||
content(ctx) {
|
||||
const models = ctx.getSurfaceModelsByType(BrushElementModel);
|
||||
if (!models.length) return null;
|
||||
|
||||
const lineWidth =
|
||||
getMostCommonValue(models, 'lineWidth') ?? LineWidth.Four;
|
||||
const onPick = (e: CustomEvent<LineWidth>) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const lineWidth = e.detail;
|
||||
|
||||
for (const model of models) {
|
||||
ctx.std
|
||||
.get(EdgelessCRUDIdentifier)
|
||||
.updateElement(model.id, { lineWidth });
|
||||
}
|
||||
};
|
||||
|
||||
return html`
|
||||
<edgeless-line-width-panel
|
||||
.selectedSize=${lineWidth}
|
||||
@select=${onPick}
|
||||
>
|
||||
</edgeless-line-width-panel>
|
||||
`;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'b.color-picker',
|
||||
content(ctx) {
|
||||
const models = ctx.getSurfaceModelsByType(BrushElementModel);
|
||||
if (!models.length) return null;
|
||||
|
||||
const enableCustomColor = ctx.features.getFlag('enable_color_picker');
|
||||
const theme = ctx.theme.edgeless$.value;
|
||||
|
||||
const field = 'color';
|
||||
const firstModel = models[0];
|
||||
const originalColor = firstModel[field];
|
||||
const color =
|
||||
getMostCommonResolvedValue(models, field, color =>
|
||||
resolveColor(color, theme)
|
||||
) ?? resolveColor(DefaultTheme.black, theme);
|
||||
const onPick = (e: PickColorEvent) => {
|
||||
switch (e.type) {
|
||||
case 'pick':
|
||||
{
|
||||
const color = e.detail.value;
|
||||
const props = packColor(field, color);
|
||||
const crud = ctx.std.get(EdgelessCRUDIdentifier);
|
||||
models.forEach(model => {
|
||||
crud.updateElement(model.id, props);
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'start':
|
||||
ctx.store.captureSync();
|
||||
models.forEach(model => {
|
||||
model.stash(field);
|
||||
});
|
||||
break;
|
||||
case 'end':
|
||||
ctx.store.transact(() => {
|
||||
models.forEach(model => {
|
||||
model.pop(field);
|
||||
});
|
||||
});
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return html`
|
||||
<edgeless-color-picker-button
|
||||
class="color"
|
||||
.label="${'Color'}"
|
||||
.pick=${onPick}
|
||||
.color=${color}
|
||||
.theme=${theme}
|
||||
.originalColor=${originalColor}
|
||||
.enableCustomColor=${enableCustomColor}
|
||||
>
|
||||
</edgeless-color-picker-button>
|
||||
`;
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
when: ctx => ctx.getSurfaceModelsByType(BrushElementModel).length > 0,
|
||||
} as const satisfies ToolbarModuleConfig;
|
||||
|
||||
export const brushToolbarExtension = ToolbarModuleExtension({
|
||||
id: BlockFlavourIdentifier('affine:surface:brush'),
|
||||
config: brushToolbarConfig,
|
||||
});
|
||||
139
blocksuite/affine/gfx/brush/src/toolbar/configs/highlighter.ts
Normal file
139
blocksuite/affine/gfx/brush/src/toolbar/configs/highlighter.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { EdgelessCRUDIdentifier } from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
adjustColorAlpha,
|
||||
keepColor,
|
||||
packColor,
|
||||
type PickColorEvent,
|
||||
} from '@blocksuite/affine-components/color-picker';
|
||||
import {
|
||||
DEFAULT_HIGHLIGHTER_LINE_WIDTH,
|
||||
DefaultTheme,
|
||||
HIGHLIGHTER_LINE_WIDTHS,
|
||||
HighlighterElementModel,
|
||||
resolveColor,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
type ToolbarModuleConfig,
|
||||
ToolbarModuleExtension,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
getMostCommonResolvedValue,
|
||||
getMostCommonValue,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import { BlockFlavourIdentifier } from '@blocksuite/std';
|
||||
import { html } from 'lit';
|
||||
|
||||
const trackBaseProps = {
|
||||
category: 'highlighter',
|
||||
};
|
||||
|
||||
export const highlighterToolbarConfig = {
|
||||
actions: [
|
||||
{
|
||||
id: 'a.line-width',
|
||||
content(ctx) {
|
||||
const models = ctx.getSurfaceModelsByType(HighlighterElementModel);
|
||||
if (!models.length) return null;
|
||||
|
||||
const lineWidth =
|
||||
getMostCommonValue(models, 'lineWidth') ??
|
||||
DEFAULT_HIGHLIGHTER_LINE_WIDTH;
|
||||
const onPick = (e: CustomEvent<number>) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const lineWidth = e.detail;
|
||||
|
||||
for (const model of models) {
|
||||
ctx.std
|
||||
.get(EdgelessCRUDIdentifier)
|
||||
.updateElement(model.id, { lineWidth });
|
||||
}
|
||||
|
||||
ctx.track('CanvasElementUpdated', {
|
||||
...trackBaseProps,
|
||||
control: 'line width',
|
||||
});
|
||||
};
|
||||
|
||||
return html`
|
||||
<edgeless-line-width-panel
|
||||
.config=${{
|
||||
width: 140,
|
||||
itemSize: 16,
|
||||
itemIconSize: 8,
|
||||
dragHandleSize: 14,
|
||||
count: HIGHLIGHTER_LINE_WIDTHS.length,
|
||||
}}
|
||||
.lineWidths=${HIGHLIGHTER_LINE_WIDTHS}
|
||||
.selectedSize=${lineWidth}
|
||||
@select=${onPick}
|
||||
>
|
||||
</edgeless-line-width-panel>
|
||||
`;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'b.color-picker',
|
||||
content(ctx) {
|
||||
const models = ctx.getSurfaceModelsByType(HighlighterElementModel);
|
||||
if (!models.length) return null;
|
||||
|
||||
const theme = ctx.theme.edgeless$.value;
|
||||
|
||||
const field = 'color';
|
||||
const firstModel = models[0];
|
||||
const originalColor = firstModel[field];
|
||||
const color = keepColor(
|
||||
getMostCommonResolvedValue(models, field, color =>
|
||||
resolveColor(color, theme)
|
||||
) ?? resolveColor(DefaultTheme.black, theme)
|
||||
);
|
||||
const onPick = (e: PickColorEvent) => {
|
||||
if (e.type === 'pick') {
|
||||
const color = adjustColorAlpha(e.detail.value, 0.3);
|
||||
for (const model of models) {
|
||||
const props = packColor(field, color);
|
||||
ctx.std
|
||||
.get(EdgelessCRUDIdentifier)
|
||||
.updateElement(model.id, props);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const isStartEvent = e.type === 'start';
|
||||
for (const model of models) {
|
||||
model[isStartEvent ? 'stash' : 'pop'](field);
|
||||
}
|
||||
|
||||
if (!isStartEvent) return;
|
||||
ctx.track('CanvasElementUpdated', {
|
||||
...trackBaseProps,
|
||||
control: 'color picker',
|
||||
});
|
||||
};
|
||||
|
||||
return html`
|
||||
<edgeless-color-picker-button
|
||||
.colorPanelClass="${'one-way small'}"
|
||||
.label="${'Color'}"
|
||||
.pick=${onPick}
|
||||
.color=${color}
|
||||
.theme=${theme}
|
||||
.originalColor=${originalColor}
|
||||
.palettes=${DefaultTheme.StrokeColorShortPalettes}
|
||||
.shouldKeepColor=${true}
|
||||
.enableCustomColor=${false}
|
||||
>
|
||||
</edgeless-color-picker-button>
|
||||
`;
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
when: ctx => ctx.getSurfaceModelsByType(HighlighterElementModel).length > 0,
|
||||
} as const satisfies ToolbarModuleConfig;
|
||||
|
||||
export const highlighterToolbarExtension = ToolbarModuleExtension({
|
||||
id: BlockFlavourIdentifier('affine:surface:highlighter'),
|
||||
config: highlighterToolbarConfig,
|
||||
});
|
||||
2
blocksuite/affine/gfx/brush/src/toolbar/configs/index.ts
Normal file
2
blocksuite/affine/gfx/brush/src/toolbar/configs/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './brush';
|
||||
export * from './highlighter';
|
||||
15
blocksuite/affine/gfx/brush/src/toolbar/senior-tool.ts
Normal file
15
blocksuite/affine/gfx/brush/src/toolbar/senior-tool.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { SeniorToolExtension } from '@blocksuite/affine-widget-edgeless-toolbar';
|
||||
import { html } from 'lit';
|
||||
|
||||
export const penSeniorTool = SeniorToolExtension('pen', ({ block }) => {
|
||||
return {
|
||||
name: 'Pen',
|
||||
content: html`<div class="pen-and-eraser">
|
||||
<edgeless-pen-tool-button .edgeless=${block}></edgeless-pen-tool-button>
|
||||
|
||||
<edgeless-eraser-tool-button
|
||||
.edgeless=${block}
|
||||
></edgeless-eraser-tool-button>
|
||||
</div> `,
|
||||
};
|
||||
});
|
||||
47
blocksuite/affine/gfx/brush/src/view.ts
Normal file
47
blocksuite/affine/gfx/brush/src/view.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
type ViewExtensionContext,
|
||||
ViewExtensionProvider,
|
||||
} from '@blocksuite/affine-ext-loader';
|
||||
|
||||
import { BrushTool } from './brush-tool';
|
||||
import { effects } from './effects';
|
||||
import { EraserTool } from './eraser-tool';
|
||||
import { HighlighterTool } from './highlighter-tool';
|
||||
import {
|
||||
BrushDomRendererExtension,
|
||||
BrushElementRendererExtension,
|
||||
HighlighterDomRendererExtension,
|
||||
HighlighterElementRendererExtension,
|
||||
} from './renderer';
|
||||
import {
|
||||
brushToolbarExtension,
|
||||
highlighterToolbarExtension,
|
||||
} from './toolbar/configs';
|
||||
import { penSeniorTool } from './toolbar/senior-tool';
|
||||
|
||||
export class BrushViewExtension extends ViewExtensionProvider {
|
||||
override name = 'affine-brush-gfx';
|
||||
|
||||
override effect(): void {
|
||||
super.effect();
|
||||
effects();
|
||||
}
|
||||
|
||||
override setup(context: ViewExtensionContext) {
|
||||
super.setup(context);
|
||||
|
||||
context.register(BrushTool);
|
||||
context.register(EraserTool);
|
||||
context.register(HighlighterTool);
|
||||
|
||||
context.register(BrushElementRendererExtension);
|
||||
context.register(BrushDomRendererExtension);
|
||||
context.register(HighlighterElementRendererExtension);
|
||||
context.register(HighlighterDomRendererExtension);
|
||||
|
||||
context.register(brushToolbarExtension);
|
||||
context.register(highlighterToolbarExtension);
|
||||
|
||||
context.register(penSeniorTool);
|
||||
}
|
||||
}
|
||||
19
blocksuite/affine/gfx/brush/tsconfig.json
Normal file
19
blocksuite/affine/gfx/brush/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"include": ["./src"],
|
||||
"references": [
|
||||
{ "path": "../../blocks/surface" },
|
||||
{ "path": "../../components" },
|
||||
{ "path": "../../ext-loader" },
|
||||
{ "path": "../../model" },
|
||||
{ "path": "../../shared" },
|
||||
{ "path": "../../widgets/edgeless-toolbar" },
|
||||
{ "path": "../../../framework/global" },
|
||||
{ "path": "../../../framework/std" }
|
||||
]
|
||||
}
|
||||
44
blocksuite/affine/gfx/connector/package.json
Normal file
44
blocksuite/affine/gfx/connector/package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "@blocksuite/affine-gfx-connector",
|
||||
"description": "Gfx connector for BlockSuite.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "yarn run -T tsc"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"keywords": [],
|
||||
"author": "toeverything",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@blocksuite/affine-block-surface": "workspace:*",
|
||||
"@blocksuite/affine-components": "workspace:*",
|
||||
"@blocksuite/affine-ext-loader": "workspace:*",
|
||||
"@blocksuite/affine-gfx-text": "workspace:*",
|
||||
"@blocksuite/affine-model": "workspace:*",
|
||||
"@blocksuite/affine-rich-text": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/affine-widget-edgeless-toolbar": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"lit": "^3.2.0",
|
||||
"lodash-es": "^4.17.23",
|
||||
"yjs": "^13.6.27"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./view": "./src/view.ts",
|
||||
"./store": "./src/store.ts"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.27.5"
|
||||
}
|
||||
2
blocksuite/affine/gfx/connector/src/adapter/index.ts
Normal file
2
blocksuite/affine/gfx/connector/src/adapter/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './markdown';
|
||||
export * from './plain-text';
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ElementToMarkdownAdapterExtension } from '@blocksuite/affine-block-surface';
|
||||
|
||||
import { getConnectorText } from '../text';
|
||||
|
||||
export const connectorToMarkdownAdapterMatcher =
|
||||
ElementToMarkdownAdapterExtension({
|
||||
name: 'connector',
|
||||
match: elementModel => elementModel.type === 'connector',
|
||||
toAST: elementModel => {
|
||||
const text = getConnectorText(elementModel);
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = `Connector, with text label "${text}"`;
|
||||
return {
|
||||
type: 'paragraph',
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
value: content,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ElementToPlainTextAdapterExtension } from '@blocksuite/affine-block-surface';
|
||||
|
||||
import { getConnectorText } from '../text';
|
||||
|
||||
export const connectorToPlainTextAdapterMatcher =
|
||||
ElementToPlainTextAdapterExtension({
|
||||
name: 'connector',
|
||||
match: elementModel => elementModel.type === 'connector',
|
||||
toAST: elementModel => {
|
||||
const text = getConnectorText(elementModel);
|
||||
const content = `Connector, with text label "${text}"`;
|
||||
return { content };
|
||||
},
|
||||
});
|
||||
23
blocksuite/affine/gfx/connector/src/adapter/text.ts
Normal file
23
blocksuite/affine/gfx/connector/src/adapter/text.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { DeltaInsert } from '@blocksuite/affine/store';
|
||||
|
||||
export function getConnectorText(
|
||||
elementModel: Record<string, unknown>
|
||||
): string {
|
||||
let text = '';
|
||||
if (elementModel.type !== 'connector') {
|
||||
return text;
|
||||
}
|
||||
|
||||
if (
|
||||
'text' in elementModel &&
|
||||
typeof elementModel.text === 'object' &&
|
||||
elementModel.text
|
||||
) {
|
||||
let delta: DeltaInsert[] = [];
|
||||
if ('delta' in elementModel.text) {
|
||||
delta = elementModel.text.delta as DeltaInsert[];
|
||||
}
|
||||
text = delta.map(d => d.insert).join('');
|
||||
}
|
||||
return text;
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import {
|
||||
EdgelessLegacySlotIdentifier,
|
||||
OverlayIdentifier,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import type { ConnectorElementModel } from '@blocksuite/affine-model';
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import { Vec } from '@blocksuite/global/gfx';
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import {
|
||||
type BlockComponent,
|
||||
type BlockStdScope,
|
||||
stdContext,
|
||||
storeContext,
|
||||
} from '@blocksuite/std';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
|
||||
import type { Store } from '@blocksuite/store';
|
||||
import { consume } from '@lit/context';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import type { ConnectionOverlay } from '../connector-manager';
|
||||
|
||||
const SIZE = 12;
|
||||
const HALF_SIZE = SIZE / 2;
|
||||
|
||||
export class EdgelessConnectorHandle extends WithDisposable(LitElement) {
|
||||
static override styles = css`
|
||||
.line-controller {
|
||||
position: absolute;
|
||||
width: ${SIZE}px;
|
||||
height: ${SIZE}px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--affine-text-emphasis-color);
|
||||
background-color: var(--affine-background-primary-color);
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
pointer-events: all;
|
||||
/**
|
||||
* Fix: pointerEvent stops firing after a short time.
|
||||
* When a gesture is started, the browser intersects the touch-action values of the touched element and its ancestors,
|
||||
* up to the one that implements the gesture (in other words, the first containing scrolling element)
|
||||
* https://developer.mozilla.org/en-US/docs/Web/CSS/touch-action
|
||||
*/
|
||||
touch-action: none;
|
||||
}
|
||||
.line-controller-hidden {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
private _lastZoom = 1;
|
||||
|
||||
get connectionOverlay() {
|
||||
return this.std.get(OverlayIdentifier('connection')) as ConnectionOverlay;
|
||||
}
|
||||
|
||||
get gfx() {
|
||||
return this.std.get(GfxControllerIdentifier);
|
||||
}
|
||||
|
||||
get slots() {
|
||||
return this.std.get(EdgelessLegacySlotIdentifier);
|
||||
}
|
||||
|
||||
private _bindEvent() {
|
||||
const slots = this.slots;
|
||||
|
||||
this._disposables.addFromEvent(this._startHandler, 'pointerdown', e => {
|
||||
slots.elementResizeStart.next();
|
||||
this._capPointerDown(e, 'source');
|
||||
});
|
||||
this._disposables.addFromEvent(this._endHandler, 'pointerdown', e => {
|
||||
slots.elementResizeStart.next();
|
||||
this._capPointerDown(e, 'target');
|
||||
});
|
||||
this._disposables.add(() => {
|
||||
this.connectionOverlay.clear();
|
||||
});
|
||||
}
|
||||
|
||||
private _capPointerDown(e: PointerEvent, connection: 'target' | 'source') {
|
||||
const { gfx, connector, slots, _disposables } = this;
|
||||
e.stopPropagation();
|
||||
_disposables.addFromEvent(document, 'pointermove', e => {
|
||||
const point = gfx.viewport.toModelCoordFromClientCoord([e.x, e.y]);
|
||||
const isStartPointer = connection === 'source';
|
||||
const otherSideId = connector[isStartPointer ? 'target' : 'source'].id;
|
||||
|
||||
connector[connection] = this.connectionOverlay.renderConnector(
|
||||
point,
|
||||
otherSideId ? [otherSideId] : []
|
||||
);
|
||||
this.requestUpdate();
|
||||
});
|
||||
|
||||
_disposables.addFromEvent(document, 'pointerup', () => {
|
||||
this.doc.captureSync();
|
||||
_disposables.dispose();
|
||||
this._disposables = new DisposableGroup();
|
||||
this._bindEvent();
|
||||
slots.elementResizeEnd.next();
|
||||
});
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
const { gfx } = this;
|
||||
const { viewport } = gfx;
|
||||
|
||||
this._lastZoom = viewport.zoom;
|
||||
viewport.viewportUpdated.subscribe(() => {
|
||||
if (viewport.zoom !== this._lastZoom) {
|
||||
this._lastZoom = viewport.zoom;
|
||||
this.requestUpdate();
|
||||
}
|
||||
});
|
||||
|
||||
this._bindEvent();
|
||||
}
|
||||
|
||||
override render() {
|
||||
const { gfx } = this;
|
||||
// path is relative to the element's xywh
|
||||
const { path } = this.connector;
|
||||
const zoom = gfx.viewport.zoom;
|
||||
const startPoint = Vec.subScalar(Vec.mul(path[0], zoom), HALF_SIZE);
|
||||
const endPoint = Vec.subScalar(
|
||||
Vec.mul(path[path.length - 1], zoom),
|
||||
HALF_SIZE
|
||||
);
|
||||
const startStyle = {
|
||||
transform: `translate3d(${startPoint[0]}px,${startPoint[1]}px,0)`,
|
||||
};
|
||||
const endStyle = {
|
||||
transform: `translate3d(${endPoint[0]}px,${endPoint[1]}px,0)`,
|
||||
};
|
||||
return html`
|
||||
<div
|
||||
class="line-controller line-start"
|
||||
style=${styleMap(startStyle)}
|
||||
></div>
|
||||
<div class="line-controller line-end" style=${styleMap(endStyle)}></div>
|
||||
`;
|
||||
}
|
||||
|
||||
@query('.line-end')
|
||||
private accessor _endHandler!: HTMLDivElement;
|
||||
|
||||
@query('.line-start')
|
||||
private accessor _startHandler!: HTMLDivElement;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor connector!: ConnectorElementModel;
|
||||
|
||||
@consume({
|
||||
context: storeContext,
|
||||
})
|
||||
accessor doc!: Store;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor edgeless!: BlockComponent;
|
||||
|
||||
@consume({
|
||||
context: stdContext,
|
||||
})
|
||||
accessor std!: BlockStdScope;
|
||||
}
|
||||
1454
blocksuite/affine/gfx/connector/src/connector-manager.ts
Normal file
1454
blocksuite/affine/gfx/connector/src/connector-manager.ts
Normal file
File diff suppressed because it is too large
Load Diff
237
blocksuite/affine/gfx/connector/src/connector-tool.ts
Normal file
237
blocksuite/affine/gfx/connector/src/connector-tool.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import {
|
||||
CanvasElementType,
|
||||
DefaultTool,
|
||||
OverlayIdentifier,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
type Connection,
|
||||
type ConnectorElementModel,
|
||||
ConnectorMode,
|
||||
GroupElementModel,
|
||||
ShapeElementModel,
|
||||
ShapeType,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { TelemetryProvider } from '@blocksuite/affine-shared/services';
|
||||
import type { IBound, IVec } from '@blocksuite/global/gfx';
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
import type { PointerEventState } from '@blocksuite/std';
|
||||
import { BaseTool, type GfxModel } from '@blocksuite/std/gfx';
|
||||
|
||||
import {
|
||||
calculateNearestLocation,
|
||||
type ConnectionOverlay,
|
||||
ConnectorEndpointLocations,
|
||||
ConnectorEndpointLocationsOnTriangle,
|
||||
} from './connector-manager';
|
||||
|
||||
enum ConnectorToolMode {
|
||||
// Dragging connect
|
||||
Dragging,
|
||||
// Quick connect
|
||||
Quick,
|
||||
}
|
||||
|
||||
export type ConnectorToolOptions = {
|
||||
mode: ConnectorMode;
|
||||
};
|
||||
|
||||
export class ConnectorTool extends BaseTool<ConnectorToolOptions> {
|
||||
static override toolName: string = 'connector';
|
||||
|
||||
// Likes pressing `ESC`
|
||||
private _allowCancel = false;
|
||||
|
||||
private _connector: ConnectorElementModel | null = null;
|
||||
|
||||
private _mode: ConnectorToolMode = ConnectorToolMode.Dragging;
|
||||
|
||||
private _source: Connection | null = null;
|
||||
|
||||
private _sourceBounds: IBound | null = null;
|
||||
|
||||
private _sourceLocations: IVec[] = ConnectorEndpointLocations;
|
||||
|
||||
private _startPoint: IVec | null = null;
|
||||
|
||||
private get _overlay() {
|
||||
return this.std.get(OverlayIdentifier('connection')) as ConnectionOverlay;
|
||||
}
|
||||
|
||||
private _createConnector() {
|
||||
if (!(this._source && this._startPoint) || !this.gfx.surface) {
|
||||
this._source = null;
|
||||
this._startPoint = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this.doc.captureSync();
|
||||
const id = this.gfx.surface.addElement({
|
||||
type: CanvasElementType.CONNECTOR,
|
||||
mode: this.activatedOption.mode,
|
||||
controllers: [],
|
||||
source: this._source,
|
||||
target: { position: this._startPoint },
|
||||
});
|
||||
|
||||
this.gfx.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', {
|
||||
control: 'canvas:draw',
|
||||
page: 'whiteboard editor',
|
||||
module: 'toolbar',
|
||||
segment: 'toolbar',
|
||||
type: CanvasElementType.CONNECTOR,
|
||||
});
|
||||
|
||||
const connector = this.gfx.getElementById(id);
|
||||
if (!connector) {
|
||||
this._source = null;
|
||||
this._startPoint = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this._connector = connector as ConnectorElementModel;
|
||||
}
|
||||
|
||||
override click() {
|
||||
if (this._mode === ConnectorToolMode.Dragging) return;
|
||||
if (!this._connector) return;
|
||||
|
||||
const { id, source, target } = this._connector;
|
||||
let focusedId = id;
|
||||
|
||||
if (source?.id && !target?.id) {
|
||||
focusedId = source.id;
|
||||
this._allowCancel = true;
|
||||
}
|
||||
|
||||
this.gfx.tool.setTool(DefaultTool);
|
||||
this.gfx.selection.set({ elements: [focusedId] });
|
||||
}
|
||||
|
||||
override deactivate() {
|
||||
const id = this._connector?.id;
|
||||
|
||||
if (this._allowCancel && id) {
|
||||
this.gfx.surface?.deleteElement(id);
|
||||
}
|
||||
|
||||
this._overlay?.clear();
|
||||
this._mode = ConnectorToolMode.Dragging;
|
||||
this._connector = null;
|
||||
this._source = null;
|
||||
this._sourceBounds = null;
|
||||
this._startPoint = null;
|
||||
this._allowCancel = false;
|
||||
}
|
||||
|
||||
override dragEnd() {
|
||||
if (this._mode === ConnectorToolMode.Quick) return;
|
||||
if (!this._connector) return;
|
||||
|
||||
const connector = this._connector;
|
||||
|
||||
this.doc.captureSync();
|
||||
|
||||
this.gfx.tool.setTool(DefaultTool);
|
||||
this.gfx.selection.set({ elements: [connector.id] });
|
||||
}
|
||||
|
||||
override dragMove(e: PointerEventState) {
|
||||
this.findTargetByPoint([e.x, e.y]);
|
||||
}
|
||||
|
||||
override dragStart() {
|
||||
if (this._mode === ConnectorToolMode.Quick) return;
|
||||
|
||||
this._createConnector();
|
||||
}
|
||||
|
||||
findTargetByPoint(point: IVec) {
|
||||
if (!this._connector || !this.gfx.surface) return;
|
||||
|
||||
const { _connector } = this;
|
||||
|
||||
point = this.gfx.viewport.toModelCoord(point[0], point[1]);
|
||||
|
||||
const excludedIds = [];
|
||||
if (_connector.source?.id) {
|
||||
excludedIds.push(_connector.source.id);
|
||||
}
|
||||
|
||||
const target = this._overlay?.renderConnector(point, excludedIds);
|
||||
this.gfx.updateElement(_connector, { target });
|
||||
}
|
||||
|
||||
override pointerDown(e: PointerEventState) {
|
||||
this._startPoint = this.gfx.viewport.toModelCoord(e.x, e.y);
|
||||
this._source = this._overlay?.renderConnector(this._startPoint) ?? null;
|
||||
}
|
||||
|
||||
override pointerMove(e: PointerEventState) {
|
||||
if (this._mode === ConnectorToolMode.Dragging) return;
|
||||
if (!this._sourceBounds) return;
|
||||
if (!this._connector) return;
|
||||
const sourceId = this._connector.source?.id;
|
||||
if (!sourceId) return;
|
||||
|
||||
const point = this.gfx.viewport.toModelCoord(e.x, e.y);
|
||||
const target = this._overlay!.renderConnector(point, [sourceId]);
|
||||
|
||||
this._allowCancel = !target.id;
|
||||
this._connector.source.position = calculateNearestLocation(
|
||||
point,
|
||||
this._sourceBounds,
|
||||
this._sourceLocations
|
||||
);
|
||||
this.gfx.updateElement(this._connector, {
|
||||
target,
|
||||
source: this._connector.source,
|
||||
});
|
||||
}
|
||||
|
||||
override pointerUp(_: PointerEventState): void {
|
||||
this._overlay?.clear();
|
||||
}
|
||||
|
||||
quickConnect(point: IVec, element: GfxModel) {
|
||||
this._startPoint = this.gfx.viewport.toModelCoord(point[0], point[1]);
|
||||
this._mode = ConnectorToolMode.Quick;
|
||||
this._sourceBounds = Bound.deserialize(element.xywh);
|
||||
this._sourceBounds.rotate = element.rotate;
|
||||
this._sourceLocations =
|
||||
element instanceof ShapeElementModel &&
|
||||
element.shapeType === ShapeType.Triangle
|
||||
? ConnectorEndpointLocationsOnTriangle
|
||||
: ConnectorEndpointLocations;
|
||||
|
||||
this._source = {
|
||||
id: element.id,
|
||||
position: calculateNearestLocation(
|
||||
this._startPoint,
|
||||
this._sourceBounds,
|
||||
this._sourceLocations
|
||||
),
|
||||
};
|
||||
this._allowCancel = true;
|
||||
|
||||
this._createConnector();
|
||||
|
||||
if (element instanceof GroupElementModel && this._overlay) {
|
||||
this._overlay.sourceBounds = this._sourceBounds;
|
||||
}
|
||||
|
||||
this.findTargetByPoint(point);
|
||||
}
|
||||
|
||||
getNextMode() {
|
||||
// reorder the enum values
|
||||
const modes = [
|
||||
ConnectorMode.Curve,
|
||||
ConnectorMode.Orthogonal,
|
||||
ConnectorMode.Straight,
|
||||
];
|
||||
|
||||
const currentIndex = modes.indexOf(this.activatedOption.mode);
|
||||
const nextIndex = (currentIndex + 1) % modes.length;
|
||||
return modes[nextIndex];
|
||||
}
|
||||
}
|
||||
96
blocksuite/affine/gfx/connector/src/connector-watcher.ts
Normal file
96
blocksuite/affine/gfx/connector/src/connector-watcher.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
type SurfaceBlockModel,
|
||||
type SurfaceMiddleware,
|
||||
surfaceMiddlewareExtension,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import type { ConnectorElementModel } from '@blocksuite/affine-model';
|
||||
import type { GfxModel } from '@blocksuite/std/gfx';
|
||||
|
||||
import { ConnectorPathGenerator } from './connector-manager';
|
||||
|
||||
export const connectorWatcher: SurfaceMiddleware = (
|
||||
surface: SurfaceBlockModel
|
||||
) => {
|
||||
const hasElementById = (id: string) =>
|
||||
surface.hasElementById(id) || surface.store.hasBlock(id);
|
||||
const elementGetter = (id: string) =>
|
||||
surface.getElementById(id) ?? (surface.store.getModelById(id) as GfxModel);
|
||||
const updateConnectorPath = (connector: ConnectorElementModel) => {
|
||||
if (
|
||||
((connector.source?.id && hasElementById(connector.source.id)) ||
|
||||
(!connector.source?.id && connector.source?.position)) &&
|
||||
((connector.target?.id && hasElementById(connector.target.id)) ||
|
||||
(!connector.target?.id && connector.target?.position))
|
||||
) {
|
||||
ConnectorPathGenerator.updatePath(connector, null, elementGetter);
|
||||
}
|
||||
};
|
||||
const pendingList = new Set<ConnectorElementModel>();
|
||||
let pendingFlag = false;
|
||||
const addToUpdateList = (connector: ConnectorElementModel) => {
|
||||
pendingList.add(connector);
|
||||
|
||||
if (!pendingFlag) {
|
||||
pendingFlag = true;
|
||||
queueMicrotask(() => {
|
||||
pendingList.forEach(updateConnectorPath);
|
||||
pendingList.clear();
|
||||
pendingFlag = false;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const disposables = [
|
||||
surface.elementAdded.subscribe(({ id }) => {
|
||||
const element = elementGetter(id);
|
||||
|
||||
if (!element) return;
|
||||
|
||||
if ('type' in element && element.type === 'connector') {
|
||||
addToUpdateList(element as ConnectorElementModel);
|
||||
} else {
|
||||
surface.getConnectors(id).forEach(addToUpdateList);
|
||||
}
|
||||
}),
|
||||
surface.elementUpdated.subscribe(({ id, props }) => {
|
||||
const element = elementGetter(id);
|
||||
|
||||
if (props['xywh'] || props['rotate']) {
|
||||
surface.getConnectors(id).forEach(addToUpdateList);
|
||||
}
|
||||
|
||||
if (
|
||||
'type' in element &&
|
||||
element.type === 'connector' &&
|
||||
(props['mode'] !== undefined || props['target'] || props['source'])
|
||||
) {
|
||||
addToUpdateList(element as ConnectorElementModel);
|
||||
}
|
||||
}),
|
||||
surface.store.slots.blockUpdated.subscribe(payload => {
|
||||
if (
|
||||
payload.type === 'add' ||
|
||||
(payload.type === 'update' && payload.props.key === 'xywh')
|
||||
) {
|
||||
surface.getConnectors(payload.id).forEach(addToUpdateList);
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
surface
|
||||
.getElementsByType('connector')
|
||||
.forEach(connector =>
|
||||
updateConnectorPath(connector as ConnectorElementModel)
|
||||
);
|
||||
|
||||
return () => {
|
||||
pendingFlag = false;
|
||||
pendingList.clear();
|
||||
disposables.forEach(d => d.unsubscribe());
|
||||
};
|
||||
};
|
||||
|
||||
export const connectorWatcherExtension = surfaceMiddlewareExtension(
|
||||
'connector-watcher',
|
||||
connectorWatcher
|
||||
);
|
||||
26
blocksuite/affine/gfx/connector/src/effects.ts
Normal file
26
blocksuite/affine/gfx/connector/src/effects.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { EdgelessConnectorHandle } from './components/connector-handle';
|
||||
import { EdgelessConnectorLabelEditor } from './text/edgeless-connector-label-editor';
|
||||
import { EdgelessConnectorMenu } from './toolbar/connector-menu';
|
||||
import { EdgelessConnectorToolButton } from './toolbar/connector-tool-button';
|
||||
|
||||
export function effects() {
|
||||
customElements.define(
|
||||
'edgeless-connector-tool-button',
|
||||
EdgelessConnectorToolButton
|
||||
);
|
||||
customElements.define('edgeless-connector-menu', EdgelessConnectorMenu);
|
||||
customElements.define(
|
||||
'edgeless-connector-label-editor',
|
||||
EdgelessConnectorLabelEditor
|
||||
);
|
||||
customElements.define('edgeless-connector-handle', EdgelessConnectorHandle);
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-connector-tool-button': EdgelessConnectorToolButton;
|
||||
'edgeless-connector-menu': EdgelessConnectorMenu;
|
||||
'edgeless-connector-label-editor': EdgelessConnectorLabelEditor;
|
||||
'edgeless-connector-handle': EdgelessConnectorHandle;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { ConnectorElementModel } from '@blocksuite/affine-model';
|
||||
import {
|
||||
type DragExtensionInitializeContext,
|
||||
InteractivityExtension,
|
||||
} from '@blocksuite/std/gfx';
|
||||
|
||||
export class ConnectorFilter extends InteractivityExtension {
|
||||
static override key = 'connector-filter';
|
||||
|
||||
override mounted() {
|
||||
this.action.onDragInitialize((context: DragExtensionInitializeContext) => {
|
||||
let hasConnectorFlag = false;
|
||||
|
||||
const elementSet = new Set(context.elements.map(elem => elem.id));
|
||||
const elements = context.elements.filter(elem => {
|
||||
if (elem instanceof ConnectorElementModel) {
|
||||
const sourceElemNotFound =
|
||||
elem.source.id && !elementSet.has(elem.source.id);
|
||||
const targetElemNotFound =
|
||||
elem.target.id && !elementSet.has(elem.target.id);
|
||||
|
||||
// If either source or target element is not found, then remove the connector
|
||||
if (sourceElemNotFound || targetElemNotFound) {
|
||||
return false;
|
||||
}
|
||||
|
||||
hasConnectorFlag = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (hasConnectorFlag) {
|
||||
// connector needs to be updated first
|
||||
elements.sort((a, _) => (a instanceof ConnectorElementModel ? -1 : 1));
|
||||
}
|
||||
|
||||
context.elements = elements;
|
||||
|
||||
return {};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './connector-filter';
|
||||
9
blocksuite/affine/gfx/connector/src/index.ts
Normal file
9
blocksuite/affine/gfx/connector/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export * from './adapter';
|
||||
export * from './connector-manager';
|
||||
export * from './connector-tool';
|
||||
export * from './element-transform';
|
||||
export * from './renderer';
|
||||
export * from './text';
|
||||
export * from './toolbar/config';
|
||||
export * from './toolbar/quick-tool';
|
||||
export * from './view/index';
|
||||
416
blocksuite/affine/gfx/connector/src/renderer/dom-renderer.ts
Normal file
416
blocksuite/affine/gfx/connector/src/renderer/dom-renderer.ts
Normal file
@@ -0,0 +1,416 @@
|
||||
import {
|
||||
DomElementRendererExtension,
|
||||
type DomRenderer,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
type ConnectorElementModel,
|
||||
ConnectorMode,
|
||||
DefaultTheme,
|
||||
type LocalConnectorElementModel,
|
||||
type PointStyle,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { PointLocation, SVGPathBuilder } from '@blocksuite/global/gfx';
|
||||
|
||||
import { isConnectorWithLabel } from '../connector-manager';
|
||||
import { DEFAULT_ARROW_SIZE } from './utils';
|
||||
|
||||
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||
|
||||
interface PathBounds {
|
||||
minX: number;
|
||||
minY: number;
|
||||
maxX: number;
|
||||
maxY: number;
|
||||
}
|
||||
|
||||
type RetainedConnectorDom = {
|
||||
defs: SVGDefsElement;
|
||||
label: HTMLDivElement | null;
|
||||
path: SVGPathElement;
|
||||
svg: SVGSVGElement;
|
||||
};
|
||||
|
||||
const retainedConnectorDom = new WeakMap<HTMLElement, RetainedConnectorDom>();
|
||||
|
||||
function calculatePathBounds(path: PointLocation[]): PathBounds {
|
||||
if (path.length === 0) {
|
||||
return { minX: 0, minY: 0, maxX: 0, maxY: 0 };
|
||||
}
|
||||
|
||||
let minX = path[0][0];
|
||||
let minY = path[0][1];
|
||||
let maxX = path[0][0];
|
||||
let maxY = path[0][1];
|
||||
|
||||
for (const point of path) {
|
||||
minX = Math.min(minX, point[0]);
|
||||
minY = Math.min(minY, point[1]);
|
||||
maxX = Math.max(maxX, point[0]);
|
||||
maxY = Math.max(maxY, point[1]);
|
||||
}
|
||||
|
||||
return { minX, minY, maxX, maxY };
|
||||
}
|
||||
|
||||
function createConnectorPath(
|
||||
points: PointLocation[],
|
||||
mode: ConnectorMode
|
||||
): string {
|
||||
if (points.length < 2) return '';
|
||||
|
||||
const pathBuilder = new SVGPathBuilder();
|
||||
pathBuilder.moveTo(points[0][0], points[0][1]);
|
||||
|
||||
if (mode === ConnectorMode.Curve) {
|
||||
// Use bezier curves
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const prev = points[i - 1];
|
||||
const curr = points[i];
|
||||
pathBuilder.curveTo(
|
||||
prev.absOut[0],
|
||||
prev.absOut[1],
|
||||
curr.absIn[0],
|
||||
curr.absIn[1],
|
||||
curr[0],
|
||||
curr[1]
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Use straight lines
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
pathBuilder.lineTo(points[i][0], points[i][1]);
|
||||
}
|
||||
}
|
||||
|
||||
return pathBuilder.build();
|
||||
}
|
||||
|
||||
function createArrowMarker(
|
||||
id: string,
|
||||
style: PointStyle,
|
||||
color: string,
|
||||
strokeWidth: number,
|
||||
isStart: boolean = false
|
||||
): SVGMarkerElement {
|
||||
const marker = document.createElementNS(SVG_NS, 'marker');
|
||||
const size = DEFAULT_ARROW_SIZE * (strokeWidth / 2);
|
||||
|
||||
marker.id = id;
|
||||
marker.setAttribute('viewBox', '0 0 20 20');
|
||||
marker.setAttribute('refX', isStart ? '20' : '0');
|
||||
marker.setAttribute('refY', '10');
|
||||
marker.setAttribute('markerWidth', String(size));
|
||||
marker.setAttribute('markerHeight', String(size));
|
||||
marker.setAttribute('orient', 'auto');
|
||||
marker.setAttribute('markerUnits', 'strokeWidth');
|
||||
|
||||
switch (style) {
|
||||
case 'Arrow': {
|
||||
const path = document.createElementNS(SVG_NS, 'path');
|
||||
path.setAttribute(
|
||||
'd',
|
||||
isStart ? 'M 20 5 L 10 10 L 20 15 Z' : 'M 0 5 L 10 10 L 0 15 Z'
|
||||
);
|
||||
path.setAttribute('fill', color);
|
||||
path.setAttribute('stroke', color);
|
||||
marker.append(path);
|
||||
break;
|
||||
}
|
||||
case 'Triangle': {
|
||||
const path = document.createElementNS(SVG_NS, 'path');
|
||||
path.setAttribute(
|
||||
'd',
|
||||
isStart ? 'M 20 7 L 12 10 L 20 13 Z' : 'M 0 7 L 8 10 L 0 13 Z'
|
||||
);
|
||||
path.setAttribute('fill', color);
|
||||
path.setAttribute('stroke', color);
|
||||
marker.append(path);
|
||||
break;
|
||||
}
|
||||
case 'Circle': {
|
||||
const circle = document.createElementNS(SVG_NS, 'circle');
|
||||
circle.setAttribute('cx', '10');
|
||||
circle.setAttribute('cy', '10');
|
||||
circle.setAttribute('r', '4');
|
||||
circle.setAttribute('fill', color);
|
||||
circle.setAttribute('stroke', color);
|
||||
marker.append(circle);
|
||||
break;
|
||||
}
|
||||
case 'Diamond': {
|
||||
const path = document.createElementNS(SVG_NS, 'path');
|
||||
path.setAttribute('d', 'M 10 6 L 14 10 L 10 14 L 6 10 Z');
|
||||
path.setAttribute('fill', color);
|
||||
path.setAttribute('stroke', color);
|
||||
marker.append(path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return marker;
|
||||
}
|
||||
|
||||
function clearRetainedConnectorDom(element: HTMLElement) {
|
||||
retainedConnectorDom.delete(element);
|
||||
element.replaceChildren();
|
||||
}
|
||||
|
||||
function getRetainedConnectorDom(element: HTMLElement): RetainedConnectorDom {
|
||||
const existing = retainedConnectorDom.get(element);
|
||||
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const svg = document.createElementNS(SVG_NS, 'svg');
|
||||
svg.style.position = 'absolute';
|
||||
svg.style.overflow = 'visible';
|
||||
svg.style.pointerEvents = 'none';
|
||||
|
||||
const defs = document.createElementNS(SVG_NS, 'defs');
|
||||
const path = document.createElementNS(SVG_NS, 'path');
|
||||
path.setAttribute('fill', 'none');
|
||||
path.setAttribute('stroke-linecap', 'round');
|
||||
path.setAttribute('stroke-linejoin', 'round');
|
||||
|
||||
svg.append(defs, path);
|
||||
element.replaceChildren(svg);
|
||||
|
||||
const retained = {
|
||||
svg,
|
||||
defs,
|
||||
path,
|
||||
label: null,
|
||||
};
|
||||
retainedConnectorDom.set(element, retained);
|
||||
|
||||
return retained;
|
||||
}
|
||||
|
||||
function getOrCreateLabelElement(retained: RetainedConnectorDom) {
|
||||
if (retained.label) {
|
||||
return retained.label;
|
||||
}
|
||||
|
||||
const label = document.createElement('div');
|
||||
retained.svg.insertAdjacentElement('afterend', label);
|
||||
retained.label = label;
|
||||
|
||||
return label;
|
||||
}
|
||||
|
||||
function renderConnectorLabel(
|
||||
model: ConnectorElementModel,
|
||||
retained: RetainedConnectorDom,
|
||||
renderer: DomRenderer,
|
||||
zoom: number
|
||||
) {
|
||||
if (!isConnectorWithLabel(model) || !model.labelXYWH) {
|
||||
retained.label?.remove();
|
||||
retained.label = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const [lx, ly, lw, lh] = model.labelXYWH;
|
||||
const {
|
||||
labelStyle: {
|
||||
color,
|
||||
fontSize,
|
||||
fontWeight,
|
||||
fontStyle,
|
||||
fontFamily,
|
||||
textAlign,
|
||||
},
|
||||
} = model;
|
||||
|
||||
const labelElement = getOrCreateLabelElement(retained);
|
||||
labelElement.style.position = 'absolute';
|
||||
labelElement.style.left = `${lx * zoom}px`;
|
||||
labelElement.style.top = `${ly * zoom}px`;
|
||||
labelElement.style.width = `${lw * zoom}px`;
|
||||
labelElement.style.height = `${lh * zoom}px`;
|
||||
labelElement.style.pointerEvents = 'none';
|
||||
labelElement.style.overflow = 'hidden';
|
||||
labelElement.style.display = 'flex';
|
||||
labelElement.style.alignItems = 'center';
|
||||
labelElement.style.justifyContent =
|
||||
textAlign === 'center'
|
||||
? 'center'
|
||||
: textAlign === 'right'
|
||||
? 'flex-end'
|
||||
: 'flex-start';
|
||||
|
||||
// Style the text
|
||||
labelElement.style.color = renderer.getColorValue(
|
||||
color,
|
||||
DefaultTheme.black,
|
||||
true
|
||||
);
|
||||
labelElement.style.fontSize = `${fontSize * zoom}px`;
|
||||
labelElement.style.fontWeight = fontWeight;
|
||||
labelElement.style.fontStyle = fontStyle;
|
||||
labelElement.style.fontFamily = fontFamily;
|
||||
labelElement.style.textAlign = textAlign;
|
||||
labelElement.style.lineHeight = '1.2';
|
||||
labelElement.style.whiteSpace = 'pre-wrap';
|
||||
labelElement.style.wordWrap = 'break-word';
|
||||
|
||||
// Add text content
|
||||
labelElement.textContent = model.text ? model.text.toString() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a ConnectorElementModel to a given HTMLElement using DOM/SVG.
|
||||
* This function is intended to be registered via the DomElementRendererExtension.
|
||||
*
|
||||
* @param model - The connector element model containing rendering properties.
|
||||
* @param element - The HTMLElement to apply the connector's styles to.
|
||||
* @param renderer - The main DOMRenderer instance, providing access to viewport and color utilities.
|
||||
*/
|
||||
export const connectorBaseDomRenderer = (
|
||||
model: ConnectorElementModel | LocalConnectorElementModel,
|
||||
element: HTMLElement,
|
||||
renderer: DomRenderer
|
||||
): void => {
|
||||
const { zoom } = renderer.viewport;
|
||||
const {
|
||||
mode,
|
||||
path: points,
|
||||
strokeStyle,
|
||||
frontEndpointStyle,
|
||||
rearEndpointStyle,
|
||||
strokeWidth,
|
||||
stroke,
|
||||
} = model;
|
||||
|
||||
if (!points || points.length < 2) {
|
||||
clearRetainedConnectorDom(element);
|
||||
return;
|
||||
}
|
||||
|
||||
const retained = getRetainedConnectorDom(element);
|
||||
|
||||
// Calculate bounds for the SVG viewBox
|
||||
const pathBounds = calculatePathBounds(points);
|
||||
const padding = Math.max(strokeWidth * 2, 20); // Add padding for arrows
|
||||
const svgWidth = (pathBounds.maxX - pathBounds.minX + padding * 2) * zoom;
|
||||
const svgHeight = (pathBounds.maxY - pathBounds.minY + padding * 2) * zoom;
|
||||
const offsetX = pathBounds.minX - padding;
|
||||
const offsetY = pathBounds.minY - padding;
|
||||
|
||||
const { defs, path, svg } = retained;
|
||||
svg.style.position = 'absolute';
|
||||
svg.style.left = `${offsetX * zoom}px`;
|
||||
svg.style.top = `${offsetY * zoom}px`;
|
||||
svg.style.width = `${svgWidth}px`;
|
||||
svg.style.height = `${svgHeight}px`;
|
||||
svg.style.overflow = 'visible';
|
||||
svg.style.pointerEvents = 'none';
|
||||
svg.setAttribute('viewBox', `0 0 ${svgWidth / zoom} ${svgHeight / zoom}`);
|
||||
|
||||
const strokeColor = renderer.getColorValue(
|
||||
stroke,
|
||||
DefaultTheme.connectorColor,
|
||||
true
|
||||
);
|
||||
|
||||
const markers: SVGMarkerElement[] = [];
|
||||
let startMarkerId = '';
|
||||
let endMarkerId = '';
|
||||
|
||||
if (frontEndpointStyle !== 'None') {
|
||||
startMarkerId = `start-marker-${model.id}`;
|
||||
markers.push(
|
||||
createArrowMarker(
|
||||
startMarkerId,
|
||||
frontEndpointStyle,
|
||||
strokeColor,
|
||||
strokeWidth,
|
||||
true
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (rearEndpointStyle !== 'None') {
|
||||
endMarkerId = `end-marker-${model.id}`;
|
||||
markers.push(
|
||||
createArrowMarker(
|
||||
endMarkerId,
|
||||
rearEndpointStyle,
|
||||
strokeColor,
|
||||
strokeWidth,
|
||||
false
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
defs.replaceChildren(...markers);
|
||||
|
||||
// Adjust points relative to the SVG coordinate system
|
||||
const adjustedPoints = points.map(point => {
|
||||
const adjustedPoint = new PointLocation([
|
||||
point[0] - offsetX,
|
||||
point[1] - offsetY,
|
||||
]);
|
||||
if (point.absIn) {
|
||||
adjustedPoint.in = [
|
||||
point.absIn[0] - offsetX - adjustedPoint[0],
|
||||
point.absIn[1] - offsetY - adjustedPoint[1],
|
||||
];
|
||||
}
|
||||
if (point.absOut) {
|
||||
adjustedPoint.out = [
|
||||
point.absOut[0] - offsetX - adjustedPoint[0],
|
||||
point.absOut[1] - offsetY - adjustedPoint[1],
|
||||
];
|
||||
}
|
||||
return adjustedPoint;
|
||||
});
|
||||
|
||||
const pathData = createConnectorPath(adjustedPoints, mode);
|
||||
path.setAttribute('d', pathData);
|
||||
path.setAttribute('stroke', strokeColor);
|
||||
path.setAttribute('stroke-width', String(strokeWidth));
|
||||
if (strokeStyle === 'dash') {
|
||||
path.setAttribute('stroke-dasharray', '12,12');
|
||||
} else {
|
||||
path.removeAttribute('stroke-dasharray');
|
||||
}
|
||||
if (startMarkerId) {
|
||||
path.setAttribute('marker-start', `url(#${startMarkerId})`);
|
||||
} else {
|
||||
path.removeAttribute('marker-start');
|
||||
}
|
||||
if (endMarkerId) {
|
||||
path.setAttribute('marker-end', `url(#${endMarkerId})`);
|
||||
} else {
|
||||
path.removeAttribute('marker-end');
|
||||
}
|
||||
|
||||
// Set element size and position
|
||||
element.style.width = `${model.w * zoom}px`;
|
||||
element.style.height = `${model.h * zoom}px`;
|
||||
element.style.overflow = 'visible';
|
||||
element.style.pointerEvents = 'none';
|
||||
};
|
||||
|
||||
export const connectorDomRenderer = (
|
||||
model: ConnectorElementModel,
|
||||
element: HTMLElement,
|
||||
renderer: DomRenderer
|
||||
): void => {
|
||||
connectorBaseDomRenderer(model, element, renderer);
|
||||
|
||||
const retained = retainedConnectorDom.get(element);
|
||||
if (!retained) return;
|
||||
|
||||
renderConnectorLabel(model, retained, renderer, renderer.viewport.zoom);
|
||||
};
|
||||
|
||||
/**
|
||||
* Extension to register the DOM-based renderer for 'connector' elements.
|
||||
*/
|
||||
export const ConnectorDomRendererExtension = DomElementRendererExtension(
|
||||
'connector',
|
||||
connectorDomRenderer
|
||||
);
|
||||
316
blocksuite/affine/gfx/connector/src/renderer/element-renderer.ts
Normal file
316
blocksuite/affine/gfx/connector/src/renderer/element-renderer.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
import {
|
||||
type CanvasRenderer,
|
||||
type ElementRenderer,
|
||||
ElementRendererExtension,
|
||||
type RoughCanvas,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
getFontString,
|
||||
getLineHeight,
|
||||
getTextWidth,
|
||||
isRTL,
|
||||
type TextDelta,
|
||||
wrapTextDeltas,
|
||||
} from '@blocksuite/affine-gfx-text';
|
||||
import {
|
||||
type ConnectorElementModel,
|
||||
ConnectorMode,
|
||||
DefaultTheme,
|
||||
type LocalConnectorElementModel,
|
||||
type PointStyle,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { getAffinePlaceholderFillColor } from '@blocksuite/affine-shared/theme';
|
||||
import {
|
||||
getBezierParameters,
|
||||
type PointLocation,
|
||||
} from '@blocksuite/global/gfx';
|
||||
import { deltaInsertsToChunks } from '@blocksuite/std/inline';
|
||||
|
||||
import { isConnectorWithLabel } from '../connector-manager';
|
||||
import {
|
||||
DEFAULT_ARROW_SIZE,
|
||||
getArrowOptions,
|
||||
renderArrow,
|
||||
renderCircle,
|
||||
renderDiamond,
|
||||
renderTriangle,
|
||||
} from './utils';
|
||||
|
||||
export const connector: ElementRenderer<
|
||||
ConnectorElementModel | LocalConnectorElementModel
|
||||
> = (model, ctx, matrix, renderer, rc) => {
|
||||
const {
|
||||
mode,
|
||||
path: points,
|
||||
strokeStyle,
|
||||
frontEndpointStyle,
|
||||
rearEndpointStyle,
|
||||
strokeWidth,
|
||||
} = model;
|
||||
|
||||
// points might not be build yet in some senarios
|
||||
// eg. undo/redo, copy/paste
|
||||
if (!points.length || points.length < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.setTransform(matrix);
|
||||
|
||||
const hasLabel = isConnectorWithLabel(model);
|
||||
let dx = 0;
|
||||
let dy = 0;
|
||||
|
||||
if (hasLabel) {
|
||||
ctx.save();
|
||||
|
||||
const { deserializedXYWH, labelXYWH } = model as ConnectorElementModel;
|
||||
const [x, y, w, h] = deserializedXYWH;
|
||||
const [lx, ly, lw, lh] = labelXYWH!;
|
||||
const offset = DEFAULT_ARROW_SIZE * strokeWidth;
|
||||
|
||||
dx = lx - x;
|
||||
dy = ly - y;
|
||||
|
||||
const path = new Path2D();
|
||||
path.rect(-offset / 2, -offset / 2, w + offset, h + offset);
|
||||
path.rect(dx - 3 - 0.5, dy - 3 - 0.5, lw + 6 + 1, lh + 6 + 1);
|
||||
ctx.clip(path, 'evenodd');
|
||||
}
|
||||
|
||||
const strokeColor = renderer.getColorValue(
|
||||
model.stroke,
|
||||
DefaultTheme.connectorColor,
|
||||
true
|
||||
);
|
||||
|
||||
renderPoints(
|
||||
model,
|
||||
ctx,
|
||||
rc,
|
||||
points,
|
||||
strokeStyle === 'dash',
|
||||
mode === ConnectorMode.Curve,
|
||||
strokeColor
|
||||
);
|
||||
renderEndpoint(
|
||||
model,
|
||||
points,
|
||||
ctx,
|
||||
rc,
|
||||
'Front',
|
||||
frontEndpointStyle,
|
||||
strokeColor
|
||||
);
|
||||
renderEndpoint(
|
||||
model,
|
||||
points,
|
||||
ctx,
|
||||
rc,
|
||||
'Rear',
|
||||
rearEndpointStyle,
|
||||
strokeColor
|
||||
);
|
||||
|
||||
if (hasLabel) {
|
||||
ctx.restore();
|
||||
|
||||
renderLabel(
|
||||
model as ConnectorElementModel,
|
||||
ctx,
|
||||
matrix.translate(dx, dy),
|
||||
renderer
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const ConnectorElementRendererExtension = ElementRendererExtension(
|
||||
'connector',
|
||||
connector
|
||||
);
|
||||
|
||||
function renderPoints(
|
||||
model: ConnectorElementModel | LocalConnectorElementModel,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
rc: RoughCanvas,
|
||||
points: PointLocation[],
|
||||
dash: boolean,
|
||||
curve: boolean,
|
||||
stroke: string
|
||||
) {
|
||||
const { seed, strokeWidth, roughness, rough } = model;
|
||||
|
||||
if (rough) {
|
||||
const options = {
|
||||
seed,
|
||||
roughness,
|
||||
stroke,
|
||||
strokeLineDash: dash ? [12, 12] : undefined,
|
||||
strokeWidth,
|
||||
};
|
||||
if (curve) {
|
||||
const b = getBezierParameters(points);
|
||||
rc.path(
|
||||
`M${b[0][0]},${b[0][1]} C${b[1][0]},${b[1][1]} ${b[2][0]},${b[2][1]} ${b[3][0]},${b[3][1]}`,
|
||||
options
|
||||
);
|
||||
} else {
|
||||
rc.linearPath(points as unknown as [number, number][], options);
|
||||
}
|
||||
} else {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = stroke;
|
||||
ctx.lineWidth = strokeWidth;
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.lineCap = 'round';
|
||||
dash && ctx.setLineDash([12, 12]);
|
||||
ctx.beginPath();
|
||||
if (curve) {
|
||||
points.forEach((point, index) => {
|
||||
if (index === 0) {
|
||||
ctx.moveTo(point[0], point[1]);
|
||||
} else {
|
||||
const last = points[index - 1];
|
||||
ctx.bezierCurveTo(
|
||||
last.absOut[0],
|
||||
last.absOut[1],
|
||||
point.absIn[0],
|
||||
point.absIn[1],
|
||||
point[0],
|
||||
point[1]
|
||||
);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
points.forEach((point, index) => {
|
||||
if (index === 0) {
|
||||
ctx.moveTo(point[0], point[1]);
|
||||
} else {
|
||||
ctx.lineTo(point[0], point[1]);
|
||||
}
|
||||
});
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.closePath();
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
function renderEndpoint(
|
||||
model: ConnectorElementModel | LocalConnectorElementModel,
|
||||
location: PointLocation[],
|
||||
ctx: CanvasRenderingContext2D,
|
||||
rc: RoughCanvas,
|
||||
end: 'Front' | 'Rear',
|
||||
style: PointStyle,
|
||||
stroke: string
|
||||
) {
|
||||
const arrowOptions = getArrowOptions(end, model, stroke);
|
||||
|
||||
switch (style) {
|
||||
case 'Arrow':
|
||||
renderArrow(location, ctx, rc, arrowOptions);
|
||||
break;
|
||||
case 'Triangle':
|
||||
renderTriangle(location, ctx, rc, arrowOptions);
|
||||
break;
|
||||
case 'Circle':
|
||||
renderCircle(location, ctx, rc, arrowOptions);
|
||||
break;
|
||||
case 'Diamond':
|
||||
renderDiamond(location, ctx, rc, arrowOptions);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function renderLabel(
|
||||
model: ConnectorElementModel,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
matrix: DOMMatrix,
|
||||
renderer: CanvasRenderer
|
||||
) {
|
||||
const {
|
||||
text,
|
||||
labelXYWH,
|
||||
labelStyle: {
|
||||
color,
|
||||
fontSize,
|
||||
fontWeight,
|
||||
fontStyle,
|
||||
fontFamily,
|
||||
textAlign,
|
||||
},
|
||||
labelConstraints: { hasMaxWidth, maxWidth },
|
||||
} = model;
|
||||
const font = getFontString({
|
||||
fontStyle,
|
||||
fontWeight,
|
||||
fontSize,
|
||||
fontFamily,
|
||||
});
|
||||
const [, , w, h] = labelXYWH!;
|
||||
const cx = w / 2;
|
||||
const cy = h / 2;
|
||||
|
||||
ctx.setTransform(matrix);
|
||||
|
||||
if (renderer.usePlaceholder) {
|
||||
ctx.fillStyle = getAffinePlaceholderFillColor(renderer.getColorScheme());
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
return; // Skip actual label rendering
|
||||
}
|
||||
|
||||
const deltas = wrapTextDeltas(text!, font, w);
|
||||
const lines = deltaInsertsToChunks(deltas);
|
||||
const lineHeight = getLineHeight(fontFamily, fontSize, fontWeight);
|
||||
const textHeight = (lines.length - 1) * lineHeight * 0.5;
|
||||
|
||||
ctx.font = font;
|
||||
ctx.textAlign = textAlign;
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillStyle = renderer.getColorValue(color, DefaultTheme.black, true);
|
||||
|
||||
let textMaxWidth = textAlign === 'center' ? 0 : getMaxTextWidth(lines, font);
|
||||
if (hasMaxWidth && maxWidth > 0) {
|
||||
textMaxWidth = Math.min(textMaxWidth, textMaxWidth);
|
||||
}
|
||||
|
||||
for (const [index, line] of lines.entries()) {
|
||||
for (const delta of line) {
|
||||
const str = delta.insert;
|
||||
const rtl = isRTL(str);
|
||||
const shouldTemporarilyAttach = rtl && !ctx.canvas.isConnected;
|
||||
if (shouldTemporarilyAttach) {
|
||||
// to correctly render RTL text mixed with LTR, we have to append it
|
||||
// to the DOM
|
||||
document.body.append(ctx.canvas);
|
||||
}
|
||||
|
||||
ctx.canvas.setAttribute('dir', rtl ? 'rtl' : 'ltr');
|
||||
|
||||
const x =
|
||||
textMaxWidth *
|
||||
(textAlign === 'center'
|
||||
? 1
|
||||
: textAlign === 'right'
|
||||
? rtl
|
||||
? -0.5
|
||||
: 0.5
|
||||
: rtl
|
||||
? 0.5
|
||||
: -0.5);
|
||||
ctx.fillText(str, x + cx, index * lineHeight - textHeight + cy);
|
||||
|
||||
if (shouldTemporarilyAttach) {
|
||||
ctx.canvas.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getMaxTextWidth(lines: TextDelta[][], font: string) {
|
||||
return Math.max(
|
||||
...lines.flatMap(line =>
|
||||
line.map(delta => getTextWidth(delta.insert, font))
|
||||
)
|
||||
);
|
||||
}
|
||||
2
blocksuite/affine/gfx/connector/src/renderer/index.ts
Normal file
2
blocksuite/affine/gfx/connector/src/renderer/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './dom-renderer';
|
||||
export * from './element-renderer';
|
||||
312
blocksuite/affine/gfx/connector/src/renderer/utils.ts
Normal file
312
blocksuite/affine/gfx/connector/src/renderer/utils.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
import type { RoughCanvas } from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
type ConnectorElementModel,
|
||||
ConnectorMode,
|
||||
type LocalConnectorElementModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import type {
|
||||
BezierCurveParameters,
|
||||
IVec,
|
||||
PointLocation,
|
||||
} from '@blocksuite/global/gfx';
|
||||
import {
|
||||
getBezierParameters,
|
||||
getBezierTangent,
|
||||
Vec,
|
||||
} from '@blocksuite/global/gfx';
|
||||
|
||||
type ConnectorEnd = 'Front' | 'Rear';
|
||||
|
||||
export const DEFAULT_ARROW_SIZE = 15;
|
||||
|
||||
export function getArrowPoints(
|
||||
points: PointLocation[],
|
||||
size = 10,
|
||||
mode: ConnectorMode,
|
||||
bezierParameters: BezierCurveParameters,
|
||||
endPoint: ConnectorEnd = 'Rear',
|
||||
radians: number = Math.PI / 4
|
||||
) {
|
||||
const anchorPoint = getPointWithTangent(
|
||||
points,
|
||||
mode,
|
||||
endPoint,
|
||||
bezierParameters
|
||||
);
|
||||
const unit = Vec.mul(anchorPoint.tangent, -1);
|
||||
const angle = endPoint === 'Front' ? Math.PI : 0;
|
||||
|
||||
return {
|
||||
points: [
|
||||
Vec.add(Vec.mul(Vec.rot(unit, angle + radians), size), anchorPoint),
|
||||
anchorPoint,
|
||||
Vec.add(Vec.mul(Vec.rot(unit, angle - radians), size), anchorPoint),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function getCircleCenterPoint(
|
||||
points: PointLocation[],
|
||||
radius = 5,
|
||||
mode: ConnectorMode,
|
||||
bezierParameters: BezierCurveParameters,
|
||||
endPoint: ConnectorEnd = 'Rear'
|
||||
) {
|
||||
const anchorPoint = getPointWithTangent(
|
||||
points,
|
||||
mode,
|
||||
endPoint,
|
||||
bezierParameters
|
||||
);
|
||||
|
||||
const unit = Vec.mul(anchorPoint.tangent, -1);
|
||||
const angle = endPoint === 'Front' ? Math.PI : 0;
|
||||
|
||||
return Vec.add(Vec.mul(Vec.rot(unit, angle), radius), anchorPoint);
|
||||
}
|
||||
|
||||
export function getPointWithTangent(
|
||||
points: PointLocation[],
|
||||
mode: ConnectorMode,
|
||||
endPoint: ConnectorEnd,
|
||||
bezierParameters: BezierCurveParameters
|
||||
) {
|
||||
const anchorIndex = endPoint === 'Rear' ? points.length - 1 : 0;
|
||||
const pointToAnchorIndex =
|
||||
endPoint === 'Rear' ? anchorIndex - 1 : anchorIndex + 1;
|
||||
const anchorPoint = points[anchorIndex];
|
||||
const pointToAnchor = points[pointToAnchorIndex];
|
||||
|
||||
const clone = anchorPoint.clone();
|
||||
let tangent;
|
||||
if (mode !== ConnectorMode.Curve) {
|
||||
tangent =
|
||||
endPoint === 'Rear'
|
||||
? Vec.tangent(anchorPoint, pointToAnchor)
|
||||
: Vec.tangent(pointToAnchor, anchorPoint);
|
||||
} else {
|
||||
tangent =
|
||||
endPoint === 'Rear'
|
||||
? getBezierTangent(bezierParameters, 1)
|
||||
: getBezierTangent(bezierParameters, 0);
|
||||
}
|
||||
clone.tangent = tangent ?? [0, 0];
|
||||
|
||||
return clone;
|
||||
}
|
||||
|
||||
export function getDiamondPoints(
|
||||
point: PointLocation,
|
||||
size = 10,
|
||||
endPoint: ConnectorEnd = 'Rear'
|
||||
) {
|
||||
const unit = Vec.mul(point.tangent, -1);
|
||||
const angle = endPoint === 'Front' ? Math.PI : 0;
|
||||
|
||||
const diamondPoints = [
|
||||
Vec.add(Vec.mul(Vec.rot(unit, angle + Math.PI * 0.25), size), point),
|
||||
point,
|
||||
Vec.add(Vec.mul(Vec.rot(unit, angle - Math.PI * 0.25), size), point),
|
||||
Vec.add(Vec.mul(Vec.rot(unit, angle), size * Math.sqrt(2)), point),
|
||||
];
|
||||
|
||||
return {
|
||||
points: diamondPoints,
|
||||
};
|
||||
}
|
||||
|
||||
export type ArrowOptions = ReturnType<typeof getArrowOptions>;
|
||||
|
||||
export function getArrowOptions(
|
||||
end: ConnectorEnd,
|
||||
model: ConnectorElementModel | LocalConnectorElementModel,
|
||||
strokeColor: string
|
||||
) {
|
||||
const { seed, mode, rough, roughness, strokeWidth, path } = model;
|
||||
|
||||
return {
|
||||
end,
|
||||
seed,
|
||||
mode,
|
||||
rough,
|
||||
roughness,
|
||||
strokeWidth,
|
||||
strokeColor,
|
||||
fillColor: strokeColor,
|
||||
fillStyle: 'solid',
|
||||
bezierParameters: getBezierParameters(path),
|
||||
};
|
||||
}
|
||||
|
||||
export function getRcOptions(options: ArrowOptions) {
|
||||
const { seed, roughness, strokeWidth, strokeColor, fillColor } = options;
|
||||
return {
|
||||
seed,
|
||||
roughness,
|
||||
stroke: strokeColor,
|
||||
strokeWidth,
|
||||
fill: fillColor,
|
||||
fillStyle: 'solid',
|
||||
};
|
||||
}
|
||||
|
||||
export function renderRoundedPolygon(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
points: IVec[],
|
||||
color: string,
|
||||
strokeWidth: number,
|
||||
fill: boolean = true
|
||||
) {
|
||||
ctx.fillStyle = color;
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = strokeWidth;
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.lineCap = 'round';
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
if (i === 0) {
|
||||
ctx.moveTo(points[i][0], points[i][1]);
|
||||
} else {
|
||||
ctx.lineTo(points[i][0], points[i][1]);
|
||||
}
|
||||
}
|
||||
|
||||
if (fill) {
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
export function renderArrow(
|
||||
points: PointLocation[],
|
||||
ctx: CanvasRenderingContext2D,
|
||||
rc: RoughCanvas,
|
||||
options: ArrowOptions
|
||||
) {
|
||||
const { mode, end, bezierParameters, rough, strokeColor, strokeWidth } =
|
||||
options;
|
||||
const radians = Math.PI / 4;
|
||||
const size = DEFAULT_ARROW_SIZE * (strokeWidth / 2);
|
||||
const { points: arrowPoints } = getArrowPoints(
|
||||
points,
|
||||
size,
|
||||
mode,
|
||||
bezierParameters,
|
||||
end,
|
||||
radians
|
||||
);
|
||||
|
||||
if (rough) {
|
||||
rc.linearPath(arrowPoints as [number, number][], getRcOptions(options));
|
||||
} else {
|
||||
renderRoundedPolygon(ctx, arrowPoints, strokeColor, strokeWidth, false);
|
||||
}
|
||||
}
|
||||
|
||||
export function renderTriangle(
|
||||
points: PointLocation[],
|
||||
ctx: CanvasRenderingContext2D,
|
||||
rc: RoughCanvas,
|
||||
options: ArrowOptions
|
||||
) {
|
||||
const { mode, end, bezierParameters, rough, strokeColor, strokeWidth } =
|
||||
options;
|
||||
const radians = Math.PI / 6;
|
||||
const size = DEFAULT_ARROW_SIZE * (strokeWidth / 2);
|
||||
const { points: trianglePoints } = getArrowPoints(
|
||||
points,
|
||||
size,
|
||||
mode,
|
||||
bezierParameters,
|
||||
end,
|
||||
radians
|
||||
);
|
||||
|
||||
if (rough) {
|
||||
rc.polygon(
|
||||
[
|
||||
[trianglePoints[0][0], trianglePoints[0][1]],
|
||||
[trianglePoints[1][0], trianglePoints[1][1]],
|
||||
[trianglePoints[2][0], trianglePoints[2][1]],
|
||||
],
|
||||
getRcOptions(options)
|
||||
);
|
||||
} else {
|
||||
renderRoundedPolygon(ctx, trianglePoints, strokeColor, strokeWidth);
|
||||
}
|
||||
}
|
||||
|
||||
export function renderDiamond(
|
||||
points: PointLocation[],
|
||||
ctx: CanvasRenderingContext2D,
|
||||
rc: RoughCanvas,
|
||||
options: ArrowOptions
|
||||
) {
|
||||
const { mode, end, rough, bezierParameters, strokeColor, strokeWidth } =
|
||||
options;
|
||||
const anchorPoint = getPointWithTangent(points, mode, end, bezierParameters);
|
||||
const size = 10 * (strokeWidth / 2);
|
||||
const { points: diamondPoints } = getDiamondPoints(anchorPoint, size, end);
|
||||
|
||||
if (rough) {
|
||||
rc.polygon(
|
||||
[
|
||||
[diamondPoints[0][0], diamondPoints[0][1]],
|
||||
[diamondPoints[1][0], diamondPoints[1][1]],
|
||||
[diamondPoints[2][0], diamondPoints[2][1]],
|
||||
[diamondPoints[3][0], diamondPoints[3][1]],
|
||||
],
|
||||
getRcOptions(options)
|
||||
);
|
||||
} else {
|
||||
renderRoundedPolygon(ctx, diamondPoints, strokeColor, strokeWidth);
|
||||
}
|
||||
}
|
||||
|
||||
export function renderCircle(
|
||||
points: PointLocation[],
|
||||
ctx: CanvasRenderingContext2D,
|
||||
rc: RoughCanvas,
|
||||
options: ArrowOptions
|
||||
) {
|
||||
const {
|
||||
bezierParameters,
|
||||
mode,
|
||||
end,
|
||||
fillColor,
|
||||
strokeColor,
|
||||
strokeWidth,
|
||||
rough,
|
||||
} = options;
|
||||
const radius = 5 * (strokeWidth / 2);
|
||||
const centerPoint = getCircleCenterPoint(
|
||||
points,
|
||||
radius,
|
||||
mode,
|
||||
bezierParameters,
|
||||
end
|
||||
);
|
||||
const cx = centerPoint[0];
|
||||
const cy = centerPoint[1];
|
||||
|
||||
if (rough) {
|
||||
// radius + 2 when render rough circle to avoid connector line cross the circle and make it looks bad
|
||||
rc.circle(cx, cy, radius + 2, getRcOptions(options));
|
||||
} else {
|
||||
ctx.fillStyle = fillColor;
|
||||
ctx.strokeStyle = strokeColor;
|
||||
ctx.lineWidth = strokeWidth;
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(cx, cy, radius, radius, 0, 0, 2 * Math.PI);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
21
blocksuite/affine/gfx/connector/src/store.ts
Normal file
21
blocksuite/affine/gfx/connector/src/store.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import {
|
||||
type StoreExtensionContext,
|
||||
StoreExtensionProvider,
|
||||
} from '@blocksuite/affine-ext-loader';
|
||||
|
||||
import {
|
||||
connectorToMarkdownAdapterMatcher,
|
||||
connectorToPlainTextAdapterMatcher,
|
||||
} from './adapter';
|
||||
import { connectorWatcherExtension } from './connector-watcher';
|
||||
|
||||
export class ConnectorStoreExtension extends StoreExtensionProvider {
|
||||
override name = 'affine-connector-gfx';
|
||||
|
||||
override setup(context: StoreExtensionContext) {
|
||||
super.setup(context);
|
||||
context.register(connectorToPlainTextAdapterMatcher);
|
||||
context.register(connectorToMarkdownAdapterMatcher);
|
||||
context.register(connectorWatcherExtension);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
import {
|
||||
DefaultTool,
|
||||
EdgelessCRUDIdentifier,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import { getLineHeight } from '@blocksuite/affine-gfx-text';
|
||||
import type { ConnectorElementModel } from '@blocksuite/affine-model';
|
||||
import type { RichText } from '@blocksuite/affine-rich-text';
|
||||
import { ThemeProvider } from '@blocksuite/affine-shared/services';
|
||||
import { almostEqual } from '@blocksuite/affine-shared/utils';
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import { Bound, type IVec, Vec } from '@blocksuite/global/gfx';
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import {
|
||||
type BlockComponent,
|
||||
type BlockStdScope,
|
||||
ShadowlessElement,
|
||||
stdContext,
|
||||
} from '@blocksuite/std';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
|
||||
import { RANGE_SYNC_EXCLUDE_ATTR } from '@blocksuite/std/inline';
|
||||
import { consume } from '@lit/context';
|
||||
import { css, html, nothing } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
const HORIZONTAL_PADDING = 2;
|
||||
const VERTICAL_PADDING = 2;
|
||||
const BORDER_WIDTH = 1;
|
||||
|
||||
export function mountConnectorLabelEditor(
|
||||
connector: ConnectorElementModel,
|
||||
edgeless: BlockComponent,
|
||||
point?: IVec
|
||||
) {
|
||||
const mountElm = edgeless.querySelector('.edgeless-mount-point');
|
||||
if (!mountElm) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.ValueNotExists,
|
||||
"edgeless block's mount point does not exist"
|
||||
);
|
||||
}
|
||||
|
||||
const gfx = edgeless.std.get(GfxControllerIdentifier);
|
||||
|
||||
gfx.tool.setTool(DefaultTool);
|
||||
gfx.selection.set({
|
||||
elements: [connector.id],
|
||||
editing: true,
|
||||
});
|
||||
|
||||
if (!connector.text) {
|
||||
const text = new Y.Text();
|
||||
const labelOffset = connector.labelOffset;
|
||||
let labelXYWH = connector.labelXYWH ?? [0, 0, 16, 16];
|
||||
|
||||
if (point) {
|
||||
const center = connector.getNearestPoint(point);
|
||||
const distance = connector.getOffsetDistanceByPoint(center as IVec);
|
||||
const bounds = Bound.fromXYWH(labelXYWH);
|
||||
bounds.center = center;
|
||||
labelOffset.distance = distance;
|
||||
labelXYWH = bounds.toXYWH();
|
||||
}
|
||||
|
||||
edgeless.std.get(EdgelessCRUDIdentifier).updateElement(connector.id, {
|
||||
text,
|
||||
labelXYWH,
|
||||
labelOffset: { ...labelOffset },
|
||||
});
|
||||
}
|
||||
|
||||
const editor = new EdgelessConnectorLabelEditor();
|
||||
editor.connector = connector;
|
||||
|
||||
mountElm.append(editor);
|
||||
editor.updateComplete
|
||||
.then(() => {
|
||||
editor.inlineEditor?.focusEnd();
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
export class EdgelessConnectorLabelEditor extends WithDisposable(
|
||||
ShadowlessElement
|
||||
) {
|
||||
static override styles = css`
|
||||
.edgeless-connector-label-editor {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
transform-origin: center;
|
||||
z-index: 10;
|
||||
padding: ${VERTICAL_PADDING}px ${HORIZONTAL_PADDING}px;
|
||||
border: ${BORDER_WIDTH}px solid var(--affine-primary-color, #1e96eb);
|
||||
background: var(--affine-background-primary-color, #fff);
|
||||
border-radius: 2px;
|
||||
box-shadow: 0px 0px 0px 2px rgba(30, 150, 235, 0.3);
|
||||
box-sizing: border-box;
|
||||
overflow: visible;
|
||||
|
||||
.inline-editor {
|
||||
white-space: pre-wrap !important;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.inline-editor span {
|
||||
word-break: normal !important;
|
||||
overflow-wrap: anywhere !important;
|
||||
}
|
||||
|
||||
.edgeless-connector-label-editor-placeholder {
|
||||
pointer-events: none;
|
||||
color: var(--affine-text-disable-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
get crud() {
|
||||
return this.std.get(EdgelessCRUDIdentifier);
|
||||
}
|
||||
|
||||
get gfx() {
|
||||
return this.std.get(GfxControllerIdentifier);
|
||||
}
|
||||
|
||||
get selection() {
|
||||
return this.gfx.selection;
|
||||
}
|
||||
|
||||
private _isComposition = false;
|
||||
|
||||
private _keeping = false;
|
||||
|
||||
private _resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
private readonly _updateLabelRect = () => {
|
||||
const { connector, isConnected } = this;
|
||||
if (!connector || !isConnected) return;
|
||||
|
||||
if (!this.inlineEditorContainer) return;
|
||||
|
||||
const newWidth = this.inlineEditorContainer.scrollWidth;
|
||||
const newHeight = this.inlineEditorContainer.scrollHeight;
|
||||
const center = connector.getPointByOffsetDistance(
|
||||
connector.labelOffset.distance
|
||||
);
|
||||
const bounds = Bound.fromCenter(center, newWidth, newHeight);
|
||||
const labelXYWH = bounds.toXYWH();
|
||||
|
||||
if (
|
||||
!connector.labelXYWH ||
|
||||
labelXYWH.some((p, i) => !almostEqual(p, connector.labelXYWH![i]))
|
||||
) {
|
||||
this.crud.updateElement(connector.id, {
|
||||
labelXYWH,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
get inlineEditor() {
|
||||
return this.richText.inlineEditor;
|
||||
}
|
||||
|
||||
get inlineEditorContainer() {
|
||||
return this.inlineEditor?.rootElement;
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true');
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._resizeObserver?.disconnect();
|
||||
this._resizeObserver = null;
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
const { connector, selection, std } = this;
|
||||
const dispatcher = std.event;
|
||||
|
||||
this._resizeObserver = new ResizeObserver(() => {
|
||||
this._updateLabelRect();
|
||||
this.requestUpdate();
|
||||
});
|
||||
this._resizeObserver.observe(this.richText);
|
||||
|
||||
this.connector.stash('labelXYWH');
|
||||
|
||||
this.updateComplete
|
||||
.then(() => {
|
||||
if (!this.inlineEditor) return;
|
||||
this.inlineEditor.selectAll();
|
||||
|
||||
this.inlineEditor.slots.renderComplete.subscribe(() => {
|
||||
this.requestUpdate();
|
||||
});
|
||||
|
||||
this.disposables.add(
|
||||
dispatcher.add('keyDown', ctx => {
|
||||
const state = ctx.get('keyboardState');
|
||||
const { key, ctrlKey, metaKey, altKey, shiftKey, isComposing } =
|
||||
state.raw;
|
||||
const onlyCmd = (ctrlKey || metaKey) && !altKey && !shiftKey;
|
||||
const isModEnter = onlyCmd && key === 'Enter';
|
||||
const isEscape = key === 'Escape';
|
||||
if (!isComposing && (isModEnter || isEscape)) {
|
||||
this.inlineEditorContainer?.blur();
|
||||
|
||||
selection.set({
|
||||
elements: [connector.id],
|
||||
editing: false,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
})
|
||||
);
|
||||
|
||||
const surface = this.gfx.surface;
|
||||
|
||||
if (surface) {
|
||||
this.disposables.add(
|
||||
surface.elementUpdated.subscribe(({ id }) => {
|
||||
if (id === connector.id) this.requestUpdate();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
this.disposables.add(
|
||||
this.gfx.viewport.viewportUpdated.subscribe(() => {
|
||||
this.requestUpdate();
|
||||
})
|
||||
);
|
||||
|
||||
this.disposables.add(dispatcher.add('click', () => true));
|
||||
this.disposables.add(dispatcher.add('doubleClick', () => true));
|
||||
|
||||
this.disposables.add(() => {
|
||||
if (connector.text) {
|
||||
const text = connector.text.toString();
|
||||
const trimed = text.trim();
|
||||
const len = trimed.length;
|
||||
if (len === 0) {
|
||||
// reset
|
||||
this.crud.updateElement(connector.id, {
|
||||
text: undefined,
|
||||
labelXYWH: undefined,
|
||||
labelOffset: undefined,
|
||||
});
|
||||
} else if (len < text.length) {
|
||||
this.crud.updateElement(connector.id, {
|
||||
// @TODO: trim in Y.Text?
|
||||
text: new Y.Text(trimed),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
connector.labelEditing = false;
|
||||
connector.pop('labelXYWH');
|
||||
|
||||
selection.set({
|
||||
elements: [],
|
||||
editing: false,
|
||||
});
|
||||
});
|
||||
|
||||
if (!this.inlineEditorContainer) return;
|
||||
|
||||
this.disposables.addFromEvent(
|
||||
this.inlineEditorContainer,
|
||||
'blur',
|
||||
() => {
|
||||
if (this._keeping) return;
|
||||
this.remove();
|
||||
}
|
||||
);
|
||||
|
||||
this.disposables.addFromEvent(
|
||||
this.inlineEditorContainer,
|
||||
'compositionstart',
|
||||
() => {
|
||||
this._isComposition = true;
|
||||
this.requestUpdate();
|
||||
}
|
||||
);
|
||||
this.disposables.addFromEvent(
|
||||
this.inlineEditorContainer,
|
||||
'compositionend',
|
||||
() => {
|
||||
this._isComposition = false;
|
||||
this.requestUpdate();
|
||||
}
|
||||
);
|
||||
|
||||
connector.labelEditing = true;
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
override async getUpdateComplete(): Promise<boolean> {
|
||||
const result = await super.getUpdateComplete();
|
||||
await this.richText?.updateComplete;
|
||||
return result;
|
||||
}
|
||||
|
||||
override render() {
|
||||
const { connector } = this;
|
||||
const {
|
||||
labelOffset: { distance },
|
||||
labelStyle: {
|
||||
fontFamily,
|
||||
fontSize,
|
||||
fontStyle,
|
||||
fontWeight,
|
||||
textAlign,
|
||||
color: labelColor,
|
||||
},
|
||||
labelConstraints: { hasMaxWidth, maxWidth },
|
||||
} = connector;
|
||||
|
||||
const lineHeight = getLineHeight(fontFamily, fontSize, fontWeight);
|
||||
const { translateX, translateY, zoom } = this.gfx.viewport;
|
||||
const [x, y] = Vec.mul(connector.getPointByOffsetDistance(distance), zoom);
|
||||
const transformOperation = [
|
||||
'translate(-50%, -50%)',
|
||||
`translate(${translateX}px, ${translateY}px)`,
|
||||
`translate(${x}px, ${y}px)`,
|
||||
`scale(${zoom})`,
|
||||
];
|
||||
|
||||
const isEmpty = !connector.text?.length && !this._isComposition;
|
||||
const color = this.std
|
||||
.get(ThemeProvider)
|
||||
.generateColorProperty(labelColor, '#000000');
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="edgeless-connector-label-editor"
|
||||
style=${styleMap({
|
||||
fontFamily: `"${fontFamily}"`,
|
||||
fontSize: `${fontSize}px`,
|
||||
fontStyle,
|
||||
fontWeight,
|
||||
textAlign,
|
||||
lineHeight: `${lineHeight}px`,
|
||||
maxWidth: hasMaxWidth
|
||||
? `${maxWidth + BORDER_WIDTH * 2 + HORIZONTAL_PADDING * 2}px`
|
||||
: 'initial',
|
||||
color,
|
||||
transform: transformOperation.join(' '),
|
||||
})}
|
||||
>
|
||||
<rich-text
|
||||
.yText=${connector.text}
|
||||
.enableFormat=${false}
|
||||
style=${
|
||||
isEmpty
|
||||
? styleMap({
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
padding: `${VERTICAL_PADDING}px ${HORIZONTAL_PADDING}px`,
|
||||
})
|
||||
: nothing
|
||||
}
|
||||
></rich-text>
|
||||
${
|
||||
isEmpty
|
||||
? html`
|
||||
<span class="edgeless-connector-label-editor-placeholder">
|
||||
Add text
|
||||
</span>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
setKeeping(keeping: boolean) {
|
||||
this._keeping = keeping;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor connector!: ConnectorElementModel;
|
||||
|
||||
@consume({
|
||||
context: stdContext,
|
||||
})
|
||||
accessor std!: BlockStdScope;
|
||||
|
||||
@query('rich-text')
|
||||
accessor richText!: RichText;
|
||||
}
|
||||
1
blocksuite/affine/gfx/connector/src/text/index.ts
Normal file
1
blocksuite/affine/gfx/connector/src/text/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './text.js';
|
||||
66
blocksuite/affine/gfx/connector/src/text/text.ts
Normal file
66
blocksuite/affine/gfx/connector/src/text/text.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
DefaultTool,
|
||||
EdgelessCRUDIdentifier,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import type { ConnectorElementModel } from '@blocksuite/affine-model';
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import type { IVec } from '@blocksuite/global/gfx';
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
import type { BlockComponent } from '@blocksuite/std';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { EdgelessConnectorLabelEditor } from './edgeless-connector-label-editor';
|
||||
|
||||
export function mountConnectorLabelEditor(
|
||||
connector: ConnectorElementModel,
|
||||
edgeless: BlockComponent,
|
||||
point?: IVec
|
||||
) {
|
||||
const mountElm = edgeless.querySelector('.edgeless-mount-point');
|
||||
if (!mountElm) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.ValueNotExists,
|
||||
"edgeless block's mount point does not exist"
|
||||
);
|
||||
}
|
||||
|
||||
const gfx = edgeless.std.get(GfxControllerIdentifier);
|
||||
|
||||
gfx.tool.setTool(DefaultTool);
|
||||
gfx.selection.set({
|
||||
elements: [connector.id],
|
||||
editing: true,
|
||||
});
|
||||
|
||||
if (!connector.text) {
|
||||
const text = new Y.Text();
|
||||
const labelOffset = connector.labelOffset;
|
||||
let labelXYWH = connector.labelXYWH ?? [0, 0, 16, 16];
|
||||
|
||||
if (point) {
|
||||
const center = connector.getNearestPoint(point);
|
||||
const distance = connector.getOffsetDistanceByPoint(center as IVec);
|
||||
const bounds = Bound.fromXYWH(labelXYWH);
|
||||
bounds.center = center;
|
||||
labelOffset.distance = distance;
|
||||
labelXYWH = bounds.toXYWH();
|
||||
}
|
||||
|
||||
edgeless.std.get(EdgelessCRUDIdentifier).updateElement(connector.id, {
|
||||
text,
|
||||
labelXYWH,
|
||||
labelOffset: { ...labelOffset },
|
||||
});
|
||||
}
|
||||
|
||||
const editor = new EdgelessConnectorLabelEditor();
|
||||
editor.connector = connector;
|
||||
|
||||
mountElm.append(editor);
|
||||
editor.updateComplete
|
||||
.then(() => {
|
||||
editor.inlineEditor?.focusEnd();
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
436
blocksuite/affine/gfx/connector/src/toolbar/config.ts
Normal file
436
blocksuite/affine/gfx/connector/src/toolbar/config.ts
Normal file
@@ -0,0 +1,436 @@
|
||||
import { EdgelessCRUDIdentifier } from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
packColor,
|
||||
type PickColorEvent,
|
||||
} from '@blocksuite/affine-components/color-picker';
|
||||
import type { LineDetailType } from '@blocksuite/affine-components/edgeless-line-styles-panel';
|
||||
import {
|
||||
createTextActions,
|
||||
normalizeTextBound,
|
||||
} from '@blocksuite/affine-gfx-text';
|
||||
import {
|
||||
ConnectorElementModel,
|
||||
type ConnectorElementProps,
|
||||
type ConnectorLabelProps,
|
||||
ConnectorMode,
|
||||
DEFAULT_FRONT_ENDPOINT_STYLE,
|
||||
DEFAULT_REAR_ENDPOINT_STYLE,
|
||||
DefaultTheme,
|
||||
LineWidth,
|
||||
PointStyle,
|
||||
resolveColor,
|
||||
StrokeStyle,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
type ToolbarContext,
|
||||
type ToolbarGenericAction,
|
||||
type ToolbarModuleConfig,
|
||||
ToolbarModuleExtension,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
getMostCommonResolvedValue,
|
||||
getMostCommonValue,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import type { MenuItem } from '@blocksuite/affine-widget-edgeless-toolbar';
|
||||
import {
|
||||
getRootBlock,
|
||||
LINE_STYLE_LIST,
|
||||
renderMenu,
|
||||
} from '@blocksuite/affine-widget-edgeless-toolbar';
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
import {
|
||||
AddTextIcon,
|
||||
ConnectorCIcon,
|
||||
ConnectorEIcon,
|
||||
ConnectorLIcon,
|
||||
EndPointArrowIcon,
|
||||
EndPointCircleIcon,
|
||||
EndPointDiamondIcon,
|
||||
EndPointTriangleIcon,
|
||||
FlipDirectionIcon,
|
||||
StartPointArrowIcon,
|
||||
StartPointCircleIcon,
|
||||
StartPointDiamondIcon,
|
||||
StartPointIcon,
|
||||
StartPointTriangleIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import { BlockFlavourIdentifier } from '@blocksuite/std';
|
||||
import { html } from 'lit';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { isConnectorWithLabel } from '../connector-manager';
|
||||
import { mountConnectorLabelEditor } from '../text';
|
||||
|
||||
const FRONT_ENDPOINT_STYLE_LIST = [
|
||||
{
|
||||
value: PointStyle.None,
|
||||
icon: StartPointIcon(),
|
||||
},
|
||||
{
|
||||
value: PointStyle.Arrow,
|
||||
icon: StartPointArrowIcon(),
|
||||
},
|
||||
{
|
||||
value: PointStyle.Triangle,
|
||||
icon: StartPointTriangleIcon(),
|
||||
},
|
||||
{
|
||||
value: PointStyle.Circle,
|
||||
icon: StartPointCircleIcon(),
|
||||
},
|
||||
{
|
||||
value: PointStyle.Diamond,
|
||||
icon: StartPointDiamondIcon(),
|
||||
},
|
||||
] as const satisfies MenuItem<PointStyle>[];
|
||||
|
||||
const REAR_ENDPOINT_STYLE_LIST = [
|
||||
{
|
||||
value: PointStyle.Diamond,
|
||||
icon: EndPointDiamondIcon(),
|
||||
},
|
||||
{
|
||||
value: PointStyle.Circle,
|
||||
icon: EndPointCircleIcon(),
|
||||
},
|
||||
{
|
||||
value: PointStyle.Triangle,
|
||||
icon: EndPointTriangleIcon(),
|
||||
},
|
||||
{
|
||||
value: PointStyle.Arrow,
|
||||
icon: EndPointArrowIcon(),
|
||||
},
|
||||
{
|
||||
value: PointStyle.None,
|
||||
icon: StartPointIcon(),
|
||||
},
|
||||
] as const satisfies MenuItem<PointStyle>[];
|
||||
|
||||
const CONNECTOR_MODE_LIST = [
|
||||
{
|
||||
key: 'Curve',
|
||||
value: ConnectorMode.Curve,
|
||||
icon: ConnectorCIcon(),
|
||||
},
|
||||
{
|
||||
key: 'Elbowed',
|
||||
value: ConnectorMode.Orthogonal,
|
||||
icon: ConnectorEIcon(),
|
||||
},
|
||||
{
|
||||
key: 'Straight',
|
||||
value: ConnectorMode.Straight,
|
||||
icon: ConnectorLIcon(),
|
||||
},
|
||||
] as const satisfies MenuItem<ConnectorMode>[];
|
||||
|
||||
export const connectorToolbarConfig = {
|
||||
actions: [
|
||||
{
|
||||
id: 'a.stroke-color',
|
||||
content(ctx) {
|
||||
const models = ctx.getSurfaceModelsByType(ConnectorElementModel);
|
||||
if (!models.length) return null;
|
||||
|
||||
const enableCustomColor = ctx.features.getFlag('enable_color_picker');
|
||||
const theme = ctx.theme.edgeless$.value;
|
||||
|
||||
const field = 'stroke';
|
||||
const firstModel = models[0];
|
||||
const strokeWidth =
|
||||
getMostCommonValue(models, 'strokeWidth') ?? LineWidth.Four;
|
||||
const strokeStyle =
|
||||
getMostCommonValue(models, 'strokeStyle') ?? StrokeStyle.Solid;
|
||||
const stroke =
|
||||
getMostCommonResolvedValue(models, field, stroke =>
|
||||
resolveColor(stroke, theme)
|
||||
) ?? resolveColor(DefaultTheme.connectorColor, theme);
|
||||
|
||||
const onPickColor = (e: PickColorEvent) => {
|
||||
switch (e.type) {
|
||||
case 'pick':
|
||||
{
|
||||
const color = e.detail.value;
|
||||
const props = packColor(field, color);
|
||||
const crud = ctx.std.get(EdgelessCRUDIdentifier);
|
||||
models.forEach(model => {
|
||||
crud.updateElement(model.id, props);
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'start':
|
||||
ctx.store.captureSync();
|
||||
models.forEach(model => {
|
||||
model.stash(field);
|
||||
});
|
||||
break;
|
||||
case 'end':
|
||||
ctx.store.transact(() => {
|
||||
models.forEach(model => {
|
||||
model.pop(field);
|
||||
});
|
||||
});
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const onPickStrokeStyle = (e: CustomEvent<LineDetailType>) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const { type, value } = e.detail;
|
||||
|
||||
if (type === 'size') {
|
||||
updateModelsWith(ctx, models, 'strokeWidth', value);
|
||||
return;
|
||||
}
|
||||
|
||||
updateModelsWith(ctx, models, 'strokeStyle', value);
|
||||
};
|
||||
|
||||
return html`
|
||||
<edgeless-color-picker-button
|
||||
class="stroke-color"
|
||||
.label="${'Stroke style'}"
|
||||
.pick=${onPickColor}
|
||||
.color=${stroke}
|
||||
.theme=${theme}
|
||||
.hollowCircle=${true}
|
||||
.originalColor=${firstModel.stroke}
|
||||
.enableCustomColor=${enableCustomColor}
|
||||
>
|
||||
<edgeless-line-styles-panel
|
||||
slot="other"
|
||||
style=${styleMap({
|
||||
display: 'flex',
|
||||
alignSelf: 'stretch',
|
||||
gap: '8px',
|
||||
})}
|
||||
@select=${onPickStrokeStyle}
|
||||
.lineSize=${strokeWidth}
|
||||
.lineStyle=${strokeStyle}
|
||||
></edgeless-line-styles-panel>
|
||||
<editor-toolbar-separator
|
||||
slot="separator"
|
||||
data-orientation="horizontal"
|
||||
></editor-toolbar-separator>
|
||||
</edgeless-color-picker-button>
|
||||
`;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'b.style',
|
||||
when: ctx => ctx.features.getFlag('enable_edgeless_scribbled_style'),
|
||||
content(ctx) {
|
||||
const models = ctx.getSurfaceModelsByType(ConnectorElementModel);
|
||||
if (!models.length) return null;
|
||||
|
||||
const field = 'rough';
|
||||
const rough = getMostCommonValue(models, field) ?? false;
|
||||
const onPick = (value: boolean) => {
|
||||
updateModelsWith(ctx, models, field, value);
|
||||
};
|
||||
|
||||
return renderMenu({
|
||||
label: 'Style',
|
||||
items: LINE_STYLE_LIST,
|
||||
currentValue: rough,
|
||||
onPick,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'c.endpoint-style',
|
||||
actions: [
|
||||
{
|
||||
id: 'a.start-point-style',
|
||||
content(ctx) {
|
||||
const models = ctx.getSurfaceModelsByType(ConnectorElementModel);
|
||||
if (!models.length) return null;
|
||||
|
||||
const field = 'frontEndpointStyle';
|
||||
const pointStyle =
|
||||
getMostCommonValue(models, field) ?? DEFAULT_FRONT_ENDPOINT_STYLE;
|
||||
const onPick = (value: PointStyle) => {
|
||||
updateModelsWith(ctx, models, field, value);
|
||||
};
|
||||
|
||||
return renderMenu({
|
||||
label: 'Start point style',
|
||||
items: FRONT_ENDPOINT_STYLE_LIST,
|
||||
currentValue: pointStyle,
|
||||
onPick,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'b.flip-direction',
|
||||
icon: FlipDirectionIcon(),
|
||||
tooltip: 'Flip direction',
|
||||
run(ctx) {
|
||||
const models = ctx.getSurfaceModelsByType(ConnectorElementModel);
|
||||
if (!models.length) return;
|
||||
|
||||
const frontEndpointStyle =
|
||||
getMostCommonValue(models, 'frontEndpointStyle') ??
|
||||
DEFAULT_FRONT_ENDPOINT_STYLE;
|
||||
const rearEndpointStyle =
|
||||
getMostCommonValue(models, 'rearEndpointStyle') ??
|
||||
DEFAULT_REAR_ENDPOINT_STYLE;
|
||||
|
||||
if (frontEndpointStyle === rearEndpointStyle) return;
|
||||
|
||||
for (const model of models) {
|
||||
ctx.std.get(EdgelessCRUDIdentifier).updateElement(model.id, {
|
||||
frontEndpointStyle: rearEndpointStyle,
|
||||
rearEndpointStyle: frontEndpointStyle,
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'c.end-point-style',
|
||||
content(ctx) {
|
||||
const models = ctx.getSurfaceModelsByType(ConnectorElementModel);
|
||||
if (!models.length) return null;
|
||||
|
||||
const field = 'rearEndpointStyle';
|
||||
const pointStyle =
|
||||
getMostCommonValue(models, field) ?? DEFAULT_REAR_ENDPOINT_STYLE;
|
||||
const onPick = (value: PointStyle) => {
|
||||
updateModelsWith(ctx, models, field, value);
|
||||
};
|
||||
|
||||
return renderMenu({
|
||||
label: 'End point style',
|
||||
items: REAR_ENDPOINT_STYLE_LIST,
|
||||
currentValue: pointStyle,
|
||||
onPick,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'd.connector-shape',
|
||||
content(ctx) {
|
||||
const models = ctx.getSurfaceModelsByType(ConnectorElementModel);
|
||||
if (!models.length) return null;
|
||||
|
||||
const field = 'mode';
|
||||
const mode =
|
||||
getMostCommonValue(models, field) ?? ConnectorMode.Orthogonal;
|
||||
const onPick = (value: ConnectorMode) => {
|
||||
updateModelsWith(ctx, models, field, value);
|
||||
};
|
||||
|
||||
return renderMenu({
|
||||
label: 'Shape',
|
||||
tooltip: 'Connector shape',
|
||||
items: CONNECTOR_MODE_LIST,
|
||||
currentValue: mode,
|
||||
onPick,
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'g.text',
|
||||
tooltip: 'Add text',
|
||||
icon: AddTextIcon(),
|
||||
when(ctx) {
|
||||
const models = ctx.getSurfaceModelsByType(ConnectorElementModel);
|
||||
return models.length === 1 && !models[0].text;
|
||||
},
|
||||
run(ctx) {
|
||||
const model = ctx.getCurrentModelByType(ConnectorElementModel);
|
||||
if (!model) return;
|
||||
|
||||
const rootModel = ctx.store.root;
|
||||
if (!rootModel) return;
|
||||
|
||||
const rootBlock = getRootBlock(ctx);
|
||||
if (!rootBlock) return;
|
||||
|
||||
mountConnectorLabelEditor(model, rootBlock);
|
||||
},
|
||||
},
|
||||
// id: `g.text`
|
||||
...createTextActions(
|
||||
ConnectorElementModel,
|
||||
'connector',
|
||||
(ctx, model, props) => {
|
||||
if (!isConnectorWithLabel(model)) return;
|
||||
|
||||
const labelStyle = { ...model.labelStyle, ...props };
|
||||
|
||||
// No need to adjust element bounds
|
||||
if (props['textAlign']) {
|
||||
ctx.std
|
||||
.get(EdgelessCRUDIdentifier)
|
||||
.updateElement(model.id, { labelStyle });
|
||||
return;
|
||||
}
|
||||
|
||||
const { fontFamily, fontStyle, fontSize, fontWeight } = labelStyle;
|
||||
const {
|
||||
text,
|
||||
labelXYWH,
|
||||
labelConstraints: { hasMaxWidth, maxWidth },
|
||||
} = model;
|
||||
const prevBounds = Bound.fromXYWH(labelXYWH || [0, 0, 16, 16]);
|
||||
const center = prevBounds.center;
|
||||
const bounds = normalizeTextBound(
|
||||
{
|
||||
yText: text!,
|
||||
fontFamily,
|
||||
fontStyle,
|
||||
fontSize,
|
||||
fontWeight,
|
||||
hasMaxWidth,
|
||||
maxWidth,
|
||||
},
|
||||
prevBounds
|
||||
);
|
||||
bounds.center = center;
|
||||
|
||||
ctx.std.get(EdgelessCRUDIdentifier).updateElement(model.id, {
|
||||
labelStyle,
|
||||
labelXYWH: bounds.toXYWH(),
|
||||
});
|
||||
},
|
||||
model => model.labelStyle,
|
||||
(model, type, _) => model[type]('labelStyle')
|
||||
).map<ToolbarGenericAction>(action => ({
|
||||
...action,
|
||||
id: `g.text-${action.id}`,
|
||||
when(ctx) {
|
||||
const models = ctx.getSurfaceModelsByType(ConnectorElementModel);
|
||||
return models.length > 0 && models.every(model => model.hasLabel());
|
||||
},
|
||||
})),
|
||||
],
|
||||
|
||||
when: ctx => ctx.getSurfaceModelsByType(ConnectorElementModel).length > 0,
|
||||
} as const satisfies ToolbarModuleConfig;
|
||||
|
||||
function updateModelsWith<
|
||||
T extends keyof Omit<ConnectorElementProps, keyof ConnectorLabelProps>,
|
||||
>(
|
||||
ctx: ToolbarContext,
|
||||
models: ConnectorElementModel[],
|
||||
field: T,
|
||||
value: ConnectorElementProps[T]
|
||||
) {
|
||||
ctx.store.captureSync();
|
||||
|
||||
for (const model of models) {
|
||||
ctx.std
|
||||
.get(EdgelessCRUDIdentifier)
|
||||
.updateElement(model.id, { [field]: value });
|
||||
}
|
||||
}
|
||||
|
||||
export const connectorToolbarExtension = ToolbarModuleExtension({
|
||||
id: BlockFlavourIdentifier('affine:surface:connector'),
|
||||
config: connectorToolbarConfig,
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { menu } from '@blocksuite/affine-components/context-menu';
|
||||
import { ConnectorMode } from '@blocksuite/affine-model';
|
||||
import { EditPropsStore } from '@blocksuite/affine-shared/services';
|
||||
import type { DenseMenuBuilder } from '@blocksuite/affine-widget-edgeless-toolbar';
|
||||
import {
|
||||
ConnectorCIcon,
|
||||
ConnectorEIcon,
|
||||
ConnectorLIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
|
||||
import { ConnectorTool } from '../connector-tool';
|
||||
|
||||
export const buildConnectorDenseMenu: DenseMenuBuilder = (edgeless, gfx) => {
|
||||
const prevMode =
|
||||
edgeless.std.get(EditPropsStore).lastProps$.value.connector.mode;
|
||||
|
||||
const isSelected = gfx.tool.currentToolName$.peek() === 'connector';
|
||||
|
||||
const createSelect =
|
||||
(mode: ConnectorMode, record = true) =>
|
||||
() => {
|
||||
gfx.tool.setTool(ConnectorTool, {
|
||||
mode,
|
||||
});
|
||||
record &&
|
||||
edgeless.std.get(EditPropsStore).recordLastProps('connector', { mode });
|
||||
};
|
||||
|
||||
const iconSize = { width: '20', height: '20' };
|
||||
return menu.subMenu({
|
||||
name: 'Connector',
|
||||
prefix: ConnectorCIcon(iconSize),
|
||||
select: createSelect(prevMode, false),
|
||||
isSelected,
|
||||
options: {
|
||||
items: [
|
||||
menu.action({
|
||||
name: 'Curve',
|
||||
prefix: ConnectorCIcon(iconSize),
|
||||
select: createSelect(ConnectorMode.Curve),
|
||||
isSelected: isSelected && prevMode === ConnectorMode.Curve,
|
||||
}),
|
||||
menu.action({
|
||||
name: 'Elbowed',
|
||||
prefix: ConnectorEIcon(iconSize),
|
||||
select: createSelect(ConnectorMode.Orthogonal),
|
||||
isSelected: isSelected && prevMode === ConnectorMode.Orthogonal,
|
||||
}),
|
||||
menu.action({
|
||||
name: 'Straight',
|
||||
prefix: ConnectorLIcon(iconSize),
|
||||
select: createSelect(ConnectorMode.Straight),
|
||||
isSelected: isSelected && prevMode === ConnectorMode.Straight,
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
};
|
||||
153
blocksuite/affine/gfx/connector/src/toolbar/connector-menu.ts
Normal file
153
blocksuite/affine/gfx/connector/src/toolbar/connector-menu.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import {
|
||||
ConnectorMode,
|
||||
DefaultTheme,
|
||||
type LineWidth,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
EditPropsStore,
|
||||
FeatureFlagService,
|
||||
ThemeProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import type { ColorEvent } from '@blocksuite/affine-shared/utils';
|
||||
import { EdgelessToolbarToolMixin } from '@blocksuite/affine-widget-edgeless-toolbar';
|
||||
import { SignalWatcher } from '@blocksuite/global/lit';
|
||||
import {
|
||||
ConnectorCIcon,
|
||||
ConnectorEIcon,
|
||||
ConnectorLIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import { computed } from '@preact/signals-core';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
import { ConnectorTool } from '../connector-tool';
|
||||
|
||||
function ConnectorModeButtonGroup(
|
||||
mode: ConnectorMode,
|
||||
setConnectorMode: (props: Record<string, unknown>) => void
|
||||
) {
|
||||
/**
|
||||
* There is little hacky on rendering tooltip.
|
||||
* We don't want either tooltip overlap the top button or tooltip on left.
|
||||
* So we put the lower button's tooltip as the first element of the button group container
|
||||
*/
|
||||
return html`
|
||||
<div class="connector-mode-button-group">
|
||||
<edgeless-tool-icon-button
|
||||
.active=${mode === ConnectorMode.Curve}
|
||||
.activeMode=${'background'}
|
||||
.tooltip=${'Curve'}
|
||||
.iconSize=${'20px'}
|
||||
@click=${() => setConnectorMode({ mode: ConnectorMode.Curve })}
|
||||
>
|
||||
${ConnectorCIcon()}
|
||||
</edgeless-tool-icon-button>
|
||||
<edgeless-tool-icon-button
|
||||
.active=${mode === ConnectorMode.Orthogonal}
|
||||
.activeMode=${'background'}
|
||||
.tooltip=${'Elbowed'}
|
||||
.iconSize=${'20px'}
|
||||
@click=${() => setConnectorMode({ mode: ConnectorMode.Orthogonal })}
|
||||
>
|
||||
${ConnectorEIcon()}
|
||||
</edgeless-tool-icon-button>
|
||||
<edgeless-tool-icon-button
|
||||
.active=${mode === ConnectorMode.Straight}
|
||||
.activeMode=${'background'}
|
||||
.tooltip=${'Straight'}
|
||||
.iconSize=${'20px'}
|
||||
@click=${() => setConnectorMode({ mode: ConnectorMode.Straight })}
|
||||
>
|
||||
${ConnectorLIcon()}
|
||||
</edgeless-tool-icon-button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export class EdgelessConnectorMenu extends EdgelessToolbarToolMixin(
|
||||
SignalWatcher(LitElement)
|
||||
) {
|
||||
static override styles = css`
|
||||
:host {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.connector-submenu-content {
|
||||
display: flex;
|
||||
height: 24px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.connector-mode-button-group {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.connector-mode-button-group > edgeless-tool-icon-button svg {
|
||||
fill: var(--affine-icon-color);
|
||||
}
|
||||
|
||||
.submenu-divider {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
margin: 0 16px;
|
||||
background-color: var(--affine-border-color);
|
||||
display: inline-block;
|
||||
}
|
||||
`;
|
||||
|
||||
private readonly _props$ = computed(() => {
|
||||
const { mode, stroke, strokeWidth } =
|
||||
this.edgeless.std.get(EditPropsStore).lastProps$.value.connector;
|
||||
return { mode, stroke, strokeWidth };
|
||||
});
|
||||
|
||||
private readonly _theme$ = computed(() => {
|
||||
return this.edgeless.std.get(ThemeProvider).theme$.value;
|
||||
});
|
||||
|
||||
override type = ConnectorTool;
|
||||
|
||||
override render() {
|
||||
const { stroke, strokeWidth, mode } = this._props$.value;
|
||||
const connectorModeButtonGroup = ConnectorModeButtonGroup(
|
||||
mode,
|
||||
this.onChange
|
||||
);
|
||||
|
||||
return html`
|
||||
<edgeless-slide-menu>
|
||||
<div class="connector-submenu-content">
|
||||
${connectorModeButtonGroup}
|
||||
<div class="submenu-divider"></div>
|
||||
<edgeless-line-width-panel
|
||||
.selectedSize=${strokeWidth}
|
||||
@select=${(e: CustomEvent<LineWidth>) =>
|
||||
this.onChange({ strokeWidth: e.detail })}
|
||||
>
|
||||
</edgeless-line-width-panel>
|
||||
<div class="submenu-divider"></div>
|
||||
<edgeless-color-panel
|
||||
class="one-way"
|
||||
.value=${stroke}
|
||||
.theme=${this._theme$.value}
|
||||
.palettes=${DefaultTheme.StrokeColorShortPalettes}
|
||||
.hasTransparent=${!this.edgeless.store
|
||||
.get(FeatureFlagService)
|
||||
.getFlag('enable_color_picker')}
|
||||
@select=${(e: ColorEvent) =>
|
||||
this.onChange({ stroke: e.detail.value })}
|
||||
></edgeless-color-panel>
|
||||
</div>
|
||||
</edgeless-slide-menu>
|
||||
`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onChange!: (props: Record<string, unknown>) => void;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { ConnectorMode, getConnectorModeName } from '@blocksuite/affine-model';
|
||||
import { EditPropsStore } from '@blocksuite/affine-shared/services';
|
||||
import { QuickToolMixin } from '@blocksuite/affine-widget-edgeless-toolbar';
|
||||
import { SignalWatcher } from '@blocksuite/global/lit';
|
||||
import {
|
||||
ConnectorCIcon,
|
||||
ConnectorEIcon,
|
||||
ConnectorLIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import { computed } from '@preact/signals-core';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
|
||||
import { ConnectorTool } from '../connector-tool';
|
||||
|
||||
const IcomMap = {
|
||||
[ConnectorMode.Straight]: ConnectorLIcon(),
|
||||
[ConnectorMode.Orthogonal]: ConnectorEIcon(),
|
||||
[ConnectorMode.Curve]: ConnectorCIcon(),
|
||||
};
|
||||
|
||||
export class EdgelessConnectorToolButton extends QuickToolMixin(
|
||||
SignalWatcher(LitElement)
|
||||
) {
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
}
|
||||
`;
|
||||
|
||||
private readonly _mode$ = computed(() => {
|
||||
return this.edgeless.std.get(EditPropsStore).lastProps$.value.connector
|
||||
.mode;
|
||||
});
|
||||
|
||||
override type = ConnectorTool;
|
||||
|
||||
private _toggleMenu() {
|
||||
if (this.tryDisposePopper()) return;
|
||||
|
||||
const menu = this.createPopper('edgeless-connector-menu', this);
|
||||
menu.element.edgeless = this.edgeless;
|
||||
menu.element.onChange = (props: Record<string, unknown>) => {
|
||||
this.edgeless.std.get(EditPropsStore).recordLastProps('connector', props);
|
||||
this.setEdgelessTool(this.type, {
|
||||
mode: this._mode$.value,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
override render() {
|
||||
const { active } = this;
|
||||
const mode = this._mode$.value;
|
||||
return html`
|
||||
<edgeless-tool-icon-button
|
||||
class="edgeless-connector-button"
|
||||
.tooltip=${
|
||||
this.popper
|
||||
? ''
|
||||
: html`<affine-tooltip-content-with-shortcut
|
||||
data-tip="${getConnectorModeName(mode)}"
|
||||
data-shortcut="${'C'}"
|
||||
></affine-tooltip-content-with-shortcut>`
|
||||
}
|
||||
.tooltipOffset=${17}
|
||||
.active=${active}
|
||||
.iconContainerPadding=${6}
|
||||
.iconSize=${'24px'}
|
||||
@click=${() => {
|
||||
// don't update tool before toggling menu
|
||||
this._toggleMenu();
|
||||
this.gfx.tool.setTool(ConnectorTool, {
|
||||
mode,
|
||||
});
|
||||
}}
|
||||
>
|
||||
${IcomMap[mode]}
|
||||
<toolbar-arrow-up-icon></toolbar-arrow-up-icon>
|
||||
</edgeless-tool-icon-button>
|
||||
`;
|
||||
}
|
||||
}
|
||||
15
blocksuite/affine/gfx/connector/src/toolbar/quick-tool.ts
Normal file
15
blocksuite/affine/gfx/connector/src/toolbar/quick-tool.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { QuickToolExtension } from '@blocksuite/affine-widget-edgeless-toolbar';
|
||||
import { html } from 'lit';
|
||||
|
||||
export const connectorQuickTool = QuickToolExtension(
|
||||
'connector',
|
||||
({ block }) => {
|
||||
return {
|
||||
type: 'connector',
|
||||
content: html`<edgeless-connector-tool-button
|
||||
.edgeless=${block}
|
||||
></edgeless-connector-tool-button>`,
|
||||
priority: 80,
|
||||
};
|
||||
}
|
||||
);
|
||||
40
blocksuite/affine/gfx/connector/src/view.ts
Normal file
40
blocksuite/affine/gfx/connector/src/view.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
type ViewExtensionContext,
|
||||
ViewExtensionProvider,
|
||||
} from '@blocksuite/affine-ext-loader';
|
||||
|
||||
import { ConnectionOverlay } from './connector-manager';
|
||||
import { ConnectorTool } from './connector-tool';
|
||||
import { effects } from './effects';
|
||||
import { ConnectorFilter } from './element-transform';
|
||||
import {
|
||||
ConnectorDomRendererExtension,
|
||||
ConnectorElementRendererExtension,
|
||||
} from './renderer';
|
||||
import { connectorToolbarExtension } from './toolbar/config';
|
||||
import { connectorQuickTool } from './toolbar/quick-tool';
|
||||
import { ConnectorElementView, ConnectorInteraction } from './view/view';
|
||||
|
||||
export class ConnectorViewExtension extends ViewExtensionProvider {
|
||||
override name = 'affine-connector-gfx';
|
||||
|
||||
override effect(): void {
|
||||
super.effect();
|
||||
effects();
|
||||
}
|
||||
|
||||
override setup(context: ViewExtensionContext) {
|
||||
super.setup(context);
|
||||
context.register(ConnectorElementView);
|
||||
context.register(ConnectorElementRendererExtension);
|
||||
context.register(ConnectorDomRendererExtension);
|
||||
if (this.isEdgeless(context.scope)) {
|
||||
context.register(ConnectorTool);
|
||||
context.register(ConnectorFilter);
|
||||
context.register(connectorQuickTool);
|
||||
context.register(connectorToolbarExtension);
|
||||
context.register(ConnectionOverlay);
|
||||
context.register(ConnectorInteraction);
|
||||
}
|
||||
}
|
||||
}
|
||||
1
blocksuite/affine/gfx/connector/src/view/index.ts
Normal file
1
blocksuite/affine/gfx/connector/src/view/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './view';
|
||||
248
blocksuite/affine/gfx/connector/src/view/view.ts
Normal file
248
blocksuite/affine/gfx/connector/src/view/view.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import {
|
||||
type ConnectorElementModel,
|
||||
LocalShapeElementModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { Bound, serializeXYWH, Vec } from '@blocksuite/global/gfx';
|
||||
import type { PointerEventState } from '@blocksuite/std';
|
||||
import {
|
||||
type DragEndContext,
|
||||
type DragMoveContext,
|
||||
type DragStartContext,
|
||||
generateKeyBetween,
|
||||
GfxElementModelView,
|
||||
GfxViewInteractionExtension,
|
||||
} from '@blocksuite/std/gfx';
|
||||
|
||||
import { mountConnectorLabelEditor } from '../text/edgeless-connector-label-editor';
|
||||
|
||||
export class ConnectorElementView extends GfxElementModelView<ConnectorElementModel> {
|
||||
static override type = 'connector';
|
||||
|
||||
override onDragStart = (context: DragStartContext) => {
|
||||
super.onDragStart(context);
|
||||
this.model.stash('labelXYWH');
|
||||
};
|
||||
|
||||
override onDragEnd = (context: DragEndContext) => {
|
||||
super.onDragEnd(context);
|
||||
this.model.stash('labelXYWH');
|
||||
};
|
||||
|
||||
override onDragMove = (context: DragMoveContext) => {
|
||||
const { dx, dy, currentBound } = context;
|
||||
|
||||
this.model.moveTo(currentBound.moveDelta(dx, dy));
|
||||
};
|
||||
|
||||
override onCreated(): void {
|
||||
super.onCreated();
|
||||
|
||||
this._initLabelMoving();
|
||||
}
|
||||
|
||||
private _initLabelMoving(): void {
|
||||
let curLabelElement: LocalShapeElementModel | null = null;
|
||||
|
||||
if (this.model.isLocked()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const enterLabelEditor = (evt: PointerEventState) => {
|
||||
const edgeless = this.std.view.getBlock(this.std.store.root!.id);
|
||||
|
||||
if (edgeless && !this.model.isLocked()) {
|
||||
mountConnectorLabelEditor(
|
||||
this.model,
|
||||
edgeless,
|
||||
this.gfx.viewport.toModelCoord(evt.x, evt.y)
|
||||
);
|
||||
}
|
||||
};
|
||||
const getCurrentPosition = (evt: PointerEventState) => {
|
||||
const [x, y] = this.gfx.viewport.toModelCoord(evt.x, evt.y);
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
clientX: evt.raw.clientX,
|
||||
clientY: evt.raw.clientY,
|
||||
};
|
||||
};
|
||||
const watchEvent = (labelModel: LocalShapeElementModel) => {
|
||||
const view = this.gfx.view.get(labelModel) as GfxElementModelView;
|
||||
const connectorModel = this.model;
|
||||
|
||||
let labelBound: Bound | null = null;
|
||||
let startPoint = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
};
|
||||
let lastPoint = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
};
|
||||
|
||||
view.on('dblclick', evt => {
|
||||
enterLabelEditor(evt);
|
||||
});
|
||||
view.on('dragstart', evt => {
|
||||
startPoint = getCurrentPosition(evt);
|
||||
labelBound = Bound.deserialize(labelModel.xywh);
|
||||
|
||||
connectorModel.stash('labelXYWH');
|
||||
connectorModel.stash('labelOffset');
|
||||
});
|
||||
|
||||
view.on('dragmove', evt => {
|
||||
if (!labelBound) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastPoint = getCurrentPosition(evt);
|
||||
const newBound = labelBound.clone();
|
||||
const delta = [lastPoint.x - startPoint.x, lastPoint.y - startPoint.y];
|
||||
const center = connectorModel.getNearestPoint(
|
||||
Vec.add(newBound.center, delta)
|
||||
);
|
||||
const distance = connectorModel.getOffsetDistanceByPoint(center);
|
||||
newBound.center = center;
|
||||
|
||||
connectorModel.labelXYWH = newBound.toXYWH();
|
||||
connectorModel.labelOffset = {
|
||||
distance,
|
||||
};
|
||||
});
|
||||
|
||||
view.on('dragend', () => {
|
||||
if (labelBound) {
|
||||
labelBound = null;
|
||||
connectorModel.pop('labelXYWH');
|
||||
connectorModel.pop('labelOffset');
|
||||
}
|
||||
});
|
||||
};
|
||||
const updateLabelElement = () => {
|
||||
if (!this.model.labelXYWH || !this.model.text) {
|
||||
// Clean up existing label element if conditions are no longer met
|
||||
if (curLabelElement) {
|
||||
this.surface.deleteLocalElement(curLabelElement);
|
||||
curLabelElement = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const labelElement =
|
||||
curLabelElement || new LocalShapeElementModel(this.surface);
|
||||
labelElement.xywh = serializeXYWH(...this.model.labelXYWH);
|
||||
labelElement.index = generateKeyBetween(this.model.index, null);
|
||||
|
||||
if (!curLabelElement) {
|
||||
curLabelElement = labelElement;
|
||||
|
||||
labelElement.id = `#${this.model.id}-label`;
|
||||
labelElement.creator = this.model;
|
||||
labelElement.fillColor = 'transparent';
|
||||
labelElement.strokeColor = 'transparent';
|
||||
labelElement.strokeWidth = 0;
|
||||
|
||||
this.surface.addLocalElement(labelElement);
|
||||
this.disposable.add(() => {
|
||||
this.surface.deleteLocalElement(labelElement);
|
||||
});
|
||||
watchEvent(labelElement);
|
||||
}
|
||||
};
|
||||
|
||||
this.disposable.add(
|
||||
this.model.propsUpdated.subscribe(payload => {
|
||||
if (
|
||||
payload.key === 'labelXYWH' ||
|
||||
payload.key === 'text' ||
|
||||
payload.key === 'index'
|
||||
) {
|
||||
updateLabelElement();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
updateLabelElement();
|
||||
|
||||
this.on('dblclick', evt => {
|
||||
if (!curLabelElement) {
|
||||
enterLabelEditor(evt);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const ConnectorInteraction =
|
||||
GfxViewInteractionExtension<ConnectorElementView>(ConnectorElementView.type, {
|
||||
handleResize: ({ model, gfx }) => {
|
||||
const initialPath = model.absolutePath;
|
||||
|
||||
return {
|
||||
beforeResize(context): void {
|
||||
const { elements } = context;
|
||||
// show the handles only when connector is selected along with
|
||||
// its source and target elements
|
||||
if (
|
||||
elements.length === 1 ||
|
||||
(model.source.id &&
|
||||
!elements.some(el => el.model.id === model.source.id)) ||
|
||||
(model.target.id &&
|
||||
!elements.some(el => el.model.id === model.target.id))
|
||||
) {
|
||||
context.set({
|
||||
allowedHandlers: [],
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
onResizeStart(): void {
|
||||
model.stash('labelXYWH');
|
||||
model.stash('source');
|
||||
model.stash('target');
|
||||
},
|
||||
|
||||
onResizeMove(context): void {
|
||||
const { matrix } = context;
|
||||
const props = model.resize(initialPath, matrix);
|
||||
|
||||
gfx.updateElement(model, props);
|
||||
},
|
||||
|
||||
onResizeEnd(): void {
|
||||
model.pop('labelXYWH');
|
||||
model.pop('source');
|
||||
model.pop('target');
|
||||
},
|
||||
};
|
||||
},
|
||||
handleRotate({ model, gfx }) {
|
||||
const initialPath = model.absolutePath;
|
||||
|
||||
return {
|
||||
onRotateStart(): void {
|
||||
model.stash('labelXYWH');
|
||||
model.stash('source');
|
||||
model.stash('target');
|
||||
},
|
||||
|
||||
onRotateMove(context): void {
|
||||
const { matrix } = context;
|
||||
const props = model.resize(initialPath, matrix);
|
||||
|
||||
gfx.updateElement(model, props);
|
||||
},
|
||||
|
||||
onRotateEnd(): void {
|
||||
model.pop('labelXYWH');
|
||||
model.pop('source');
|
||||
model.pop('target');
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
22
blocksuite/affine/gfx/connector/tsconfig.json
Normal file
22
blocksuite/affine/gfx/connector/tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"include": ["./src"],
|
||||
"references": [
|
||||
{ "path": "../../blocks/surface" },
|
||||
{ "path": "../../components" },
|
||||
{ "path": "../../ext-loader" },
|
||||
{ "path": "../text" },
|
||||
{ "path": "../../model" },
|
||||
{ "path": "../../rich-text" },
|
||||
{ "path": "../../shared" },
|
||||
{ "path": "../../widgets/edgeless-toolbar" },
|
||||
{ "path": "../../../framework/global" },
|
||||
{ "path": "../../../framework/std" },
|
||||
{ "path": "../../../framework/store" }
|
||||
]
|
||||
}
|
||||
43
blocksuite/affine/gfx/group/package.json
Normal file
43
blocksuite/affine/gfx/group/package.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "@blocksuite/affine-gfx-group",
|
||||
"description": "Gfx group for BlockSuite.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "yarn run -T tsc"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"keywords": [],
|
||||
"author": "toeverything",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@blocksuite/affine-block-surface": "workspace:*",
|
||||
"@blocksuite/affine-components": "workspace:*",
|
||||
"@blocksuite/affine-ext-loader": "workspace:*",
|
||||
"@blocksuite/affine-gfx-text": "workspace:*",
|
||||
"@blocksuite/affine-model": "workspace:*",
|
||||
"@blocksuite/affine-rich-text": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/affine-widget-edgeless-toolbar": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@lit/context": "^1.1.2",
|
||||
"fractional-indexing": "^3.2.0",
|
||||
"lit": "^3.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./view": "./src/view.ts",
|
||||
"./store": "./src/store.ts"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.27.5"
|
||||
}
|
||||
152
blocksuite/affine/gfx/group/src/__tests__/group-api.unit.spec.ts
Normal file
152
blocksuite/affine/gfx/group/src/__tests__/group-api.unit.spec.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
vi.mock('fractional-indexing', () => ({
|
||||
generateKeyBetween: vi.fn(),
|
||||
generateNKeysBetween: vi.fn(),
|
||||
}));
|
||||
|
||||
import { generateKeyBetween, generateNKeysBetween } from 'fractional-indexing';
|
||||
|
||||
import { ungroupCommand } from '../command/group-api.js';
|
||||
|
||||
type TestElement = {
|
||||
id: string;
|
||||
index: string;
|
||||
group: TestElement | null;
|
||||
childElements: TestElement[];
|
||||
removeChildren?: (elements: TestElement[]) => void;
|
||||
addChildren?: (elements: TestElement[]) => void;
|
||||
};
|
||||
|
||||
const mockedGenerateNKeysBetween = vi.mocked(generateNKeysBetween);
|
||||
const mockedGenerateKeyBetween = vi.mocked(generateKeyBetween);
|
||||
|
||||
const createElement = (
|
||||
id: string,
|
||||
index: string,
|
||||
group: TestElement | null
|
||||
): TestElement => ({
|
||||
id,
|
||||
index,
|
||||
group,
|
||||
childElements: [],
|
||||
});
|
||||
|
||||
const createUngroupFixture = () => {
|
||||
const parent = createElement('parent', 'p0', null);
|
||||
const left = createElement('left', 'a0', parent);
|
||||
const right = createElement('right', 'a0', parent);
|
||||
const group = createElement('group', 'm0', parent);
|
||||
const childA = createElement('child-a', 'c0', group);
|
||||
const childB = createElement('child-b', 'c1', group);
|
||||
|
||||
group.childElements = [childB, childA];
|
||||
parent.childElements = [left, group, right];
|
||||
|
||||
parent.removeChildren = vi.fn();
|
||||
parent.addChildren = vi.fn();
|
||||
group.removeChildren = vi.fn();
|
||||
|
||||
const elementOrder = new Map<TestElement, number>([
|
||||
[left, 0],
|
||||
[group, 1],
|
||||
[right, 2],
|
||||
[childA, 3],
|
||||
[childB, 4],
|
||||
]);
|
||||
|
||||
const selectionSet = vi.fn();
|
||||
const gfx = {
|
||||
layer: {
|
||||
compare: (a: TestElement, b: TestElement) =>
|
||||
(elementOrder.get(a) ?? 0) - (elementOrder.get(b) ?? 0),
|
||||
},
|
||||
selection: {
|
||||
set: selectionSet,
|
||||
},
|
||||
};
|
||||
|
||||
const std = {
|
||||
get: vi.fn(() => gfx),
|
||||
store: {
|
||||
transact: (callback: () => void) => callback(),
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
childA,
|
||||
childB,
|
||||
group,
|
||||
parent,
|
||||
selectionSet,
|
||||
std,
|
||||
};
|
||||
};
|
||||
|
||||
describe('ungroupCommand', () => {
|
||||
beforeEach(() => {
|
||||
mockedGenerateNKeysBetween.mockReset();
|
||||
mockedGenerateKeyBetween.mockReset();
|
||||
});
|
||||
|
||||
test('falls back to open-ended key generation when sibling interval is invalid', () => {
|
||||
const fixture = createUngroupFixture();
|
||||
mockedGenerateNKeysBetween
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('interval reversed');
|
||||
})
|
||||
.mockReturnValueOnce(['n0', 'n1']);
|
||||
|
||||
const next = vi.fn();
|
||||
ungroupCommand(
|
||||
{
|
||||
std: fixture.std,
|
||||
group: fixture.group as any,
|
||||
} as any,
|
||||
next
|
||||
);
|
||||
|
||||
expect(mockedGenerateNKeysBetween).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'a0',
|
||||
'a0',
|
||||
2
|
||||
);
|
||||
expect(mockedGenerateNKeysBetween).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'a0',
|
||||
null,
|
||||
2
|
||||
);
|
||||
expect(fixture.childA.index).toBe('n0');
|
||||
expect(fixture.childB.index).toBe('n1');
|
||||
expect(fixture.selectionSet).toHaveBeenCalledWith({
|
||||
editing: false,
|
||||
elements: ['child-a', 'child-b'],
|
||||
});
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('falls back to key-by-key generation when all batched strategies fail', () => {
|
||||
const fixture = createUngroupFixture();
|
||||
mockedGenerateNKeysBetween.mockImplementation(() => {
|
||||
throw new Error('invalid range');
|
||||
});
|
||||
|
||||
let seq = 0;
|
||||
mockedGenerateKeyBetween.mockImplementation(() => `k${seq++}`);
|
||||
|
||||
ungroupCommand(
|
||||
{
|
||||
std: fixture.std,
|
||||
group: fixture.group as any,
|
||||
} as any,
|
||||
vi.fn()
|
||||
);
|
||||
|
||||
expect(mockedGenerateNKeysBetween).toHaveBeenCalledTimes(4);
|
||||
expect(mockedGenerateKeyBetween).toHaveBeenCalledTimes(2);
|
||||
expect(fixture.childA.index).toBe('k0');
|
||||
expect(fixture.childB.index).toBe('k1');
|
||||
});
|
||||
});
|
||||
2
blocksuite/affine/gfx/group/src/adapter/index.ts
Normal file
2
blocksuite/affine/gfx/group/src/adapter/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './markdown';
|
||||
export * from './plain-text';
|
||||
25
blocksuite/affine/gfx/group/src/adapter/markdown/index.ts
Normal file
25
blocksuite/affine/gfx/group/src/adapter/markdown/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { ElementToMarkdownAdapterExtension } from '@blocksuite/affine-block-surface';
|
||||
|
||||
import { getGroupTitle } from '../text';
|
||||
|
||||
export const groupToMarkdownAdapterMatcher = ElementToMarkdownAdapterExtension({
|
||||
name: 'group',
|
||||
match: elementModel => elementModel.type === 'group',
|
||||
toAST: elementModel => {
|
||||
const title = getGroupTitle(elementModel);
|
||||
if (!title) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = `Group, with title "${title}"`;
|
||||
return {
|
||||
type: 'paragraph',
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
value: content,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
14
blocksuite/affine/gfx/group/src/adapter/plain-text/index.ts
Normal file
14
blocksuite/affine/gfx/group/src/adapter/plain-text/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { ElementToPlainTextAdapterExtension } from '@blocksuite/affine-block-surface';
|
||||
|
||||
import { getGroupTitle } from '../text';
|
||||
|
||||
export const groupToPlainTextAdapterMatcher =
|
||||
ElementToPlainTextAdapterExtension({
|
||||
name: 'group',
|
||||
match: elementModel => elementModel.type === 'group',
|
||||
toAST: elementModel => {
|
||||
const title = getGroupTitle(elementModel);
|
||||
const content = `Group, with title "${title}"`;
|
||||
return { content };
|
||||
},
|
||||
});
|
||||
21
blocksuite/affine/gfx/group/src/adapter/text.ts
Normal file
21
blocksuite/affine/gfx/group/src/adapter/text.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { DeltaInsert } from '@blocksuite/affine/store';
|
||||
|
||||
export function getGroupTitle(elementModel: Record<string, unknown>): string {
|
||||
let title = '';
|
||||
if (elementModel.type !== 'group') {
|
||||
return title;
|
||||
}
|
||||
|
||||
if (
|
||||
'title' in elementModel &&
|
||||
typeof elementModel.title === 'object' &&
|
||||
elementModel.title
|
||||
) {
|
||||
let delta: DeltaInsert[] = [];
|
||||
if ('delta' in elementModel.title) {
|
||||
delta = elementModel.title.delta as DeltaInsert[];
|
||||
}
|
||||
title = delta.map(d => d.insert).join('');
|
||||
}
|
||||
return title;
|
||||
}
|
||||
229
blocksuite/affine/gfx/group/src/command/group-api.ts
Normal file
229
blocksuite/affine/gfx/group/src/command/group-api.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import { EdgelessCRUDIdentifier } from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
type GroupElementModel,
|
||||
MindmapElementModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import type { Command } from '@blocksuite/std';
|
||||
import {
|
||||
batchAddChildren,
|
||||
batchRemoveChildren,
|
||||
type GfxController,
|
||||
GfxControllerIdentifier,
|
||||
type GfxModel,
|
||||
measureOperation,
|
||||
} from '@blocksuite/std/gfx';
|
||||
import { generateKeyBetween, generateNKeysBetween } from 'fractional-indexing';
|
||||
|
||||
const getTopLevelOrderedElements = (gfx: GfxController) => {
|
||||
const topLevelElements = gfx.layer.layers.reduce<GfxModel[]>(
|
||||
(elements, layer) => {
|
||||
layer.elements.forEach(element => {
|
||||
if (element.group === null) {
|
||||
elements.push(element as GfxModel);
|
||||
}
|
||||
});
|
||||
|
||||
return elements;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
topLevelElements.sort((a, b) => gfx.layer.compare(a, b));
|
||||
return topLevelElements;
|
||||
};
|
||||
|
||||
const buildUngroupIndexes = (
|
||||
orderedElements: GfxModel[],
|
||||
afterIndex: string | null,
|
||||
beforeIndex: string | null,
|
||||
fallbackAnchorIndex: string
|
||||
) => {
|
||||
if (orderedElements.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const count = orderedElements.length;
|
||||
const tryGenerateN = (left: string | null, right: string | null) => {
|
||||
try {
|
||||
const generated = generateNKeysBetween(left, right, count);
|
||||
return generated.length === count ? generated : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const tryGenerateOneByOne = (left: string | null, right: string | null) => {
|
||||
try {
|
||||
let cursor = left;
|
||||
return orderedElements.map(() => {
|
||||
cursor = generateKeyBetween(cursor, right);
|
||||
return cursor;
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Preferred: keep ungrouped children in the original group slot.
|
||||
return (
|
||||
tryGenerateN(afterIndex, beforeIndex) ??
|
||||
// Fallback: ignore the upper bound when legacy/broken data has reversed interval.
|
||||
tryGenerateN(afterIndex, null) ??
|
||||
// Fallback: use group index as anchor when sibling interval is unavailable.
|
||||
tryGenerateN(fallbackAnchorIndex, null) ??
|
||||
// Last resort: always valid.
|
||||
tryGenerateN(null, null) ??
|
||||
// Defensive fallback for unexpected library behavior.
|
||||
tryGenerateOneByOne(null, null) ??
|
||||
[]
|
||||
);
|
||||
};
|
||||
|
||||
export const createGroupCommand: Command<
|
||||
{ elements: GfxModel[] | string[] },
|
||||
{ groupId: string }
|
||||
> = (ctx, next) => {
|
||||
const { std, elements } = ctx;
|
||||
const gfx = std.get(GfxControllerIdentifier);
|
||||
const crud = std.get(EdgelessCRUDIdentifier);
|
||||
|
||||
const groups = gfx.layer.canvasElements.filter(
|
||||
el => el.type === 'group'
|
||||
) as GroupElementModel[];
|
||||
const groupId = crud.addElement('group', {
|
||||
children: elements.reduce(
|
||||
(pre, el) => {
|
||||
const id = typeof el === 'string' ? el : el.id;
|
||||
pre[id] = true;
|
||||
return pre;
|
||||
},
|
||||
{} as Record<string, true>
|
||||
),
|
||||
title: `Group ${groups.length + 1}`,
|
||||
});
|
||||
if (!groupId) {
|
||||
return;
|
||||
}
|
||||
|
||||
next({ groupId });
|
||||
};
|
||||
|
||||
export const createGroupFromSelectedCommand: Command<
|
||||
{},
|
||||
{ groupId: string }
|
||||
> = (ctx, next) => {
|
||||
measureOperation('edgeless:create-group-from-selected', () => {
|
||||
const { std } = ctx;
|
||||
const gfx = std.get(GfxControllerIdentifier);
|
||||
const { selection, surface } = gfx;
|
||||
|
||||
if (!surface) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
selection.selectedElements.length === 0 ||
|
||||
!selection.selectedElements.every(
|
||||
element =>
|
||||
element.group === selection.firstElement.group &&
|
||||
!(element.group instanceof MindmapElementModel)
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = selection.firstElement.group;
|
||||
let groupId: string | undefined;
|
||||
std.store.transact(() => {
|
||||
const [_, result] = std.command.exec(createGroupCommand, {
|
||||
elements: selection.selectedElements,
|
||||
});
|
||||
|
||||
if (!result.groupId) {
|
||||
return;
|
||||
}
|
||||
|
||||
groupId = result.groupId;
|
||||
const group = surface.getElementById(groupId);
|
||||
|
||||
if (parent !== null && group) {
|
||||
batchRemoveChildren(parent, selection.selectedElements);
|
||||
batchAddChildren(parent, [group]);
|
||||
}
|
||||
});
|
||||
|
||||
if (!groupId) {
|
||||
return;
|
||||
}
|
||||
|
||||
selection.set({
|
||||
editing: false,
|
||||
elements: [groupId],
|
||||
});
|
||||
|
||||
next({ groupId });
|
||||
});
|
||||
};
|
||||
|
||||
export const ungroupCommand: Command<{ group: GroupElementModel }, {}> = (
|
||||
ctx,
|
||||
next
|
||||
) => {
|
||||
measureOperation('edgeless:ungroup', () => {
|
||||
const { std, group } = ctx;
|
||||
const gfx = std.get(GfxControllerIdentifier);
|
||||
const { selection } = gfx;
|
||||
const parent = group.group;
|
||||
const elements = [...group.childElements];
|
||||
|
||||
if (group instanceof MindmapElementModel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const orderedElements = [...elements].sort((a, b) =>
|
||||
gfx.layer.compare(a, b)
|
||||
);
|
||||
const siblings = parent
|
||||
? [...parent.childElements].sort((a, b) => gfx.layer.compare(a, b))
|
||||
: getTopLevelOrderedElements(gfx);
|
||||
const groupPosition = siblings.indexOf(group);
|
||||
const beforeSiblingIndex =
|
||||
groupPosition > 0 ? (siblings[groupPosition - 1]?.index ?? null) : null;
|
||||
const afterSiblingIndex =
|
||||
groupPosition === -1
|
||||
? null
|
||||
: (siblings[groupPosition + 1]?.index ?? null);
|
||||
const nextIndexes = buildUngroupIndexes(
|
||||
orderedElements,
|
||||
beforeSiblingIndex,
|
||||
afterSiblingIndex,
|
||||
group.index
|
||||
);
|
||||
|
||||
std.store.transact(() => {
|
||||
if (parent !== null) {
|
||||
batchRemoveChildren(parent, [group]);
|
||||
}
|
||||
|
||||
batchRemoveChildren(group, elements);
|
||||
|
||||
// keep relative index order of group children after ungroup
|
||||
orderedElements.forEach((element, idx) => {
|
||||
const index = nextIndexes[idx];
|
||||
if (element.index !== index) {
|
||||
element.index = index;
|
||||
}
|
||||
});
|
||||
|
||||
if (parent !== null) {
|
||||
batchAddChildren(parent, orderedElements);
|
||||
}
|
||||
});
|
||||
|
||||
selection.set({
|
||||
editing: false,
|
||||
elements: orderedElements.map(ele => ele.id),
|
||||
});
|
||||
next();
|
||||
});
|
||||
};
|
||||
1
blocksuite/affine/gfx/group/src/command/index.ts
Normal file
1
blocksuite/affine/gfx/group/src/command/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './group-api';
|
||||
14
blocksuite/affine/gfx/group/src/effects.ts
Normal file
14
blocksuite/affine/gfx/group/src/effects.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { EdgelessGroupTitleEditor } from './text/edgeless-group-title-editor';
|
||||
|
||||
export function effects() {
|
||||
customElements.define(
|
||||
'edgeless-group-title-editor',
|
||||
EdgelessGroupTitleEditor
|
||||
);
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-group-title-editor': EdgelessGroupTitleEditor;
|
||||
}
|
||||
}
|
||||
57
blocksuite/affine/gfx/group/src/element-view.ts
Normal file
57
blocksuite/affine/gfx/group/src/element-view.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { GroupElementModel } from '@blocksuite/affine-model';
|
||||
import {
|
||||
GfxElementModelView,
|
||||
GfxViewInteractionExtension,
|
||||
} from '@blocksuite/std/gfx';
|
||||
|
||||
import { mountGroupTitleEditor } from './text/edgeless-group-title-editor';
|
||||
|
||||
export class GroupElementView extends GfxElementModelView<GroupElementModel> {
|
||||
static override type: string = 'group';
|
||||
|
||||
override onCreated(): void {
|
||||
super.onCreated();
|
||||
|
||||
this._initDblClickToEdit();
|
||||
}
|
||||
|
||||
private _initDblClickToEdit(): void {
|
||||
this.on('dblclick', () => {
|
||||
const rootId = this.std.store.root?.id;
|
||||
if (!rootId) {
|
||||
console.error(
|
||||
'GroupElementView: rootId is not found when dblclick to edit'
|
||||
);
|
||||
return;
|
||||
}
|
||||
const edgeless = this.std.view.getBlock(rootId);
|
||||
|
||||
if (edgeless && !this.model.isLocked()) {
|
||||
mountGroupTitleEditor(this.model, edgeless);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const GroupInteraction = GfxViewInteractionExtension<GroupElementView>(
|
||||
GroupElementView.type,
|
||||
{
|
||||
handleResize(context) {
|
||||
const empty = () => {};
|
||||
context.model.descendantElements.forEach(elm => {
|
||||
if (elm instanceof GroupElementModel) {
|
||||
return;
|
||||
}
|
||||
|
||||
context.add(elm);
|
||||
});
|
||||
context.delete(context.model);
|
||||
|
||||
return {
|
||||
onResizeStart: empty,
|
||||
onResizeMove: empty,
|
||||
onResizeEnd: empty,
|
||||
};
|
||||
},
|
||||
}
|
||||
);
|
||||
36
blocksuite/affine/gfx/group/src/group-watcher.ts
Normal file
36
blocksuite/affine/gfx/group/src/group-watcher.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
type SurfaceBlockModel,
|
||||
SurfaceGroupLikeModel,
|
||||
type SurfaceMiddleware,
|
||||
surfaceMiddlewareExtension,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
|
||||
const groupRelationWatcher: SurfaceMiddleware = (
|
||||
surface: SurfaceBlockModel
|
||||
) => {
|
||||
const disposables = [
|
||||
surface.elementUpdated.subscribe(({ id, props, local }) => {
|
||||
if (!local) return;
|
||||
|
||||
const element = surface.getElementById(id)!;
|
||||
|
||||
// remove the group if it has no children
|
||||
if (
|
||||
element instanceof SurfaceGroupLikeModel &&
|
||||
props['childIds'] &&
|
||||
element.childIds.length === 0
|
||||
) {
|
||||
surface.deleteElement(id);
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
return () => {
|
||||
disposables.forEach(d => d.unsubscribe());
|
||||
};
|
||||
};
|
||||
|
||||
export const groupRelationWatcherExtension = surfaceMiddlewareExtension(
|
||||
'group-relation-watcher',
|
||||
groupRelationWatcher
|
||||
);
|
||||
6
blocksuite/affine/gfx/group/src/index.ts
Normal file
6
blocksuite/affine/gfx/group/src/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from './adapter';
|
||||
export * from './command';
|
||||
export * from './element-view';
|
||||
export * from './renderer';
|
||||
export * from './text/text';
|
||||
export * from './toolbar/config';
|
||||
45
blocksuite/affine/gfx/group/src/interaction-ext.ts
Normal file
45
blocksuite/affine/gfx/group/src/interaction-ext.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { GroupElementModel } from '@blocksuite/affine-model';
|
||||
import { InteractivityExtension } from '@blocksuite/std/gfx';
|
||||
|
||||
export class GroupInteractionExtension extends InteractivityExtension {
|
||||
static override key = 'group-selection';
|
||||
|
||||
override mounted(): void {
|
||||
this.action.onElementSelect(context => {
|
||||
const { candidates, suggest } = context;
|
||||
const { activeGroup } = this.gfx.selection;
|
||||
let target = context.target;
|
||||
|
||||
if (activeGroup && activeGroup.hasDescendant(target)) {
|
||||
const groups = target.groups;
|
||||
const activeGroupIdx = groups.indexOf(activeGroup);
|
||||
|
||||
if (activeGroupIdx !== -1) {
|
||||
target =
|
||||
groups
|
||||
.slice(0, activeGroupIdx)
|
||||
.findLast(
|
||||
group =>
|
||||
group instanceof GroupElementModel &&
|
||||
candidates.includes(group)
|
||||
) ?? target;
|
||||
}
|
||||
} else {
|
||||
const groups = target.groups;
|
||||
|
||||
target =
|
||||
groups.findLast(group => {
|
||||
return (
|
||||
group instanceof GroupElementModel && candidates.includes(group)
|
||||
);
|
||||
}) ?? target;
|
||||
}
|
||||
|
||||
if (target !== context.target) {
|
||||
suggest({
|
||||
id: target.id,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
6
blocksuite/affine/gfx/group/src/renderer/consts.ts
Normal file
6
blocksuite/affine/gfx/group/src/renderer/consts.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { FontFamily } from '@blocksuite/affine-model';
|
||||
|
||||
export const GROUP_TITLE_FONT = FontFamily.Inter;
|
||||
export const GROUP_TITLE_FONT_SIZE = 12;
|
||||
export const GROUP_TITLE_PADDING = [2, 0];
|
||||
export const GROUP_TITLE_OFFSET = 4;
|
||||
62
blocksuite/affine/gfx/group/src/renderer/dom-renderer.ts
Normal file
62
blocksuite/affine/gfx/group/src/renderer/dom-renderer.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { DomElementRendererExtension } from '@blocksuite/affine-block-surface';
|
||||
import { FontWeight, type GroupElementModel } from '@blocksuite/affine-model';
|
||||
|
||||
import {
|
||||
GROUP_TITLE_FONT,
|
||||
GROUP_TITLE_FONT_SIZE,
|
||||
GROUP_TITLE_PADDING,
|
||||
} from './consts';
|
||||
import { titleRenderParams } from './utils';
|
||||
|
||||
export const GroupDomRendererExtension = DomElementRendererExtension(
|
||||
'group',
|
||||
(model: GroupElementModel, domElement, renderer) => {
|
||||
const { zoom } = renderer.viewport;
|
||||
const [, , w, h] = model.deserializedXYWH;
|
||||
|
||||
const renderParams = titleRenderParams(model, zoom);
|
||||
model.externalXYWH = renderParams.titleBound.serialize();
|
||||
|
||||
domElement.innerHTML = '';
|
||||
domElement.style.outlineColor = '';
|
||||
domElement.style.outlineWidth = '';
|
||||
domElement.style.outlineStyle = '';
|
||||
|
||||
const elements = renderer.provider.selectedElements?.() || [];
|
||||
|
||||
const renderTitle = () => {
|
||||
const { text } = renderParams;
|
||||
const titleElement = document.createElement('div');
|
||||
titleElement.style.transform = `translate(0, -100%)`;
|
||||
titleElement.style.fontFamily = GROUP_TITLE_FONT;
|
||||
titleElement.style.fontWeight = `${FontWeight.Regular}`;
|
||||
titleElement.style.fontStyle = 'normal';
|
||||
titleElement.style.fontSize = `${GROUP_TITLE_FONT_SIZE}px`;
|
||||
titleElement.style.color = renderer.getPropertyValue('--affine-blue');
|
||||
titleElement.style.textAlign = 'left';
|
||||
titleElement.style.padding = `${GROUP_TITLE_PADDING[0]}px ${GROUP_TITLE_PADDING[1]}px`;
|
||||
titleElement.textContent = text;
|
||||
domElement.replaceChildren(titleElement);
|
||||
};
|
||||
|
||||
if (elements.includes(model.id)) {
|
||||
if (model.showTitle) {
|
||||
renderTitle();
|
||||
} else {
|
||||
domElement.style.outlineColor =
|
||||
renderer.getPropertyValue('--affine-blue');
|
||||
domElement.style.outlineWidth = '2px';
|
||||
domElement.style.outlineStyle = 'solid';
|
||||
}
|
||||
} else if (model.childElements.some(child => elements.includes(child.id))) {
|
||||
domElement.style.outlineColor = '#8FD1FF';
|
||||
domElement.style.outlineWidth = '2px';
|
||||
domElement.style.outlineStyle = 'solid';
|
||||
}
|
||||
|
||||
domElement.style.width = `${w * zoom}px`;
|
||||
domElement.style.height = `${h * zoom}px`;
|
||||
domElement.style.overflow = 'visible';
|
||||
domElement.style.pointerEvents = 'none';
|
||||
}
|
||||
);
|
||||
67
blocksuite/affine/gfx/group/src/renderer/element-renderer.ts
Normal file
67
blocksuite/affine/gfx/group/src/renderer/element-renderer.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
type CanvasRenderer,
|
||||
type ElementRenderer,
|
||||
ElementRendererExtension,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import type { GroupElementModel } from '@blocksuite/affine-model';
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
|
||||
import { titleRenderParams } from './utils';
|
||||
|
||||
export const group: ElementRenderer<GroupElementModel> = (
|
||||
model,
|
||||
ctx,
|
||||
matrix,
|
||||
renderer
|
||||
) => {
|
||||
const { xywh } = model;
|
||||
const bound = Bound.deserialize(xywh);
|
||||
const elements = renderer.provider.selectedElements?.() || [];
|
||||
|
||||
const renderParams = titleRenderParams(model, renderer.viewport.zoom);
|
||||
model.externalXYWH = renderParams.titleBound.serialize();
|
||||
|
||||
ctx.setTransform(matrix);
|
||||
|
||||
if (elements.includes(model.id)) {
|
||||
if (model.showTitle) {
|
||||
renderTitle(model, ctx, renderer, renderParams);
|
||||
} else {
|
||||
ctx.lineWidth = 2 / renderer.viewport.zoom;
|
||||
ctx.strokeStyle = renderer.getPropertyValue('--affine-blue');
|
||||
ctx.strokeRect(0, 0, bound.w, bound.h);
|
||||
}
|
||||
} else if (model.childElements.some(child => elements.includes(child.id))) {
|
||||
ctx.lineWidth = 2 / renderer.viewport.zoom;
|
||||
ctx.strokeStyle = '#8FD1FF';
|
||||
ctx.strokeRect(0, 0, bound.w, bound.h);
|
||||
|
||||
if (model.showTitle) renderTitle(model, ctx, renderer, renderParams);
|
||||
}
|
||||
};
|
||||
|
||||
export const GroupElementRendererExtension = ElementRendererExtension(
|
||||
'group',
|
||||
group
|
||||
);
|
||||
|
||||
function renderTitle(
|
||||
model: GroupElementModel,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
renderer: CanvasRenderer,
|
||||
renderParams: ReturnType<typeof titleRenderParams>
|
||||
) {
|
||||
const { text, lineHeight, font, padding, offset, titleBound } = renderParams;
|
||||
|
||||
model.externalXYWH = titleBound.serialize();
|
||||
|
||||
ctx.translate(0, -offset);
|
||||
|
||||
ctx.beginPath();
|
||||
|
||||
ctx.font = font;
|
||||
ctx.fillStyle = renderer.getPropertyValue('--affine-blue');
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(text, padding[0], -lineHeight / 2 - padding[1]);
|
||||
}
|
||||
2
blocksuite/affine/gfx/group/src/renderer/index.ts
Normal file
2
blocksuite/affine/gfx/group/src/renderer/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './dom-renderer';
|
||||
export * from './element-renderer';
|
||||
77
blocksuite/affine/gfx/group/src/renderer/utils.ts
Normal file
77
blocksuite/affine/gfx/group/src/renderer/utils.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
getFontString,
|
||||
getLineHeight,
|
||||
getLineWidth,
|
||||
truncateTextByWidth,
|
||||
} from '@blocksuite/affine-gfx-text';
|
||||
import type { GroupElementModel } from '@blocksuite/affine-model';
|
||||
import { FontWeight } from '@blocksuite/affine-model';
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
|
||||
import {
|
||||
GROUP_TITLE_FONT,
|
||||
GROUP_TITLE_FONT_SIZE,
|
||||
GROUP_TITLE_OFFSET,
|
||||
GROUP_TITLE_PADDING,
|
||||
} from './consts';
|
||||
|
||||
export function titleRenderParams(group: GroupElementModel, zoom: number) {
|
||||
let text = group.title.toString().trim();
|
||||
const font = getGroupTitleFont(zoom);
|
||||
const lineWidth = getLineWidth(text, font);
|
||||
const lineHeight = getLineHeight(
|
||||
GROUP_TITLE_FONT,
|
||||
GROUP_TITLE_FONT_SIZE / zoom,
|
||||
'normal'
|
||||
);
|
||||
const bound = group.elementBound;
|
||||
const padding = [
|
||||
GROUP_TITLE_PADDING[0] / zoom,
|
||||
GROUP_TITLE_PADDING[1] / zoom,
|
||||
];
|
||||
const offset = GROUP_TITLE_OFFSET / zoom;
|
||||
|
||||
let titleWidth = lineWidth + padding[0] * 2;
|
||||
const titleHeight = lineHeight + padding[1] * 2;
|
||||
|
||||
if (titleWidth > bound.w) {
|
||||
text = truncateTextByWidth(text, font, bound.w - 10);
|
||||
text = text.slice(0, text.length - 1) + '..';
|
||||
titleWidth = bound.w;
|
||||
}
|
||||
|
||||
return {
|
||||
font,
|
||||
bound,
|
||||
text,
|
||||
titleWidth,
|
||||
titleHeight,
|
||||
offset,
|
||||
lineHeight,
|
||||
padding,
|
||||
titleBound: new Bound(
|
||||
bound.x,
|
||||
bound.y - titleHeight - offset,
|
||||
titleWidth,
|
||||
titleHeight
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function titleBound(group: GroupElementModel, zoom: number) {
|
||||
const { titleWidth, titleHeight, bound } = titleRenderParams(group, zoom);
|
||||
|
||||
return new Bound(bound.x, bound.y - titleHeight, titleWidth, titleHeight);
|
||||
}
|
||||
|
||||
function getGroupTitleFont(zoom: number) {
|
||||
const fontSize = GROUP_TITLE_FONT_SIZE / zoom;
|
||||
const font = getFontString({
|
||||
fontSize,
|
||||
fontFamily: GROUP_TITLE_FONT,
|
||||
fontWeight: FontWeight.Regular,
|
||||
fontStyle: 'normal',
|
||||
});
|
||||
|
||||
return font;
|
||||
}
|
||||
21
blocksuite/affine/gfx/group/src/store.ts
Normal file
21
blocksuite/affine/gfx/group/src/store.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import {
|
||||
type StoreExtensionContext,
|
||||
StoreExtensionProvider,
|
||||
} from '@blocksuite/affine-ext-loader';
|
||||
|
||||
import {
|
||||
groupToMarkdownAdapterMatcher,
|
||||
groupToPlainTextAdapterMatcher,
|
||||
} from './adapter';
|
||||
import { groupRelationWatcherExtension } from './group-watcher';
|
||||
|
||||
export class GroupStoreExtension extends StoreExtensionProvider {
|
||||
override name = 'affine-group-gfx';
|
||||
|
||||
override setup(context: StoreExtensionContext) {
|
||||
super.setup(context);
|
||||
context.register(groupToPlainTextAdapterMatcher);
|
||||
context.register(groupToMarkdownAdapterMatcher);
|
||||
context.register(groupRelationWatcherExtension);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { DefaultTool } from '@blocksuite/affine-block-surface';
|
||||
import type { GroupElementModel } from '@blocksuite/affine-model';
|
||||
import type { RichText } from '@blocksuite/affine-rich-text';
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import {
|
||||
type BlockComponent,
|
||||
type BlockStdScope,
|
||||
ShadowlessElement,
|
||||
stdContext,
|
||||
} from '@blocksuite/std';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
|
||||
import { RANGE_SYNC_EXCLUDE_ATTR } from '@blocksuite/std/inline';
|
||||
import { consume } from '@lit/context';
|
||||
import { html, nothing } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import {
|
||||
GROUP_TITLE_FONT_SIZE,
|
||||
GROUP_TITLE_OFFSET,
|
||||
GROUP_TITLE_PADDING,
|
||||
} from '../renderer/consts';
|
||||
|
||||
export function mountGroupTitleEditor(
|
||||
group: GroupElementModel,
|
||||
edgeless: BlockComponent
|
||||
) {
|
||||
const mountElm = edgeless.querySelector('.edgeless-mount-point');
|
||||
if (!mountElm) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.ValueNotExists,
|
||||
"edgeless block's mount point does not exist"
|
||||
);
|
||||
}
|
||||
|
||||
const gfx = edgeless.std.get(GfxControllerIdentifier);
|
||||
|
||||
gfx.tool.setTool(DefaultTool);
|
||||
gfx.selection.set({
|
||||
elements: [group.id],
|
||||
editing: true,
|
||||
});
|
||||
|
||||
const groupEditor = new EdgelessGroupTitleEditor();
|
||||
groupEditor.group = group;
|
||||
|
||||
mountElm.append(groupEditor);
|
||||
}
|
||||
|
||||
export class EdgelessGroupTitleEditor extends WithDisposable(
|
||||
ShadowlessElement
|
||||
) {
|
||||
get inlineEditor() {
|
||||
return this.richText.inlineEditor;
|
||||
}
|
||||
|
||||
get inlineEditorContainer() {
|
||||
return this.inlineEditor?.rootElement;
|
||||
}
|
||||
|
||||
get gfx() {
|
||||
return this.std.get(GfxControllerIdentifier);
|
||||
}
|
||||
|
||||
get selection() {
|
||||
return this.gfx.selection;
|
||||
}
|
||||
|
||||
private _unmount() {
|
||||
// dispose in advance to avoid execute `this.remove()` twice
|
||||
this.disposables.dispose();
|
||||
this.group.showTitle = true;
|
||||
this.selection.set({
|
||||
elements: [this.group.id],
|
||||
editing: false,
|
||||
});
|
||||
this.remove();
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true');
|
||||
}
|
||||
|
||||
override firstUpdated(): void {
|
||||
const dispatcher = this.std.event;
|
||||
|
||||
this.updateComplete
|
||||
.then(() => {
|
||||
if (!this.inlineEditor) return;
|
||||
this.inlineEditor.selectAll();
|
||||
|
||||
this.group.showTitle = false;
|
||||
|
||||
this.inlineEditor.slots.renderComplete.subscribe(() => {
|
||||
this.requestUpdate();
|
||||
});
|
||||
|
||||
this.disposables.add(
|
||||
dispatcher.add('keyDown', ctx => {
|
||||
const state = ctx.get('keyboardState');
|
||||
if (state.raw.key === 'Enter' && !state.raw.isComposing) {
|
||||
this._unmount();
|
||||
return true;
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
this.requestUpdate();
|
||||
});
|
||||
return false;
|
||||
})
|
||||
);
|
||||
this.disposables.add(
|
||||
this.gfx.viewport.viewportUpdated.subscribe(() => {
|
||||
this.requestUpdate();
|
||||
})
|
||||
);
|
||||
|
||||
this.disposables.add(dispatcher.add('click', () => true));
|
||||
this.disposables.add(dispatcher.add('doubleClick', () => true));
|
||||
|
||||
if (!this.inlineEditorContainer) return;
|
||||
this.disposables.addFromEvent(
|
||||
this.inlineEditorContainer,
|
||||
'blur',
|
||||
() => {
|
||||
this._unmount();
|
||||
}
|
||||
);
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
override async getUpdateComplete(): Promise<boolean> {
|
||||
const result = await super.getUpdateComplete();
|
||||
await this.richText?.updateComplete;
|
||||
return result;
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (!this.group.externalXYWH) {
|
||||
console.error('group.externalXYWH is not set');
|
||||
return nothing;
|
||||
}
|
||||
const viewport = this.gfx.viewport;
|
||||
const bound = Bound.deserialize(this.group.externalXYWH);
|
||||
const [x, y] = viewport.toViewCoord(bound.x, bound.y);
|
||||
|
||||
const inlineEditorStyle = styleMap({
|
||||
transformOrigin: 'top left',
|
||||
borderRadius: '2px',
|
||||
width: 'fit-content',
|
||||
maxHeight: '30px',
|
||||
height: 'fit-content',
|
||||
padding: `${GROUP_TITLE_PADDING[1]}px ${GROUP_TITLE_PADDING[0]}px`,
|
||||
fontSize: GROUP_TITLE_FONT_SIZE + 'px',
|
||||
position: 'absolute',
|
||||
left: x + 'px',
|
||||
top: `${y - GROUP_TITLE_OFFSET + 2}px`,
|
||||
minWidth: '8px',
|
||||
fontFamily: 'var(--affine-font-family)',
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
background: 'var(--affine-white-10)',
|
||||
outline: 'none',
|
||||
zIndex: '1',
|
||||
border: `1px solid
|
||||
var(--affine-primary-color)`,
|
||||
boxShadow: 'var(--affine-active-shadow)',
|
||||
});
|
||||
return html`<rich-text
|
||||
.yText=${this.group.title}
|
||||
.enableFormat=${false}
|
||||
.enableAutoScrollHorizontally=${false}
|
||||
style=${inlineEditorStyle}
|
||||
></rich-text>`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor group!: GroupElementModel;
|
||||
|
||||
@consume({
|
||||
context: stdContext,
|
||||
})
|
||||
accessor std!: BlockStdScope;
|
||||
|
||||
@query('rich-text')
|
||||
accessor richText!: RichText;
|
||||
}
|
||||
33
blocksuite/affine/gfx/group/src/text/text.ts
Normal file
33
blocksuite/affine/gfx/group/src/text/text.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { DefaultTool } from '@blocksuite/affine-block-surface';
|
||||
import type { GroupElementModel } from '@blocksuite/affine-model';
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import type { BlockComponent } from '@blocksuite/std';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
|
||||
|
||||
import { EdgelessGroupTitleEditor } from './edgeless-group-title-editor';
|
||||
|
||||
export function mountGroupTitleEditor(
|
||||
group: GroupElementModel,
|
||||
edgeless: BlockComponent
|
||||
) {
|
||||
const mountElm = edgeless.querySelector('.edgeless-mount-point');
|
||||
if (!mountElm) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.ValueNotExists,
|
||||
"edgeless block's mount point does not exist"
|
||||
);
|
||||
}
|
||||
|
||||
const gfx = edgeless.std.get(GfxControllerIdentifier);
|
||||
|
||||
gfx.tool.setTool(DefaultTool);
|
||||
gfx.selection.set({
|
||||
elements: [group.id],
|
||||
editing: true,
|
||||
});
|
||||
|
||||
const groupEditor = new EdgelessGroupTitleEditor();
|
||||
groupEditor.group = group;
|
||||
|
||||
mountElm.append(groupEditor);
|
||||
}
|
||||
106
blocksuite/affine/gfx/group/src/toolbar/config.ts
Normal file
106
blocksuite/affine/gfx/group/src/toolbar/config.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { toast } from '@blocksuite/affine-components/toast';
|
||||
import {
|
||||
DEFAULT_NOTE_HEIGHT,
|
||||
GroupElementModel,
|
||||
NoteBlockModel,
|
||||
NoteBlockSchema,
|
||||
NoteDisplayMode,
|
||||
SurfaceRefBlockSchema,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
type ToolbarModuleConfig,
|
||||
ToolbarModuleExtension,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { matchModels } from '@blocksuite/affine-shared/utils';
|
||||
import { getRootBlock } from '@blocksuite/affine-widget-edgeless-toolbar';
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
import {
|
||||
EditIcon,
|
||||
InsertIntoPageIcon,
|
||||
UngroupIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import { BlockFlavourIdentifier } from '@blocksuite/std';
|
||||
|
||||
import { ungroupCommand } from '../command';
|
||||
import { mountGroupTitleEditor } from '../text/text';
|
||||
|
||||
export const groupToolbarConfig = {
|
||||
actions: [
|
||||
{
|
||||
id: 'a.insert-into-page',
|
||||
label: 'Insert into Page',
|
||||
tooltip: 'Insert into Page',
|
||||
icon: InsertIntoPageIcon(),
|
||||
when: ctx => ctx.getSurfaceModelsByType(GroupElementModel).length === 1,
|
||||
run(ctx) {
|
||||
const model = ctx.getCurrentModelByType(GroupElementModel);
|
||||
if (!model) return;
|
||||
|
||||
const rootModel = ctx.store.root;
|
||||
if (!rootModel) return;
|
||||
|
||||
const { id: groupId, xywh } = model;
|
||||
let lastNoteId = rootModel.children.findLast(
|
||||
note =>
|
||||
matchModels(note, [NoteBlockModel]) &&
|
||||
note.props.displayMode !== NoteDisplayMode.EdgelessOnly
|
||||
)?.id;
|
||||
|
||||
if (!lastNoteId) {
|
||||
const bounds = Bound.deserialize(xywh);
|
||||
bounds.y += bounds.h;
|
||||
bounds.h = DEFAULT_NOTE_HEIGHT;
|
||||
|
||||
lastNoteId = ctx.store.addBlock(
|
||||
NoteBlockSchema.model.flavour,
|
||||
{ xywh: bounds.serialize() },
|
||||
rootModel.id
|
||||
);
|
||||
}
|
||||
|
||||
ctx.store.addBlock(
|
||||
SurfaceRefBlockSchema.model.flavour,
|
||||
{ reference: groupId, refFlavour: 'group' },
|
||||
lastNoteId
|
||||
);
|
||||
|
||||
toast(ctx.host, 'Group has been inserted into doc');
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'b.rename',
|
||||
tooltip: 'Rename',
|
||||
icon: EditIcon(),
|
||||
when: ctx => ctx.getSurfaceModelsByType(GroupElementModel).length === 1,
|
||||
run(ctx) {
|
||||
const model = ctx.getCurrentModelByType(GroupElementModel);
|
||||
if (!model) return;
|
||||
|
||||
const rootBlock = getRootBlock(ctx);
|
||||
if (!rootBlock) return;
|
||||
|
||||
mountGroupTitleEditor(model, rootBlock);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'b.ungroup',
|
||||
tooltip: 'Ungroup',
|
||||
icon: UngroupIcon(),
|
||||
run(ctx) {
|
||||
const models = ctx.getSurfaceModelsByType(GroupElementModel);
|
||||
if (!models.length) return;
|
||||
|
||||
for (const model of models) {
|
||||
ctx.command.exec(ungroupCommand, { group: model });
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
when: ctx => ctx.getSurfaceModelsByType(GroupElementModel).length > 0,
|
||||
} as const satisfies ToolbarModuleConfig;
|
||||
|
||||
export const groupToolbarExtension = ToolbarModuleExtension({
|
||||
id: BlockFlavourIdentifier('affine:surface:group'),
|
||||
config: groupToolbarConfig,
|
||||
});
|
||||
34
blocksuite/affine/gfx/group/src/view.ts
Normal file
34
blocksuite/affine/gfx/group/src/view.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
type ViewExtensionContext,
|
||||
ViewExtensionProvider,
|
||||
} from '@blocksuite/affine-ext-loader';
|
||||
|
||||
import { effects } from './effects';
|
||||
import { GroupElementView, GroupInteraction } from './element-view';
|
||||
import { GroupInteractionExtension } from './interaction-ext';
|
||||
import {
|
||||
GroupDomRendererExtension,
|
||||
GroupElementRendererExtension,
|
||||
} from './renderer';
|
||||
import { groupToolbarExtension } from './toolbar/config';
|
||||
|
||||
export class GroupViewExtension extends ViewExtensionProvider {
|
||||
override name = 'affine-group-gfx';
|
||||
|
||||
override effect(): void {
|
||||
super.effect();
|
||||
effects();
|
||||
}
|
||||
|
||||
override setup(context: ViewExtensionContext) {
|
||||
super.setup(context);
|
||||
context.register(GroupElementRendererExtension);
|
||||
context.register(GroupDomRendererExtension);
|
||||
context.register(GroupElementView);
|
||||
if (this.isEdgeless(context.scope)) {
|
||||
context.register(groupToolbarExtension);
|
||||
context.register(GroupInteraction);
|
||||
context.register(GroupInteractionExtension);
|
||||
}
|
||||
}
|
||||
}
|
||||
21
blocksuite/affine/gfx/group/tsconfig.json
Normal file
21
blocksuite/affine/gfx/group/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"include": ["./src"],
|
||||
"references": [
|
||||
{ "path": "../../blocks/surface" },
|
||||
{ "path": "../../components" },
|
||||
{ "path": "../../ext-loader" },
|
||||
{ "path": "../text" },
|
||||
{ "path": "../../model" },
|
||||
{ "path": "../../rich-text" },
|
||||
{ "path": "../../shared" },
|
||||
{ "path": "../../widgets/edgeless-toolbar" },
|
||||
{ "path": "../../../framework/global" },
|
||||
{ "path": "../../../framework/std" }
|
||||
]
|
||||
}
|
||||
29
blocksuite/affine/gfx/group/vitest.config.ts
Normal file
29
blocksuite/affine/gfx/group/vitest.config.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
esbuild: {
|
||||
target: 'es2018',
|
||||
},
|
||||
test: {
|
||||
globalSetup: fileURLToPath(
|
||||
new URL('../../../../scripts/vitest-global.js', import.meta.url)
|
||||
),
|
||||
include: ['src/__tests__/**/*.unit.spec.ts'],
|
||||
testTimeout: 1000,
|
||||
coverage: {
|
||||
provider: 'istanbul',
|
||||
reporter: ['lcov'],
|
||||
reportsDirectory: '../../../.coverage/affine-gfx-group',
|
||||
},
|
||||
onConsoleLog(log, type) {
|
||||
if (log.includes('lit.dev/msg/dev-mode')) {
|
||||
return false;
|
||||
}
|
||||
console.warn(`Unexpected ${type} log`, log);
|
||||
throw new Error(log);
|
||||
},
|
||||
environment: 'happy-dom',
|
||||
},
|
||||
});
|
||||
34
blocksuite/affine/gfx/link/package.json
Normal file
34
blocksuite/affine/gfx/link/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@blocksuite/affine-gfx-link",
|
||||
"description": "Gfx link for BlockSuite.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "yarn run -T tsc"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"keywords": [],
|
||||
"author": "toeverything",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@blocksuite/affine-block-bookmark": "workspace:*",
|
||||
"@blocksuite/affine-block-embed": "workspace:*",
|
||||
"@blocksuite/affine-block-surface": "workspace:*",
|
||||
"@blocksuite/affine-components": "workspace:*",
|
||||
"@blocksuite/affine-ext-loader": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/affine-widget-edgeless-toolbar": "workspace:*",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"lit": "^3.2.0"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./view": "./src/view.ts"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.27.5"
|
||||
}
|
||||
5
blocksuite/affine/gfx/link/src/effects.ts
Normal file
5
blocksuite/affine/gfx/link/src/effects.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { EdgelessLinkToolButton } from './toolbar/link-tool-button';
|
||||
|
||||
export function effects() {
|
||||
customElements.define('edgeless-link-tool-button', EdgelessLinkToolButton);
|
||||
}
|
||||
1
blocksuite/affine/gfx/link/src/index.ts
Normal file
1
blocksuite/affine/gfx/link/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export {};
|
||||
13
blocksuite/affine/gfx/link/src/link-tool.ts
Normal file
13
blocksuite/affine/gfx/link/src/link-tool.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { QuickToolExtension } from '@blocksuite/affine-widget-edgeless-toolbar';
|
||||
import { html } from 'lit';
|
||||
|
||||
import { buildLinkDenseMenu } from './toolbar/link-dense-menu';
|
||||
|
||||
export const linkQuickTool = QuickToolExtension('link', ({ block, gfx }) => {
|
||||
return {
|
||||
content: html`<edgeless-link-tool-button
|
||||
.edgeless=${block}
|
||||
></edgeless-link-tool-button>`,
|
||||
menu: buildLinkDenseMenu(block, gfx),
|
||||
};
|
||||
});
|
||||
32
blocksuite/affine/gfx/link/src/toolbar/link-dense-menu.ts
Normal file
32
blocksuite/affine/gfx/link/src/toolbar/link-dense-menu.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { insertLinkByQuickSearchCommand } from '@blocksuite/affine-block-bookmark';
|
||||
import { menu } from '@blocksuite/affine-components/context-menu';
|
||||
import { LinkIcon } from '@blocksuite/affine-components/icons';
|
||||
import { TelemetryProvider } from '@blocksuite/affine-shared/services';
|
||||
import type { DenseMenuBuilder } from '@blocksuite/affine-widget-edgeless-toolbar';
|
||||
|
||||
export const buildLinkDenseMenu: DenseMenuBuilder = edgeless =>
|
||||
menu.action({
|
||||
name: 'Link',
|
||||
prefix: LinkIcon,
|
||||
select: () => {
|
||||
const [_, { insertedLinkType }] = edgeless.std.command.exec(
|
||||
insertLinkByQuickSearchCommand
|
||||
);
|
||||
|
||||
insertedLinkType
|
||||
?.then(type => {
|
||||
const flavour = type?.flavour;
|
||||
if (!flavour) return;
|
||||
|
||||
edgeless.std
|
||||
.getOptional(TelemetryProvider)
|
||||
?.track('CanvasElementAdded', {
|
||||
control: 'toolbar:general',
|
||||
page: 'whiteboard editor',
|
||||
module: 'toolbar',
|
||||
type: flavour.split(':')[1],
|
||||
});
|
||||
})
|
||||
.catch(console.error);
|
||||
},
|
||||
});
|
||||
96
blocksuite/affine/gfx/link/src/toolbar/link-tool-button.ts
Normal file
96
blocksuite/affine/gfx/link/src/toolbar/link-tool-button.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { insertLinkByQuickSearchCommand } from '@blocksuite/affine-block-bookmark';
|
||||
import { insertEmbedCard } from '@blocksuite/affine-block-embed';
|
||||
import { DefaultTool } from '@blocksuite/affine-block-surface';
|
||||
import { toggleEmbedCardCreateModal } from '@blocksuite/affine-components/embed-card-modal';
|
||||
import { LinkIcon } from '@blocksuite/affine-components/icons';
|
||||
import { TelemetryProvider } from '@blocksuite/affine-shared/services';
|
||||
import { QuickToolMixin } from '@blocksuite/affine-widget-edgeless-toolbar';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
|
||||
export class EdgelessLinkToolButton extends QuickToolMixin(LitElement) {
|
||||
static override styles = css`
|
||||
.link-icon,
|
||||
.link-icon > svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
`;
|
||||
|
||||
override type = DefaultTool;
|
||||
|
||||
private _onClick() {
|
||||
const [success, { insertedLinkType }] = this.edgeless.std.command.exec(
|
||||
insertLinkByQuickSearchCommand
|
||||
);
|
||||
|
||||
if (!success) {
|
||||
// fallback to create a bookmark block with input modal
|
||||
toggleEmbedCardCreateModal(
|
||||
this.edgeless.host,
|
||||
'Links',
|
||||
'The added link will be displayed as a card view.',
|
||||
{
|
||||
mode: 'edgeless',
|
||||
onSave: url => {
|
||||
insertEmbedCard(this.edgeless.std, {
|
||||
flavour: 'affine:bookmark',
|
||||
targetStyle: 'vertical',
|
||||
props: { url },
|
||||
});
|
||||
},
|
||||
},
|
||||
({ mode }) => {
|
||||
if (mode === 'edgeless') {
|
||||
const gfx = this.edgeless.std.get(GfxControllerIdentifier);
|
||||
gfx.tool.setTool(DefaultTool);
|
||||
}
|
||||
}
|
||||
).catch(console.error);
|
||||
return;
|
||||
}
|
||||
|
||||
insertedLinkType
|
||||
?.then(type => {
|
||||
const flavour = type?.flavour;
|
||||
if (!flavour) return;
|
||||
|
||||
this.edgeless.std
|
||||
.getOptional(TelemetryProvider)
|
||||
?.track('CanvasElementAdded', {
|
||||
control: 'toolbar:general',
|
||||
page: 'whiteboard editor',
|
||||
module: 'toolbar',
|
||||
segment: 'toolbar',
|
||||
type: flavour.split(':')[1],
|
||||
});
|
||||
|
||||
this.edgeless.std
|
||||
.getOptional(TelemetryProvider)
|
||||
?.track('LinkedDocCreated', {
|
||||
control: 'links',
|
||||
page: 'whiteboard editor',
|
||||
module: 'edgeless toolbar',
|
||||
segment: 'whiteboard',
|
||||
type: flavour.split(':')[1],
|
||||
other: 'existing doc',
|
||||
});
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`<edgeless-tool-icon-button
|
||||
.iconContainerPadding="${6}"
|
||||
.tooltip="${html`<affine-tooltip-content-with-shortcut
|
||||
data-tip="${'Link'}"
|
||||
data-shortcut="${'@'}"
|
||||
></affine-tooltip-content-with-shortcut>`}"
|
||||
.tooltipOffset=${17}
|
||||
class="edgeless-link-tool-button"
|
||||
@click=${this._onClick}
|
||||
>
|
||||
<span class="link-icon">${LinkIcon}</span>
|
||||
</edgeless-tool-icon-button>`;
|
||||
}
|
||||
}
|
||||
23
blocksuite/affine/gfx/link/src/view.ts
Normal file
23
blocksuite/affine/gfx/link/src/view.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
type ViewExtensionContext,
|
||||
ViewExtensionProvider,
|
||||
} from '@blocksuite/affine-ext-loader';
|
||||
|
||||
import { effects } from './effects';
|
||||
import { linkQuickTool } from './link-tool';
|
||||
|
||||
export class LinkViewExtension extends ViewExtensionProvider {
|
||||
override name = 'affine-link-gfx';
|
||||
|
||||
override effect() {
|
||||
super.effect();
|
||||
effects();
|
||||
}
|
||||
|
||||
override setup(context: ViewExtensionContext) {
|
||||
super.setup(context);
|
||||
if (this.isEdgeless(context.scope)) {
|
||||
context.register(linkQuickTool);
|
||||
}
|
||||
}
|
||||
}
|
||||
19
blocksuite/affine/gfx/link/tsconfig.json
Normal file
19
blocksuite/affine/gfx/link/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"include": ["./src"],
|
||||
"references": [
|
||||
{ "path": "../../blocks/bookmark" },
|
||||
{ "path": "../../blocks/embed" },
|
||||
{ "path": "../../blocks/surface" },
|
||||
{ "path": "../../components" },
|
||||
{ "path": "../../ext-loader" },
|
||||
{ "path": "../../shared" },
|
||||
{ "path": "../../widgets/edgeless-toolbar" },
|
||||
{ "path": "../../../framework/std" }
|
||||
]
|
||||
}
|
||||
49
blocksuite/affine/gfx/mindmap/package.json
Normal file
49
blocksuite/affine/gfx/mindmap/package.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "@blocksuite/affine-gfx-mindmap",
|
||||
"description": "Gfx mindmap for BlockSuite.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "yarn run -T tsc"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"keywords": [],
|
||||
"author": "toeverything",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@blocksuite/affine-block-attachment": "workspace:*",
|
||||
"@blocksuite/affine-block-image": "workspace:*",
|
||||
"@blocksuite/affine-block-surface": "workspace:*",
|
||||
"@blocksuite/affine-components": "workspace:*",
|
||||
"@blocksuite/affine-ext-loader": "workspace:*",
|
||||
"@blocksuite/affine-gfx-connector": "workspace:*",
|
||||
"@blocksuite/affine-gfx-pointer": "workspace:*",
|
||||
"@blocksuite/affine-gfx-shape": "workspace:*",
|
||||
"@blocksuite/affine-gfx-text": "workspace:*",
|
||||
"@blocksuite/affine-model": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/affine-widget-edgeless-toolbar": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"lit": "^3.2.0",
|
||||
"lodash-es": "^4.17.23",
|
||||
"simple-xml-to-json": "^1.2.2",
|
||||
"yjs": "^13.6.27"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./view": "./src/view.ts",
|
||||
"./store": "./src/store.ts"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.27.5"
|
||||
}
|
||||
2
blocksuite/affine/gfx/mindmap/src/adapter/index.ts
Normal file
2
blocksuite/affine/gfx/mindmap/src/adapter/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './markdown';
|
||||
export * from './plain-text';
|
||||
69
blocksuite/affine/gfx/mindmap/src/adapter/markdown/index.ts
Normal file
69
blocksuite/affine/gfx/mindmap/src/adapter/markdown/index.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { ElementToMarkdownAdapterExtension } from '@blocksuite/affine-block-surface';
|
||||
import { getShapeText } from '@blocksuite/affine-gfx-shape';
|
||||
import {
|
||||
buildMindMapTree,
|
||||
type MindMapTreeNode,
|
||||
} from '@blocksuite/affine-model';
|
||||
|
||||
export const mindmapToMarkdownAdapterMatcher =
|
||||
ElementToMarkdownAdapterExtension({
|
||||
name: 'mindmap',
|
||||
match: elementModel => elementModel.type === 'mindmap',
|
||||
toAST: (elementModel, context) => {
|
||||
if (elementModel.type !== 'mindmap') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mindMapTree = buildMindMapTree(elementModel);
|
||||
if (!mindMapTree) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { walkerContext, elements } = context;
|
||||
const traverseMindMapTree = (node: MindMapTreeNode) => {
|
||||
const shapeElement = elements[node.id as string];
|
||||
const shapeText = getShapeText(shapeElement);
|
||||
walkerContext
|
||||
.openNode(
|
||||
{
|
||||
type: 'listItem',
|
||||
spread: false,
|
||||
children: [],
|
||||
},
|
||||
'children'
|
||||
)
|
||||
.openNode(
|
||||
{
|
||||
type: 'paragraph',
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
value: shapeText,
|
||||
},
|
||||
],
|
||||
},
|
||||
'children'
|
||||
)
|
||||
.closeNode();
|
||||
node.children.forEach(child => {
|
||||
traverseMindMapTree(child);
|
||||
walkerContext.closeNode();
|
||||
});
|
||||
};
|
||||
|
||||
// First create a list node for the mind map tree
|
||||
walkerContext.openNode(
|
||||
{
|
||||
type: 'list',
|
||||
ordered: false,
|
||||
spread: false,
|
||||
children: [],
|
||||
},
|
||||
'children'
|
||||
);
|
||||
traverseMindMapTree(mindMapTree);
|
||||
walkerContext.closeNode().closeNode();
|
||||
|
||||
return null;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ElementToPlainTextAdapterExtension } from '@blocksuite/affine-block-surface';
|
||||
|
||||
import { getMindMapTreeText } from '../utils';
|
||||
|
||||
export const mindmapToPlainTextAdapterMatcher =
|
||||
ElementToPlainTextAdapterExtension({
|
||||
name: 'mindmap',
|
||||
match: elementModel => elementModel.type === 'mindmap',
|
||||
toAST: (elementModel, context) => {
|
||||
const mindMapContent = getMindMapTreeText(elementModel, context.elements);
|
||||
return { content: mindMapContent };
|
||||
},
|
||||
});
|
||||
65
blocksuite/affine/gfx/mindmap/src/adapter/utils.ts
Normal file
65
blocksuite/affine/gfx/mindmap/src/adapter/utils.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { getShapeText } from '@blocksuite/affine-gfx-shape';
|
||||
import {
|
||||
buildMindMapTree,
|
||||
type MindMapTreeNode,
|
||||
} from '@blocksuite/affine-model';
|
||||
|
||||
/**
|
||||
* traverse the mindMapTree and construct the content string
|
||||
* like:
|
||||
* - Root
|
||||
* - Child 1
|
||||
* - Child 1.1
|
||||
* - Child 1.2
|
||||
* - Child 2
|
||||
* - Child 2.1
|
||||
* - Child 2.2
|
||||
* - Child 3
|
||||
* - Child 3.1
|
||||
* - Child 3.2
|
||||
* @param elementModel - the mindmap element model
|
||||
* @param elements - the elements map
|
||||
* @returns the mindmap tree text
|
||||
*/
|
||||
export function getMindMapTreeText(
|
||||
elementModel: Record<string, unknown>,
|
||||
elements: Record<string, Record<string, unknown>>,
|
||||
options: {
|
||||
prefix: string;
|
||||
repeat: number;
|
||||
} = {
|
||||
prefix: ' ',
|
||||
repeat: 2,
|
||||
}
|
||||
): string {
|
||||
let mindMapContent = '';
|
||||
if (elementModel.type !== 'mindmap') {
|
||||
return mindMapContent;
|
||||
}
|
||||
|
||||
const mindMapTree = buildMindMapTree(elementModel);
|
||||
if (!mindMapTree) {
|
||||
return mindMapContent;
|
||||
}
|
||||
|
||||
let layer = 0;
|
||||
const traverseMindMapTree = (
|
||||
node: MindMapTreeNode,
|
||||
prefix: string,
|
||||
repeat: number
|
||||
) => {
|
||||
const shapeElement = elements[node.id as string];
|
||||
const shapeText = getShapeText(shapeElement);
|
||||
if (shapeElement) {
|
||||
mindMapContent += `${prefix.repeat(layer * repeat)}- ${shapeText}\n`;
|
||||
}
|
||||
node.children.forEach(child => {
|
||||
layer++;
|
||||
traverseMindMapTree(child, prefix, repeat);
|
||||
layer--;
|
||||
});
|
||||
};
|
||||
traverseMindMapTree(mindMapTree, options.prefix, options.repeat);
|
||||
|
||||
return mindMapContent;
|
||||
}
|
||||
20
blocksuite/affine/gfx/mindmap/src/effects.ts
Normal file
20
blocksuite/affine/gfx/mindmap/src/effects.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { MindMapPlaceholder } from './toolbar/mindmap-importing-placeholder';
|
||||
import { EdgelessMindmapMenu } from './toolbar/mindmap-menu';
|
||||
import { EdgelessMindmapToolButton } from './toolbar/mindmap-tool-button';
|
||||
|
||||
export function effects() {
|
||||
customElements.define(
|
||||
'edgeless-mindmap-tool-button',
|
||||
EdgelessMindmapToolButton
|
||||
);
|
||||
customElements.define('edgeless-mindmap-menu', EdgelessMindmapMenu);
|
||||
customElements.define('mindmap-import-placeholder', MindMapPlaceholder);
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-mindmap-tool-button': EdgelessMindmapToolButton;
|
||||
'edgeless-mindmap-menu': EdgelessMindmapMenu;
|
||||
'mindmap-import-placeholder': MindMapPlaceholder;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user