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

This commit is contained in:
upstream-mirror
2026-09-16 04:01:20 +05:00
commit 702cc698a7
9990 changed files with 1073618 additions and 0 deletions

View 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"
}

View File

@@ -0,0 +1,2 @@
export * from './markdown';
export * from './plain-text';

View 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,
},
],
};
},
});

View 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 };
},
});

View 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,
});
}
}

View 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;
}
}

View 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);
}
}

View 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,
});
}
}

View 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';

View 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),
});
}
);

View 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
),
});
}
);

View File

@@ -0,0 +1,2 @@
export { BrushDomRendererExtension } from './brush';
export { HighlighterDomRendererExtension } from './highlighter';

View 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';
}

View 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
);

View File

@@ -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
);

View File

@@ -0,0 +1,2 @@
export { BrushElementRendererExtension } from './brush';
export { HighlighterElementRendererExtension } from './highlighter';

View File

@@ -0,0 +1,2 @@
export * from './dom';
export * from './element';

View 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);
}
}

View File

@@ -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>
`;
}
}

View File

@@ -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>`;

View File

@@ -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',
},
};

View 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,
},
};

View File

@@ -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;
}>;
}

View File

@@ -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>
`;
}
}

View File

@@ -0,0 +1,3 @@
export type Pen = 'brush' | 'highlighter';
export type PenMap<T> = Record<Pen, T>;

View 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,
});

View 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,
});

View File

@@ -0,0 +1,2 @@
export * from './brush';
export * from './highlighter';

View 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> `,
};
});

View 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);
}
}

View 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" }
]
}