mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-24 13:58:50 +08:00
55651503df
Closes: [BS-2207](https://linear.app/affine-design/issue/BS-2207/move-root-block-to-affineblock-root)
486 lines
13 KiB
TypeScript
486 lines
13 KiB
TypeScript
import {
|
|
type Options,
|
|
Overlay,
|
|
type RoughCanvas,
|
|
type SurfaceBlockComponent,
|
|
} from '@blocksuite/affine-block-surface';
|
|
import {
|
|
type Color,
|
|
DefaultTheme,
|
|
shapeMethods,
|
|
type ShapeStyle,
|
|
} from '@blocksuite/affine-model';
|
|
import { ThemeProvider } from '@blocksuite/affine-shared/services';
|
|
import type { GfxController, GfxToolsMap } from '@blocksuite/block-std/gfx';
|
|
import type { XYWH } from '@blocksuite/global/utils';
|
|
import {
|
|
assertType,
|
|
Bound,
|
|
DisposableGroup,
|
|
noop,
|
|
Slot,
|
|
} from '@blocksuite/global/utils';
|
|
import { effect } from '@preact/signals-core';
|
|
|
|
import type { ShapeTool } from '../gfx-tool/shape-tool.js';
|
|
import {
|
|
NOTE_OVERLAY_CORNER_RADIUS,
|
|
NOTE_OVERLAY_HEIGHT,
|
|
NOTE_OVERLAY_OFFSET_X,
|
|
NOTE_OVERLAY_OFFSET_Y,
|
|
NOTE_OVERLAY_STOKE_COLOR,
|
|
NOTE_OVERLAY_TEXT_COLOR,
|
|
NOTE_OVERLAY_WIDTH,
|
|
SHAPE_OVERLAY_HEIGHT,
|
|
SHAPE_OVERLAY_OFFSET_X,
|
|
SHAPE_OVERLAY_OFFSET_Y,
|
|
SHAPE_OVERLAY_WIDTH,
|
|
} from '../utils/consts.js';
|
|
|
|
const drawRoundedRect = (ctx: CanvasRenderingContext2D, xywh: XYWH) => {
|
|
const [x, y, w, h] = xywh;
|
|
const width = w;
|
|
const height = h;
|
|
const radius = 0.1;
|
|
const cornerRadius = Math.min(width * radius, height * radius);
|
|
ctx.moveTo(x + cornerRadius, y);
|
|
ctx.arcTo(x + width, y, x + width, y + height, cornerRadius);
|
|
ctx.arcTo(x + width, y + height, x, y + height, cornerRadius);
|
|
ctx.arcTo(x, y + height, x, y, cornerRadius);
|
|
ctx.arcTo(x, y, x + width, y, cornerRadius);
|
|
};
|
|
|
|
const drawGeneralShape = (
|
|
ctx: CanvasRenderingContext2D,
|
|
type: string,
|
|
xywh: XYWH,
|
|
options: Options
|
|
) => {
|
|
ctx.setLineDash(options.strokeLineDash ?? []);
|
|
ctx.strokeStyle = options.stroke ?? 'transparent';
|
|
ctx.lineWidth = options.strokeWidth ?? 2;
|
|
ctx.fillStyle = options.fill ?? 'transparent';
|
|
|
|
ctx.beginPath();
|
|
|
|
const bound = Bound.fromXYWH(xywh);
|
|
switch (type) {
|
|
case 'rect':
|
|
shapeMethods.rect.draw(ctx, bound);
|
|
break;
|
|
case 'triangle':
|
|
shapeMethods.triangle.draw(ctx, bound);
|
|
break;
|
|
case 'diamond':
|
|
shapeMethods.diamond.draw(ctx, bound);
|
|
break;
|
|
case 'ellipse':
|
|
shapeMethods.ellipse.draw(ctx, bound);
|
|
break;
|
|
case 'roundedRect':
|
|
drawRoundedRect(ctx, xywh);
|
|
break;
|
|
default:
|
|
throw new Error(`Unknown shape type: ${type}`);
|
|
}
|
|
|
|
ctx.closePath();
|
|
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
};
|
|
|
|
export abstract class Shape {
|
|
options: Options;
|
|
|
|
shapeStyle: ShapeStyle;
|
|
|
|
type: string;
|
|
|
|
xywh: XYWH;
|
|
|
|
constructor(
|
|
xywh: XYWH,
|
|
type: string,
|
|
options: Options,
|
|
shapeStyle: ShapeStyle
|
|
) {
|
|
this.xywh = xywh;
|
|
this.type = type;
|
|
this.options = options;
|
|
this.shapeStyle = shapeStyle;
|
|
}
|
|
|
|
abstract draw(ctx: CanvasRenderingContext2D, rc: RoughCanvas): void;
|
|
}
|
|
|
|
export class RectShape extends Shape {
|
|
draw(ctx: CanvasRenderingContext2D, rc: RoughCanvas): void {
|
|
if (this.shapeStyle === 'Scribbled') {
|
|
const [x, y, w, h] = this.xywh;
|
|
rc.rectangle(x, y, w, h, this.options);
|
|
} else {
|
|
drawGeneralShape(ctx, 'rect', this.xywh, this.options);
|
|
}
|
|
}
|
|
}
|
|
|
|
export class TriangleShape extends Shape {
|
|
draw(ctx: CanvasRenderingContext2D, rc: RoughCanvas): void {
|
|
if (this.shapeStyle === 'Scribbled') {
|
|
const [x, y, w, h] = this.xywh;
|
|
rc.polygon(
|
|
[
|
|
[x + w / 2, y],
|
|
[x, y + h],
|
|
[x + w, y + h],
|
|
],
|
|
this.options
|
|
);
|
|
} else {
|
|
drawGeneralShape(ctx, 'triangle', this.xywh, this.options);
|
|
}
|
|
}
|
|
}
|
|
|
|
export class DiamondShape extends Shape {
|
|
draw(ctx: CanvasRenderingContext2D, rc: RoughCanvas): void {
|
|
if (this.shapeStyle === 'Scribbled') {
|
|
const [x, y, w, h] = this.xywh;
|
|
rc.polygon(
|
|
[
|
|
[x + w / 2, y],
|
|
[x + w, y + h / 2],
|
|
[x + w / 2, y + h],
|
|
[x, y + h / 2],
|
|
],
|
|
this.options
|
|
);
|
|
} else {
|
|
drawGeneralShape(ctx, 'diamond', this.xywh, this.options);
|
|
}
|
|
}
|
|
}
|
|
|
|
export class EllipseShape extends Shape {
|
|
draw(ctx: CanvasRenderingContext2D, rc: RoughCanvas): void {
|
|
if (this.shapeStyle === 'Scribbled') {
|
|
const [x, y, w, h] = this.xywh;
|
|
rc.ellipse(x + w / 2, y + h / 2, w, h, this.options);
|
|
} else {
|
|
drawGeneralShape(ctx, 'ellipse', this.xywh, this.options);
|
|
}
|
|
}
|
|
}
|
|
|
|
export class RoundedRectShape extends Shape {
|
|
draw(ctx: CanvasRenderingContext2D, rc: RoughCanvas): void {
|
|
if (this.shapeStyle === 'Scribbled') {
|
|
const [x, y, w, h] = this.xywh;
|
|
const radius = 0.1;
|
|
const r = Math.min(w * radius, h * radius);
|
|
const x0 = x + r;
|
|
const x1 = x + w - r;
|
|
const y0 = y + r;
|
|
const y1 = y + h - r;
|
|
const path = `
|
|
M${x0},${y} L${x1},${y}
|
|
A${r},${r} 0 0 1 ${x1},${y0}
|
|
L${x1},${y1}
|
|
A${r},${r} 0 0 1 ${x1 - r},${y1}
|
|
L${x0 + r},${y1}
|
|
A${r},${r} 0 0 1 ${x0},${y1 - r}
|
|
L${x0},${y0}
|
|
A${r},${r} 0 0 1 ${x0 + r},${y}
|
|
`;
|
|
|
|
rc.path(path, this.options);
|
|
} else {
|
|
drawGeneralShape(ctx, 'roundedRect', this.xywh, this.options);
|
|
}
|
|
}
|
|
}
|
|
|
|
export class ShapeFactory {
|
|
static createShape(
|
|
xywh: XYWH,
|
|
type: string,
|
|
options: Options,
|
|
shapeStyle: ShapeStyle
|
|
): Shape {
|
|
switch (type) {
|
|
case 'rect':
|
|
return new RectShape(xywh, type, options, shapeStyle);
|
|
case 'triangle':
|
|
return new TriangleShape(xywh, type, options, shapeStyle);
|
|
case 'diamond':
|
|
return new DiamondShape(xywh, type, options, shapeStyle);
|
|
case 'ellipse':
|
|
return new EllipseShape(xywh, type, options, shapeStyle);
|
|
case 'roundedRect':
|
|
return new RoundedRectShape(xywh, type, options, shapeStyle);
|
|
default:
|
|
throw new Error(`Unknown shape type: ${type}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
class ToolOverlay extends Overlay {
|
|
protected disposables = new DisposableGroup();
|
|
|
|
globalAlpha: number;
|
|
|
|
x: number;
|
|
|
|
y: number;
|
|
|
|
constructor(gfx: GfxController) {
|
|
super(gfx);
|
|
this.x = 0;
|
|
this.y = 0;
|
|
this.globalAlpha = 0;
|
|
this.gfx = gfx;
|
|
this.disposables.add(
|
|
this.gfx.viewport.viewportUpdated.on(() => {
|
|
// when viewport is updated, we should keep the overlay in the same position
|
|
// to get last mouse position and convert it to model coordinates
|
|
const pos = this.gfx.tool.lastMousePos$.value;
|
|
const [x, y] = this.gfx.viewport.toModelCoord(pos.x, pos.y);
|
|
this.x = x;
|
|
this.y = y;
|
|
})
|
|
);
|
|
}
|
|
|
|
override dispose(): void {
|
|
this.disposables.dispose();
|
|
}
|
|
|
|
render(_ctx: CanvasRenderingContext2D, _rc: RoughCanvas): void {
|
|
noop();
|
|
}
|
|
}
|
|
|
|
export class ShapeOverlay extends ToolOverlay {
|
|
shape: Shape;
|
|
|
|
constructor(
|
|
gfx: GfxController,
|
|
type: string,
|
|
options: Options,
|
|
style: {
|
|
shapeStyle: ShapeStyle;
|
|
fillColor: Color;
|
|
strokeColor: Color;
|
|
}
|
|
) {
|
|
super(gfx);
|
|
const xywh = [
|
|
this.x,
|
|
this.y,
|
|
SHAPE_OVERLAY_WIDTH,
|
|
SHAPE_OVERLAY_HEIGHT,
|
|
] as XYWH;
|
|
const { shapeStyle, fillColor, strokeColor } = style;
|
|
const fill = this.gfx.std
|
|
.get(ThemeProvider)
|
|
.getColorValue(fillColor, DefaultTheme.shapeFillColor, true);
|
|
const stroke = this.gfx.std
|
|
.get(ThemeProvider)
|
|
.getColorValue(strokeColor, DefaultTheme.shapeStrokeColor, true);
|
|
|
|
options.fill = fill;
|
|
options.stroke = stroke;
|
|
|
|
this.shape = ShapeFactory.createShape(xywh, type, options, shapeStyle);
|
|
this.disposables.add(
|
|
effect(() => {
|
|
const currentTool = this.gfx.tool.currentTool$.value;
|
|
|
|
if (currentTool?.toolName !== 'shape') return;
|
|
|
|
assertType<ShapeTool>(currentTool);
|
|
|
|
const { shapeName } = currentTool.activatedOption;
|
|
const newOptions = {
|
|
...options,
|
|
};
|
|
|
|
let { x, y } = this;
|
|
if (shapeName === 'roundedRect' || shapeName === 'rect') {
|
|
x += SHAPE_OVERLAY_OFFSET_X;
|
|
y += SHAPE_OVERLAY_OFFSET_Y;
|
|
}
|
|
const w =
|
|
shapeName === 'roundedRect'
|
|
? SHAPE_OVERLAY_WIDTH + 40
|
|
: SHAPE_OVERLAY_WIDTH;
|
|
const xywh = [x, y, w, SHAPE_OVERLAY_HEIGHT] as XYWH;
|
|
this.shape = ShapeFactory.createShape(
|
|
xywh,
|
|
shapeName,
|
|
newOptions,
|
|
shapeStyle
|
|
);
|
|
|
|
(this.gfx.surfaceComponent as SurfaceBlockComponent).refresh();
|
|
})
|
|
);
|
|
}
|
|
|
|
override render(ctx: CanvasRenderingContext2D, rc: RoughCanvas): void {
|
|
ctx.globalAlpha = this.globalAlpha;
|
|
let { x, y } = this;
|
|
const { type } = this.shape;
|
|
if (type === 'roundedRect' || type === 'rect') {
|
|
x += SHAPE_OVERLAY_OFFSET_X;
|
|
y += SHAPE_OVERLAY_OFFSET_Y;
|
|
}
|
|
const w =
|
|
type === 'roundedRect' ? SHAPE_OVERLAY_WIDTH + 40 : SHAPE_OVERLAY_WIDTH;
|
|
const xywh = [x, y, w, SHAPE_OVERLAY_HEIGHT] as XYWH;
|
|
this.shape.xywh = xywh;
|
|
this.shape.draw(ctx, rc);
|
|
}
|
|
}
|
|
|
|
export class NoteOverlay extends ToolOverlay {
|
|
backgroundColor = 'transparent';
|
|
|
|
text = '';
|
|
|
|
constructor(gfx: GfxController, background: Color) {
|
|
super(gfx);
|
|
this.globalAlpha = 0;
|
|
this.backgroundColor = gfx.std
|
|
.get(ThemeProvider)
|
|
.getColorValue(background, DefaultTheme.noteBackgrounColor, true);
|
|
this.disposables.add(
|
|
effect(() => {
|
|
// when change note child type, update overlay text
|
|
if (this.gfx.tool.currentToolName$.value !== 'affine:note') return;
|
|
const tool =
|
|
this.gfx.tool.currentTool$.peek() as GfxToolsMap['affine:note'];
|
|
this.text = this._getOverlayText(tool.activatedOption.tip);
|
|
(this.gfx.surfaceComponent as SurfaceBlockComponent).refresh();
|
|
})
|
|
);
|
|
}
|
|
|
|
private _getOverlayText(text: string): string {
|
|
return text[0].toUpperCase() + text.slice(1);
|
|
}
|
|
|
|
override render(ctx: CanvasRenderingContext2D): void {
|
|
ctx.globalAlpha = this.globalAlpha;
|
|
const overlayX = this.x + NOTE_OVERLAY_OFFSET_X;
|
|
const overlayY = this.y + NOTE_OVERLAY_OFFSET_Y;
|
|
ctx.strokeStyle = this.gfx.std
|
|
.get(ThemeProvider)
|
|
.getCssVariableColor(NOTE_OVERLAY_STOKE_COLOR);
|
|
// Draw the overlay rectangle
|
|
ctx.fillStyle = this.backgroundColor;
|
|
ctx.lineWidth = 4;
|
|
ctx.beginPath();
|
|
ctx.moveTo(overlayX + NOTE_OVERLAY_CORNER_RADIUS, overlayY);
|
|
ctx.lineTo(
|
|
overlayX + NOTE_OVERLAY_WIDTH - NOTE_OVERLAY_CORNER_RADIUS,
|
|
overlayY
|
|
);
|
|
ctx.quadraticCurveTo(
|
|
overlayX + NOTE_OVERLAY_WIDTH,
|
|
overlayY,
|
|
overlayX + NOTE_OVERLAY_WIDTH,
|
|
overlayY + NOTE_OVERLAY_CORNER_RADIUS
|
|
);
|
|
ctx.lineTo(
|
|
overlayX + NOTE_OVERLAY_WIDTH,
|
|
overlayY + NOTE_OVERLAY_HEIGHT - NOTE_OVERLAY_CORNER_RADIUS
|
|
);
|
|
ctx.quadraticCurveTo(
|
|
overlayX + NOTE_OVERLAY_WIDTH,
|
|
overlayY + NOTE_OVERLAY_HEIGHT,
|
|
overlayX + NOTE_OVERLAY_WIDTH - NOTE_OVERLAY_CORNER_RADIUS,
|
|
overlayY + NOTE_OVERLAY_HEIGHT
|
|
);
|
|
ctx.lineTo(
|
|
overlayX + NOTE_OVERLAY_CORNER_RADIUS,
|
|
overlayY + NOTE_OVERLAY_HEIGHT
|
|
);
|
|
ctx.quadraticCurveTo(
|
|
overlayX,
|
|
overlayY + NOTE_OVERLAY_HEIGHT,
|
|
overlayX,
|
|
overlayY + NOTE_OVERLAY_HEIGHT - NOTE_OVERLAY_CORNER_RADIUS
|
|
);
|
|
ctx.lineTo(overlayX, overlayY + NOTE_OVERLAY_CORNER_RADIUS);
|
|
ctx.quadraticCurveTo(
|
|
overlayX,
|
|
overlayY,
|
|
overlayX + NOTE_OVERLAY_CORNER_RADIUS,
|
|
overlayY
|
|
);
|
|
ctx.closePath();
|
|
ctx.stroke();
|
|
ctx.fill();
|
|
|
|
// Draw the overlay text
|
|
ctx.fillStyle = this.gfx.std
|
|
.get(ThemeProvider)
|
|
.getCssVariableColor(NOTE_OVERLAY_TEXT_COLOR);
|
|
let fontSize = 16;
|
|
ctx.font = `${fontSize}px Arial`;
|
|
ctx.textAlign = 'left';
|
|
ctx.textBaseline = 'middle';
|
|
|
|
// measure the width of the text
|
|
// if the text is wider than the rectangle, reduce the maximum width of the text
|
|
while (ctx.measureText(this.text).width > NOTE_OVERLAY_WIDTH - 20) {
|
|
fontSize -= 1;
|
|
ctx.font = `${fontSize}px Arial`;
|
|
}
|
|
|
|
ctx.fillText(this.text, overlayX + 10, overlayY + NOTE_OVERLAY_HEIGHT / 2);
|
|
}
|
|
}
|
|
|
|
export class DraggingNoteOverlay extends NoteOverlay {
|
|
height: number;
|
|
|
|
slots: {
|
|
draggingNoteUpdated: Slot<{ xywh: XYWH }>;
|
|
};
|
|
|
|
width: number;
|
|
|
|
constructor(gfx: GfxController, background: Color) {
|
|
super(gfx, background);
|
|
this.slots = {
|
|
draggingNoteUpdated: new Slot<{
|
|
xywh: XYWH;
|
|
}>(),
|
|
};
|
|
this.width = 0;
|
|
this.height = 0;
|
|
this.disposables.add(
|
|
this.slots.draggingNoteUpdated.on(({ xywh }) => {
|
|
[this.x, this.y, this.width, this.height] = xywh;
|
|
(this.gfx.surfaceComponent as SurfaceBlockComponent).refresh();
|
|
})
|
|
);
|
|
}
|
|
|
|
override render(ctx: CanvasRenderingContext2D): void {
|
|
// draw a rounded rectangle with provided background color and xywh
|
|
ctx.globalAlpha = 0.8;
|
|
ctx.fillStyle = this.backgroundColor;
|
|
ctx.strokeStyle = 'rgba(0, 0, 0, 0.10)';
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
ctx.roundRect(this.x, this.y, this.width, this.height, 4);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
}
|
|
}
|