chore(editor): reorg packages (#10702)

This commit is contained in:
Saul-Mirone
2025-03-08 03:57:04 +00:00
parent 334912e85b
commit 8aedef0a36
961 changed files with 837 additions and 927 deletions
@@ -0,0 +1,38 @@
import type { SerializedElement } from '@blocksuite/block-std/gfx';
import { Bound, getBoundWithRotation } from '@blocksuite/global/gfx';
import { type BlockSnapshot, BlockSnapshotSchema } from '@blocksuite/store';
export function getBoundFromSerializedElement(element: SerializedElement) {
return Bound.from(
getBoundWithRotation({
...Bound.deserialize(element.xywh),
rotate: typeof element.rotate === 'number' ? element.rotate : 0,
})
);
}
export function getBoundFromGfxBlockSnapshot(snapshot: BlockSnapshot) {
if (typeof snapshot.props.xywh !== 'string') return null;
return Bound.deserialize(snapshot.props.xywh);
}
export function edgelessElementsBoundFromRawData(
elementsRawData: (SerializedElement | BlockSnapshot)[]
) {
if (elementsRawData.length === 0) return new Bound();
let prev: Bound | null = null;
for (const data of elementsRawData) {
const { data: blockSnapshot } = BlockSnapshotSchema.safeParse(data);
const bound = blockSnapshot
? getBoundFromGfxBlockSnapshot(blockSnapshot)
: getBoundFromSerializedElement(data as SerializedElement);
if (!bound) continue;
if (!prev) prev = bound;
else prev = prev.unite(bound);
}
return prev ?? new Bound();
}
@@ -0,0 +1,122 @@
import {
EdgelessFrameManager,
isFrameBlock,
} from '@blocksuite/affine-block-frame';
import { isNoteBlock } from '@blocksuite/affine-block-surface';
import type {
EdgelessTextBlockModel,
EmbedSyncedDocModel,
FrameBlockModel,
FrameBlockProps,
ImageBlockModel,
NoteBlockModel,
ShapeElementModel,
} from '@blocksuite/affine-model';
import { getElementsWithoutGroup } from '@blocksuite/affine-shared/utils';
import {
generateKeyBetweenV2,
type GfxModel,
type SerializedElement,
} from '@blocksuite/block-std/gfx';
import { getCommonBoundWithRotation } from '@blocksuite/global/gfx';
import { type BlockSnapshot, BlockSnapshotSchema } from '@blocksuite/store';
import groupBy from 'lodash-es/groupBy';
import type { EdgelessRootBlockComponent } from '../edgeless-root-block.js';
import { getSortedCloneElements, prepareCloneData } from './clone-utils.js';
import {
isEdgelessTextBlock,
isEmbedSyncedDocBlock,
isImageBlock,
} from './query.js';
const offset = 10;
export async function duplicate(
edgeless: EdgelessRootBlockComponent,
elements: GfxModel[],
select = true
) {
const { clipboardController } = edgeless;
const copyElements = getSortedCloneElements(elements);
const totalBound = getCommonBoundWithRotation(copyElements);
totalBound.x += totalBound.w + offset;
const snapshot = prepareCloneData(copyElements, edgeless.std);
const { canvasElements, blockModels } =
await clipboardController.createElementsFromClipboardData(
snapshot,
totalBound.center
);
const newElements = [...canvasElements, ...blockModels];
edgeless.surface.fitToViewport(totalBound);
if (select) {
edgeless.service.selection.set({
elements: newElements.map(e => e.id),
editing: false,
});
}
}
export const splitElements = (elements: GfxModel[]) => {
const { notes, frames, shapes, images, edgelessTexts, embedSyncedDocs } =
groupBy(getElementsWithoutGroup(elements), element => {
if (isNoteBlock(element)) {
return 'notes';
} else if (isFrameBlock(element)) {
return 'frames';
} else if (isImageBlock(element)) {
return 'images';
} else if (isEdgelessTextBlock(element)) {
return 'edgelessTexts';
} else if (isEmbedSyncedDocBlock(element)) {
return 'embedSyncedDocs';
}
return 'shapes';
}) as {
notes: NoteBlockModel[];
shapes: ShapeElementModel[];
frames: FrameBlockModel[];
images: ImageBlockModel[];
edgelessTexts: EdgelessTextBlockModel[];
embedSyncedDocs: EmbedSyncedDocModel[];
};
return {
notes: notes ?? [],
shapes: shapes ?? [],
frames: frames ?? [],
images: images ?? [],
edgelessTexts: edgelessTexts ?? [],
embedSyncedDocs: embedSyncedDocs ?? [],
};
};
type FrameSnapshot = BlockSnapshot & {
props: FrameBlockProps;
};
export function createNewPresentationIndexes(
raw: (SerializedElement | BlockSnapshot)[],
edgeless: EdgelessRootBlockComponent
) {
const frames = raw
.filter((block): block is FrameSnapshot => {
const { data } = BlockSnapshotSchema.safeParse(block);
return data?.flavour === 'affine:frame';
})
.sort((a, b) =>
EdgelessFrameManager.framePresentationComparator(a.props, b.props)
);
const frameMgr = edgeless.service.frame;
let before = frameMgr.generatePresentationIndex();
const result = new Map<string, string>();
frames.forEach(frame => {
result.set(frame.id, before);
before = generateKeyBetweenV2(before, null);
});
return result;
}
@@ -0,0 +1,237 @@
import type {
FrameBlockProps,
NodeDetail,
SerializedConnectorElement,
SerializedGroupElement,
SerializedMindmapElement,
} from '@blocksuite/affine-model';
import {
ConnectorElementModel,
GroupElementModel,
MindmapElementModel,
} from '@blocksuite/affine-model';
import type { BlockStdScope } from '@blocksuite/block-std';
import {
getTopElements,
GfxBlockElementModel,
type GfxModel,
type GfxPrimitiveElementModel,
isGfxGroupCompatibleModel,
type SerializedElement,
} from '@blocksuite/block-std/gfx';
import type { BlockSnapshot, Transformer } from '@blocksuite/store';
/**
* return all elements in the tree of the elements
*/
export function getSortedCloneElements(elements: GfxModel[]) {
const set = new Set<GfxModel>();
elements.forEach(element => {
// this element subtree has been added
if (set.has(element)) return;
set.add(element);
if (isGfxGroupCompatibleModel(element)) {
element.descendantElements.forEach(descendant => set.add(descendant));
}
});
return sortEdgelessElements([...set]);
}
export function prepareCloneData(elements: GfxModel[], std: BlockStdScope) {
elements = sortEdgelessElements(elements);
const job = std.store.getTransformer();
const res = elements.map(element => {
const data = serializeElement(element, elements, job);
return data;
});
return res.filter((d): d is SerializedElement | BlockSnapshot => !!d);
}
export function serializeElement(
element: GfxModel,
elements: GfxModel[],
job: Transformer
) {
if (element instanceof GfxBlockElementModel) {
const snapshot = job.blockToSnapshot(element);
if (!snapshot) {
return;
}
return { ...snapshot };
} else if (element instanceof ConnectorElementModel) {
return serializeConnector(element, elements);
} else {
return element.serialize();
}
}
export function serializeConnector(
connector: ConnectorElementModel,
elements: GfxModel[]
) {
const sourceId = connector.source?.id;
const targetId = connector.target?.id;
const serialized = connector.serialize();
// if the source or target element not to be cloned
// transfer connector position to absolute path
if (sourceId && elements.every(s => s.id !== sourceId)) {
serialized.source = { position: connector.absolutePath[0] };
}
if (targetId && elements.every(s => s.id !== targetId)) {
serialized.target = {
position: connector.absolutePath[connector.absolutePath.length - 1],
};
}
return serialized;
}
/**
* There are interdependencies between elements,
* so they must be added in a certain order
* @param elements edgeless model list
* @returns sorted edgeless model list
*/
export function sortEdgelessElements(elements: GfxModel[]) {
// Since each element has a parent-child relationship, and from-to connector relationship
// the child element must be added before the parent element
// and the connected elements must be added before the connector element
// To achieve this, we do a post-order traversal of the tree
if (elements.length === 0) return [];
const result: GfxModel[] = [];
const topElements = getTopElements(elements);
// the connector element must be added after the connected elements
const moveConnectorToEnd = (elements: GfxModel[]) => {
const connectors = elements.filter(
element => element instanceof ConnectorElementModel
);
const rest = elements.filter(
element => !(element instanceof ConnectorElementModel)
);
return [...rest, ...connectors];
};
const traverse = (element: GfxModel) => {
if (isGfxGroupCompatibleModel(element)) {
moveConnectorToEnd(element.childElements).forEach(child =>
traverse(child)
);
}
result.push(element);
};
moveConnectorToEnd(topElements).forEach(element => traverse(element));
return result;
}
/**
* map connector source & target ids
* @param props serialized element props
* @param ids old element id to new element id map
* @returns updated element props
*/
export function mapConnectorIds(
props: SerializedConnectorElement,
ids: Map<string, string>
) {
if (props.source.id) {
props.source.id = ids.get(props.source.id);
}
if (props.target.id) {
props.target.id = ids.get(props.target.id);
}
return props;
}
/**
* map group children ids
* @param props serialized element props
* @param ids old element id to new element id map
* @returns updated element props
*/
export function mapGroupIds(
props: SerializedGroupElement,
ids: Map<string, string>
) {
if (props.children) {
const newMap: Record<string, boolean> = {};
for (const [key, value] of Object.entries(props.children)) {
const newKey = ids.get(key);
if (newKey) {
newMap[newKey] = value;
}
}
props.children = newMap;
}
return props;
}
/**
* map frame children ids
* @param props frame block props
* @param ids old element id to new element id map
* @returns updated frame block props
*/
export function mapFrameIds(props: FrameBlockProps, ids: Map<string, string>) {
const oldChildIds = props.childElementIds
? Object.keys(props.childElementIds)
: [];
const newChildIds: Record<string, boolean> = {};
oldChildIds.forEach(oldId => {
const newIds = ids.get(oldId);
if (newIds) newChildIds[newIds] = true;
});
props.childElementIds = newChildIds;
return props;
}
/**
* map mindmap children & parent ids
* @param props serialized element props
* @param ids old element id to new element id map
* @returns updated element props
*/
export function mapMindmapIds(
props: SerializedMindmapElement,
ids: Map<string, string>
) {
if (props.children) {
const newMap: Record<string, NodeDetail> = {};
for (const [key, value] of Object.entries(props.children)) {
const newKey = ids.get(key);
if (value.parent) {
const newParent = ids.get(value.parent);
value.parent = newParent;
}
if (newKey) {
newMap[newKey] = value;
}
}
props.children = newMap;
}
return props;
}
export function getElementProps(
element: GfxPrimitiveElementModel,
ids: Map<string, string>
) {
if (element instanceof ConnectorElementModel) {
const props = element.serialize();
return mapConnectorIds(props, ids);
}
if (element instanceof GroupElementModel) {
const props = element.serialize();
return mapGroupIds(props, ids);
}
if (element instanceof MindmapElementModel) {
const props = element.serialize();
return mapMindmapIds(props, ids);
}
return element.serialize();
}
@@ -0,0 +1,30 @@
import { EdgelessCRUDIdentifier } from '@blocksuite/affine-block-surface';
import type { EdgelessRootService } from '../edgeless-root-service.js';
/**
* move connectors from origin to target
* @param originId origin element id
* @param targetId target element id
* @param service edgeless root service
*/
export function moveConnectors(
originId: string,
targetId: string,
service: EdgelessRootService
) {
const connectors = service.surface.getConnectors(originId);
const crud = service.std.get(EdgelessCRUDIdentifier);
connectors.forEach(connector => {
if (connector.source.id === originId) {
crud.updateElement(connector.id, {
source: { ...connector.source, id: targetId },
});
}
if (connector.target.id === originId) {
crud.updateElement(connector.id, {
target: { ...connector.target, id: targetId },
});
}
});
}
@@ -0,0 +1,52 @@
import {
DEFAULT_ROUGHNESS,
LineWidth,
StrokeStyle,
} from '@blocksuite/affine-model';
export const NOTE_OVERLAY_OFFSET_X = 6;
export const NOTE_OVERLAY_OFFSET_Y = 6;
export const NOTE_OVERLAY_WIDTH = 100;
export const NOTE_OVERLAY_HEIGHT = 50;
export const NOTE_OVERLAY_CORNER_RADIUS = 6;
export const NOTE_OVERLAY_STOKE_COLOR = '--affine-border-color';
export const NOTE_OVERLAY_TEXT_COLOR = '--affine-icon-color';
export const NOTE_OVERLAY_LIGHT_BACKGROUND_COLOR = 'rgba(252, 252, 253, 1)';
export const NOTE_OVERLAY_DARK_BACKGROUND_COLOR = 'rgb(32, 32, 32)';
export const SHAPE_OVERLAY_WIDTH = 100;
export const SHAPE_OVERLAY_HEIGHT = 100;
export const SHAPE_OVERLAY_OFFSET_X = 6;
export const SHAPE_OVERLAY_OFFSET_Y = 6;
export const SHAPE_OVERLAY_OPTIONS = {
seed: 666,
roughness: DEFAULT_ROUGHNESS,
strokeStyle: StrokeStyle.Solid,
strokeLineDash: [] as number[],
stroke: 'black',
strokeWidth: LineWidth.Two,
fill: 'transparent',
};
export const DEFAULT_NOTE_CHILD_FLAVOUR = 'affine:paragraph';
export const DEFAULT_NOTE_CHILD_TYPE = 'text';
export const DEFAULT_NOTE_TIP = 'Text';
export const FIT_TO_SCREEN_PADDING = 100;
export const ATTACHED_DISTANCE = 20;
export const EXCLUDING_MOUSE_OUT_CLASS_LIST = [
'affine-note-mask',
'edgeless-block-portal-note',
'affine-block-children-container',
];
export const SurfaceColor = '#6046FE';
export const NoteColor = '#1E96EB';
export const BlendColor = '#7D91FF';
export const AI_CHAT_BLOCK_MIN_WIDTH = 260;
export const AI_CHAT_BLOCK_MIN_HEIGHT = 160;
export const AI_CHAT_BLOCK_MAX_WIDTH = 320;
export const AI_CHAT_BLOCK_MAX_HEIGHT = 300;
@@ -0,0 +1,37 @@
import { isNoteBlock } from '@blocksuite/affine-block-surface';
import type { Connectable } from '@blocksuite/affine-model';
import type { GfxModel } from '@blocksuite/block-std/gfx';
import type { EdgelessRootBlockComponent } from '../index.js';
import { isConnectable } from './query.js';
/**
* Use deleteElementsV2 instead.
* @deprecated
*/
export function deleteElements(
edgeless: EdgelessRootBlockComponent,
elements: GfxModel[]
) {
const set = new Set(elements);
const { service } = edgeless;
elements.forEach(element => {
if (isConnectable(element)) {
const connectors = service.getConnectors(element as Connectable);
connectors.forEach(connector => set.add(connector));
}
});
set.forEach(element => {
if (isNoteBlock(element)) {
const children = edgeless.doc.root?.children ?? [];
// FIXME: should always keep at least 1 note
if (children.length > 1) {
edgeless.doc.deleteBlock(element);
}
} else {
service.removeElement(element.id);
}
});
}
@@ -0,0 +1,16 @@
import { ShapeType } from '@blocksuite/affine-model';
import type { ShapeToolOption } from '../gfx-tool/shape-tool.js';
const shapeMap: Record<ShapeToolOption['shapeName'], number> = {
[ShapeType.Rect]: 0,
[ShapeType.Ellipse]: 1,
[ShapeType.Diamond]: 2,
[ShapeType.Triangle]: 3,
roundedRect: 4,
};
const shapes = Object.keys(shapeMap) as ShapeToolOption['shapeName'][];
export function getNextShapeType(cur: ShapeToolOption['shapeName']) {
return shapes[(shapeMap[cur] + 1) % shapes.length];
}
@@ -0,0 +1,62 @@
import { MindmapElementModel } from '@blocksuite/affine-model';
import type { GfxModel, Viewport } from '@blocksuite/block-std/gfx';
export function isSingleMindMapNode(els: GfxModel[]) {
return els.length === 1 && els[0].group instanceof MindmapElementModel;
}
export function isElementOutsideViewport(
viewport: Viewport,
element: GfxModel,
padding: [number, number] = [0, 0]
) {
const elementBound = element.elementBound;
padding[0] /= viewport.zoom;
padding[1] /= viewport.zoom;
elementBound.x -= padding[1];
elementBound.w += padding[1];
elementBound.y -= padding[0];
elementBound.h += padding[0];
return !viewport.viewportBounds.contains(elementBound);
}
export function getNearestTranslation(
viewport: Viewport,
element: GfxModel,
padding: [number, number] = [0, 0]
) {
const viewportBound = viewport.viewportBounds;
const elementBound = element.elementBound;
let dx = 0;
let dy = 0;
if (elementBound.x - padding[1] < viewportBound.x) {
dx = viewportBound.x - (elementBound.x - padding[1]);
} else if (
elementBound.x + elementBound.w + padding[1] >
viewportBound.x + viewportBound.w
) {
dx =
viewportBound.x +
viewportBound.w -
(elementBound.x + elementBound.w + padding[1]);
}
if (elementBound.y - padding[0] < viewportBound.y) {
dy = elementBound.y - padding[0] - viewportBound.y;
} else if (
elementBound.y + elementBound.h + padding[0] >
viewportBound.y + viewportBound.h
) {
dy =
elementBound.y +
elementBound.h +
padding[0] -
(viewportBound.y + viewportBound.h);
}
return [dx, dy];
}
@@ -0,0 +1,46 @@
import type { PointerEventState } from '@blocksuite/block-std';
import type { Viewport } from '@blocksuite/block-std/gfx';
import type { IVec } from '@blocksuite/global/gfx';
const PANNING_DISTANCE = 30;
export function calPanDelta(
viewport: Viewport,
e: PointerEventState,
edgeDistance = 20
): IVec | null {
// Get viewport edge
const { left, top } = viewport;
const { width, height } = viewport;
// Get pointer position
let { x, y } = e;
const { containerOffset } = e;
x += containerOffset.x;
y += containerOffset.y;
// Check if pointer is near viewport edge
const nearLeft = x < left + edgeDistance;
const nearRight = x > left + width - edgeDistance;
const nearTop = y < top + edgeDistance;
const nearBottom = y > top + height - edgeDistance;
// If pointer is not near viewport edge, return false
if (!(nearLeft || nearRight || nearTop || nearBottom)) return null;
// Calculate move delta
let deltaX = 0;
let deltaY = 0;
// Use PANNING_DISTANCE to limit the max delta, avoid panning too fast
if (nearLeft) {
deltaX = Math.max(-PANNING_DISTANCE, x - (left + edgeDistance));
} else if (nearRight) {
deltaX = Math.min(PANNING_DISTANCE, x - (left + width - edgeDistance));
}
if (nearTop) {
deltaY = Math.max(-PANNING_DISTANCE, y - (top + edgeDistance));
} else if (nearBottom) {
deltaY = Math.min(PANNING_DISTANCE, y - (top + height - edgeDistance));
}
return [deltaX, deltaY];
}
@@ -0,0 +1,246 @@
import type { CanvasElementWithText } from '@blocksuite/affine-block-surface';
import {
type AttachmentBlockModel,
type BookmarkBlockModel,
type Connectable,
ConnectorElementModel,
type EdgelessTextBlockModel,
type EmbedBlockModel,
type EmbedFigmaModel,
type EmbedGithubModel,
type EmbedHtmlModel,
type EmbedLinkedDocModel,
type EmbedLoomModel,
type EmbedSyncedDocModel,
type EmbedYoutubeModel,
type ImageBlockModel,
MindmapElementModel,
ShapeElementModel,
TextElementModel,
} from '@blocksuite/affine-model';
import {
getElementsWithoutGroup,
isTopLevelBlock,
} from '@blocksuite/affine-shared/utils';
import type {
GfxBlockElementModel,
GfxModel,
GfxPrimitiveElementModel,
GfxToolsFullOptionValue,
Viewport,
} from '@blocksuite/block-std/gfx';
import type { PointLocation } from '@blocksuite/global/gfx';
import { Bound } from '@blocksuite/global/gfx';
import type { BlockModel } from '@blocksuite/store';
export function isMindmapNode(element: GfxBlockElementModel | GfxModel | null) {
return element?.group instanceof MindmapElementModel;
}
export function isEdgelessTextBlock(
element: BlockModel | GfxModel | null
): element is EdgelessTextBlockModel {
return (
!!element &&
'flavour' in element &&
element.flavour === 'affine:edgeless-text'
);
}
export function isImageBlock(
element: BlockModel | GfxModel | null
): element is ImageBlockModel {
return (
!!element && 'flavour' in element && element.flavour === 'affine:image'
);
}
export function isAttachmentBlock(
element: BlockModel | GfxModel | null
): element is AttachmentBlockModel {
return (
!!element && 'flavour' in element && element.flavour === 'affine:attachment'
);
}
export function isBookmarkBlock(
element: BlockModel | GfxModel | null
): element is BookmarkBlockModel {
return (
!!element && 'flavour' in element && element.flavour === 'affine:bookmark'
);
}
export function isEmbeddedBlock(
element: BlockModel | GfxModel | null
): element is EmbedBlockModel {
return (
!!element && 'flavour' in element && /affine:embed-*/.test(element.flavour)
);
}
/**
* TODO: Remove this function after the edgeless refactor completed
* This function is used to check if the block is an AI chat block for edgeless selected rect
* Should not be used in the future
* Related issue: https://linear.app/affine-design/issue/BS-1009/
* @deprecated
*/
export function isAIChatBlock(element: BlockModel | GfxModel | null) {
return (
!!element &&
'flavour' in element &&
element.flavour === 'affine:embed-ai-chat'
);
}
export function isEmbeddedLinkBlock(element: BlockModel | GfxModel | null) {
return (
isEmbeddedBlock(element) &&
!isEmbedSyncedDocBlock(element) &&
!isEmbedLinkedDocBlock(element)
);
}
export function isEmbedGithubBlock(
element: BlockModel | GfxModel | null
): element is EmbedGithubModel {
return (
!!element &&
'flavour' in element &&
element.flavour === 'affine:embed-github'
);
}
export function isEmbedYoutubeBlock(
element: BlockModel | GfxModel | null
): element is EmbedYoutubeModel {
return (
!!element &&
'flavour' in element &&
element.flavour === 'affine:embed-youtube'
);
}
export function isEmbedLoomBlock(
element: BlockModel | GfxModel | null
): element is EmbedLoomModel {
return (
!!element && 'flavour' in element && element.flavour === 'affine:embed-loom'
);
}
export function isEmbedFigmaBlock(
element: BlockModel | GfxModel | null
): element is EmbedFigmaModel {
return (
!!element &&
'flavour' in element &&
element.flavour === 'affine:embed-figma'
);
}
export function isEmbedLinkedDocBlock(
element: BlockModel | GfxModel | null
): element is EmbedLinkedDocModel {
return (
!!element &&
'flavour' in element &&
element.flavour === 'affine:embed-linked-doc'
);
}
export function isEmbedSyncedDocBlock(
element: BlockModel | GfxModel | null
): element is EmbedSyncedDocModel {
return (
!!element &&
'flavour' in element &&
element.flavour === 'affine:embed-synced-doc'
);
}
export function isEmbedHtmlBlock(
element: BlockModel | GfxModel | null
): element is EmbedHtmlModel {
return (
!!element && 'flavour' in element && element.flavour === 'affine:embed-html'
);
}
export function isCanvasElement(
selectable: GfxModel | BlockModel | null
): selectable is GfxPrimitiveElementModel {
return !isTopLevelBlock(selectable);
}
export function isCanvasElementWithText(
element: GfxModel
): element is CanvasElementWithText {
return (
element instanceof TextElementModel || element instanceof ShapeElementModel
);
}
export function isConnectable(
element: GfxModel | null
): element is Connectable {
return !!element && element.connectable;
}
export function getSelectionBoxBound(viewport: Viewport, bound: Bound) {
const { w, h } = bound;
const [x, y] = viewport.toViewCoord(bound.x, bound.y);
return new DOMRect(x, y, w * viewport.zoom, h * viewport.zoom);
}
// https://developer.mozilla.org/en-US/docs/Web/CSS/cursor
export function getCursorMode(edgelessTool: GfxToolsFullOptionValue | null) {
if (!edgelessTool) {
return 'default';
}
switch (edgelessTool.type) {
case 'default':
return 'default';
case 'pan':
return edgelessTool.panning ? 'grabbing' : 'grab';
case 'brush':
case 'eraser':
case 'shape':
case 'connector':
case 'frame':
case 'lasso':
return 'crosshair';
case 'text':
return 'text';
default:
return 'default';
}
}
export type SelectableProps = {
bound: Bound;
rotate: number;
path?: PointLocation[];
};
export function getSelectableBounds(
selected: GfxModel[]
): Map<string, SelectableProps> {
const bounds = new Map();
getElementsWithoutGroup(selected).forEach(ele => {
const bound = Bound.deserialize(ele.xywh);
const props: SelectableProps = {
bound,
rotate: ele.rotate,
};
if (isCanvasElement(ele) && ele instanceof ConnectorElementModel) {
props.path = ele.absolutePath.map(p => p.clone());
}
bounds.set(ele.id, props);
});
return bounds;
}
@@ -0,0 +1,734 @@
import { Overlay } from '@blocksuite/affine-block-surface';
import { ConnectorElementModel } from '@blocksuite/affine-model';
import type { GfxModel } from '@blocksuite/block-std/gfx';
import { almostEqual, Bound, Point } from '@blocksuite/global/gfx';
interface Distance {
horiz?: {
/**
* the minimum x moving distance to align with other bound
*/
distance: number;
/**
* the indices of the align position
*/
alignPositionIndices: number[];
};
vert?: {
/**
* the minimum y moving distance to align with other bound
*/
distance: number;
/**
* the indices of the align position
*/
alignPositionIndices: number[];
};
}
const ALIGN_THRESHOLD = 8;
const DISTRIBUTION_LINE_OFFSET = 1;
const STROKE_WIDTH = 2;
export class SnapManager extends Overlay {
static override overlayName: string = 'snap-manager';
private _skippedElements: Set<GfxModel> = new Set();
private _referenceBounds: {
vertical: Bound[];
horizontal: Bound[];
all: Bound[];
} = {
vertical: [],
horizontal: [],
all: [],
};
/**
* This variable contains reference lines that are
* generated by the 'Distribute Alignment' function. This alignment is achieved
* by evenly distributing elements based on specified alignment rules.
* These lines serve as a guide for achieving equal spacing or distribution
* among multiple graphics or design elements.
*/
private _distributedAlignLines: [Point, Point][] = [];
/**
* This variable holds reference lines that are calculated
* based on the self-alignment of the graphics. This alignment is determined
* according to various aspects of the graphic itself, such as the center, edges,
* corners, etc. It essentially represents the guidelines for the positioning
* and alignment within the individual graphic elements.
*/
private _intraGraphicAlignLines: [Point, Point][] = [];
override clear() {
super.clear();
this._referenceBounds = {
vertical: [],
horizontal: [],
all: [],
};
this._intraGraphicAlignLines = [];
this._distributedAlignLines = [];
this._skippedElements.clear();
}
private _alignDistributeHorizontally(
rst: { dx: number; dy: number },
bound: Bound,
threshold: number,
viewport: { zoom: number }
) {
const wBoxes: Bound[] = [];
this._referenceBounds.horizontal.forEach(box => {
if (box.isHorizontalCross(bound)) {
wBoxes.push(box);
}
});
wBoxes.sort((a, b) => a.center[0] - b.center[0]);
let dif = Infinity;
let min = Infinity;
let aveDis = Number.MAX_SAFE_INTEGER;
let curBound!: {
leftIdx: number;
rightIdx: number;
spacing: number;
points: [Point, Point][];
};
for (let i = 0; i < wBoxes.length; i++) {
for (let j = i + 1; j < wBoxes.length; j++) {
let lb = wBoxes[i],
rb = wBoxes[j];
// it means these bound need to be horizontally across
if (!lb.isHorizontalCross(rb) || lb.isIntersectWithBound(rb)) continue;
let switchFlag = false;
// exchange lb and rb to make sure lb is on the left of rb
if (rb.maxX < lb.minX) {
const temp = rb;
rb = lb;
lb = temp;
switchFlag = true;
}
let _centerX = 0;
const updateDif = () => {
dif = Math.abs(bound.center[0] - _centerX);
const curAveDis =
(Math.abs(lb.center[0] - bound.center[0]) +
Math.abs(rb.center[0] - bound.center[0])) /
2;
if (
dif <= threshold &&
(dif < min || (almostEqual(dif, min) && curAveDis < aveDis))
) {
min = dif;
aveDis = curAveDis;
rst.dx = _centerX - bound.center[0];
/**
* calculate points to draw
*/
const ys = [lb.minY, lb.maxY, rb.minY, rb.maxY].sort(
(a, b) => a - b
);
const y = (ys[1] + ys[2]) / 2;
const offset = DISTRIBUTION_LINE_OFFSET / viewport.zoom;
const xs = [
_centerX - bound.w / 2,
_centerX + bound.w / 2,
rb.minX,
rb.maxX,
lb.minX,
lb.maxX,
].sort((a, b) => a - b);
curBound = {
leftIdx: switchFlag ? j : i,
rightIdx: switchFlag ? i : j,
spacing: xs[2] - xs[1],
points: [
[new Point(xs[1] + offset, y), new Point(xs[2] - offset, y)],
[new Point(xs[3] + offset, y), new Point(xs[4] - offset, y)],
],
};
}
};
/**
* align between left and right bound
*/
if (lb.horizontalDistance(rb) > bound.w) {
_centerX = (lb.maxX + rb.minX) / 2;
updateDif();
}
/**
* align to the left bounds
*/
_centerX = lb.minX - (rb.minX - lb.maxX) - bound.w / 2;
updateDif();
/** align right */
_centerX = rb.minX - lb.maxX + rb.maxX + bound.w / 2;
updateDif();
}
}
// find the boxes that has same spacing
if (curBound) {
const { leftIdx, rightIdx, spacing, points } = curBound;
this._distributedAlignLines.push(...points);
{
let curLeftBound = wBoxes[leftIdx];
for (let i = leftIdx - 1; i >= 0; i--) {
if (almostEqual(wBoxes[i].maxX, curLeftBound.minX - spacing)) {
const targetBound = wBoxes[i];
const ys = [
targetBound.minY,
targetBound.maxY,
curLeftBound.minY,
curLeftBound.maxY,
].sort((a, b) => a - b);
const y = (ys[1] + ys[2]) / 2;
this._distributedAlignLines.push([
new Point(wBoxes[i].maxX, y),
new Point(curLeftBound.minX, y),
]);
curLeftBound = wBoxes[i];
}
}
}
{
let curRightBound = wBoxes[rightIdx];
for (let i = rightIdx + 1; i < wBoxes.length; i++) {
if (almostEqual(wBoxes[i].minX, curRightBound.maxX + spacing)) {
const targetBound = wBoxes[i];
const ys = [
targetBound.minY,
targetBound.maxY,
curRightBound.minY,
curRightBound.maxY,
].sort((a, b) => a - b);
const y = (ys[1] + ys[2]) / 2;
this._distributedAlignLines.push([
new Point(curRightBound.maxX, y),
new Point(wBoxes[i].minX, y),
]);
curRightBound = wBoxes[i];
}
}
}
}
}
private _alignDistributeVertically(
rst: { dx: number; dy: number },
bound: Bound,
threshold: number,
viewport: { zoom: number }
) {
const hBoxes: Bound[] = [];
this._referenceBounds.vertical.forEach(box => {
if (box.isVerticalCross(bound)) {
hBoxes.push(box);
}
});
hBoxes.sort((a, b) => a.center[0] - b.center[0]);
let dif = Infinity;
let min = Infinity;
let aveDis = Number.MAX_SAFE_INTEGER;
let curBound!: {
upperIdx: number;
lowerIdx: number;
spacing: number;
points: [Point, Point][];
};
for (let i = 0; i < hBoxes.length; i++) {
for (let j = i + 1; j < hBoxes.length; j++) {
let ub = hBoxes[i],
db = hBoxes[j];
if (!ub.isVerticalCross(db) || ub.isIntersectWithBound(db)) continue;
let switchFlag = false;
if (db.maxY < ub.minX) {
const temp = ub;
ub = db;
db = temp;
switchFlag = true;
}
/** align middle */
let _centerY = 0;
const updateDiff = () => {
dif = Math.abs(bound.center[1] - _centerY);
const curAveDis =
(Math.abs(ub.center[1] - bound.center[1]) +
Math.abs(db.center[1] - bound.center[1])) /
2;
if (
dif <= threshold &&
(dif < min || (almostEqual(dif, min) && curAveDis < aveDis))
) {
min = dif;
rst.dy = _centerY - bound.center[1];
/**
* calculate points to draw
*/
const xs = [ub.minX, ub.maxX, db.minX, db.maxX].sort(
(a, b) => a - b
);
const x = (xs[1] + xs[2]) / 2;
const offset = DISTRIBUTION_LINE_OFFSET / viewport.zoom;
const ys = [
_centerY - bound.h / 2,
_centerY + bound.h / 2,
db.minY,
db.maxY,
ub.minY,
ub.maxY,
].sort((a, b) => a - b);
curBound = {
upperIdx: switchFlag ? j : i,
lowerIdx: switchFlag ? i : j,
spacing: ys[2] - ys[1],
points: [
[new Point(x, ys[1] + offset), new Point(x, ys[2] - offset)],
[new Point(x, ys[3] + offset), new Point(x, ys[4] - offset)],
],
};
}
};
if (ub.verticalDistance(db) > bound.h) {
_centerY = (ub.maxY + db.minY) / 2;
updateDiff();
}
/** align upper */
_centerY = ub.minY - (db.minY - ub.maxY) - bound.h / 2;
updateDiff();
/** align lower */
_centerY = db.minY - ub.maxY + db.maxY + bound.h / 2;
updateDiff();
}
}
// find the boxes that has same spacing
if (curBound) {
const { upperIdx, lowerIdx, spacing, points } = curBound;
this._distributedAlignLines.push(...points);
{
let curUpperBound = hBoxes[upperIdx];
for (let i = upperIdx - 1; i >= 0; i--) {
if (almostEqual(hBoxes[i].maxY, curUpperBound.minY - spacing)) {
const targetBound = hBoxes[i];
const xs = [
targetBound.minX,
targetBound.maxX,
curUpperBound.minX,
curUpperBound.maxX,
].sort((a, b) => a - b);
const x = (xs[1] + xs[2]) / 2;
this._distributedAlignLines.push([
new Point(x, hBoxes[i].maxY),
new Point(x, curUpperBound.minY),
]);
curUpperBound = hBoxes[i];
}
}
}
{
let curLowerBound = hBoxes[lowerIdx];
for (let i = lowerIdx + 1; i < hBoxes.length; i++) {
if (almostEqual(hBoxes[i].minY, curLowerBound.maxY + spacing)) {
const targetBound = hBoxes[i];
const xs = [
targetBound.minX,
targetBound.maxX,
curLowerBound.minX,
curLowerBound.maxX,
].sort((a, b) => a - b);
const x = (xs[1] + xs[2]) / 2;
this._distributedAlignLines.push([
new Point(x, curLowerBound.maxY),
new Point(x, hBoxes[i].minY),
]);
curLowerBound = hBoxes[i];
}
}
}
}
}
private _calculateClosestDistances(bound: Bound, other: Bound): Distance {
// Calculate center-to-center and center-to-side distances
const centerXDistance = other.center[0] - bound.center[0];
const centerYDistance = other.center[1] - bound.center[1];
// Calculate center-to-side distances
const leftDistance = other.minX - bound.center[0];
const rightDistance = other.maxX - bound.center[0];
const topDistance = other.minY - bound.center[1];
const bottomDistance = other.maxY - bound.center[1];
// Calculate side-to-side distances
const leftToLeft = other.minX - bound.minX;
const leftToRight = other.maxX - bound.minX;
const rightToLeft = other.minX - bound.maxX;
const rightToRight = other.maxX - bound.maxX;
const topToTop = other.minY - bound.minY;
const topToBottom = other.maxY - bound.minY;
const bottomToTop = other.minY - bound.maxY;
const bottomToBottom = other.maxY - bound.maxY;
// calculate side-to-center distances
const rightToCenter = other.center[0] - bound.maxX;
const leftToCenter = other.center[0] - bound.minX;
const topToCenter = other.center[1] - bound.minY;
const bottomToCenter = other.center[1] - bound.maxY;
const xDistances = [
centerXDistance,
leftDistance,
rightDistance,
leftToLeft,
leftToRight,
rightToLeft,
rightToRight,
rightToCenter,
leftToCenter,
];
const yDistances = [
centerYDistance,
topDistance,
bottomDistance,
topToTop,
topToBottom,
bottomToTop,
bottomToBottom,
topToCenter,
bottomToCenter,
];
// Get absolute distances
const xDistancesAbs = xDistances.map(Math.abs);
const yDistancesAbs = yDistances.map(Math.abs);
// Get closest distances
const closestX = Math.min(...xDistancesAbs);
const closestY = Math.min(...yDistancesAbs);
const threshold = ALIGN_THRESHOLD / this.gfx.viewport.zoom;
// the x and y distances will be useful for locating the align point
return {
horiz:
closestX <= threshold
? {
distance: xDistances[xDistancesAbs.indexOf(closestX)],
get alignPositionIndices() {
const indices: number[] = [];
xDistancesAbs.forEach(
(val, idx) => almostEqual(val, closestX) && indices.push(idx)
);
return indices;
},
}
: undefined,
vert:
closestY <= threshold
? {
distance: yDistances[yDistancesAbs.indexOf(closestY)],
get alignPositionIndices() {
const indices: number[] = [];
yDistancesAbs.forEach(
(val, idx) => almostEqual(val, closestY) && indices.push(idx)
);
return indices;
},
}
: undefined,
};
}
/**
* Update horizontal moving distance `rst.dx` to align with other bound.
* Also, update the align points to draw.
* @param rst
* @param bound
* @param other
* @param distance
*/
private _updateXAlignPoint(
rst: { dx: number; dy: number },
bound: Bound,
other: Bound,
distance: Distance
) {
if (!distance.horiz) return;
const { distance: dx, alignPositionIndices: distanceIndices } =
distance.horiz;
const offset = STROKE_WIDTH / this.gfx.viewport.zoom / 2;
const alignXPosition = [
other.center[0],
other.minX + offset,
other.maxX - offset,
bound.minX + dx + offset,
bound.minX + dx + offset,
bound.maxX + dx - offset,
bound.maxX + dx - offset,
other.center[0] - offset,
other.center[0] + offset,
];
rst.dx = dx;
const dy = distance.vert?.distance ?? 0;
const top = Math.min(bound.minY + dy, other.minY);
const down = Math.max(bound.maxY + dy, other.maxY);
this._intraGraphicAlignLines.push(
...distanceIndices.map(
idx =>
[
new Point(alignXPosition[idx], top),
new Point(alignXPosition[idx], down),
] as [Point, Point]
)
);
}
/**
* Update vertical moving distance `rst.dy` to align with other bound.
* Also, update the align points to draw.
* @param rst
* @param bound
* @param other
* @param distance
*/
private _updateYAlignPoint(
rst: { dx: number; dy: number },
bound: Bound,
other: Bound,
distance: Distance
) {
if (!distance.vert) return;
const { distance: dy, alignPositionIndices } = distance.vert;
const offset = STROKE_WIDTH / this.gfx.viewport.zoom / 2;
const alignXPosition = [
other.center[1] - offset,
other.minY + offset,
other.maxY - offset,
bound.minY + dy + offset,
bound.minY + dy + offset,
bound.maxY + dy - offset,
bound.maxY + dy - offset,
other.center[1] + offset,
other.center[1] - offset,
];
rst.dy = dy;
const dx = distance.horiz?.distance ?? 0;
const left = Math.min(bound.minX + dx, other.minX);
const right = Math.max(bound.maxX + dx, other.maxX);
this._intraGraphicAlignLines.push(
...alignPositionIndices.map(
idx =>
[
new Point(left, alignXPosition[idx]),
new Point(right, alignXPosition[idx]),
] as [Point, Point]
)
);
}
align(bound: Bound): { dx: number; dy: number } {
const rst = { dx: 0, dy: 0 };
const threshold = ALIGN_THRESHOLD / this.gfx.viewport.zoom;
const { viewport } = this.gfx;
this._intraGraphicAlignLines = [];
this._distributedAlignLines = [];
this._updateAlignCandidates(bound);
for (const other of this._referenceBounds.all) {
const closestDistances = this._calculateClosestDistances(bound, other);
if (closestDistances.horiz) {
this._updateXAlignPoint(rst, bound, other, closestDistances);
}
if (closestDistances.vert) {
this._updateYAlignPoint(rst, bound, other, closestDistances);
}
}
// point align priority is higher than distribute align
if (rst.dx === 0) {
this._alignDistributeHorizontally(rst, bound, threshold, viewport);
}
if (rst.dy === 0) {
this._alignDistributeVertically(rst, bound, threshold, viewport);
}
this._renderer?.refresh();
return rst;
}
override render(ctx: CanvasRenderingContext2D) {
if (
this._intraGraphicAlignLines.length === 0 &&
this._distributedAlignLines.length === 0
)
return;
const { viewport } = this.gfx;
const strokeWidth = STROKE_WIDTH / viewport.zoom;
ctx.strokeStyle = '#8B5CF6';
ctx.lineWidth = strokeWidth;
ctx.beginPath();
this._intraGraphicAlignLines.forEach(line => {
let d = '';
if (line[0].x === line[1].x) {
const x = line[0].x;
const minY = Math.min(line[0].y, line[1].y);
const maxY = Math.max(line[0].y, line[1].y);
d = `M${x},${minY}L${x},${maxY}`;
} else {
const y = line[0].y;
const minX = Math.min(line[0].x, line[1].x);
const maxX = Math.max(line[0].x, line[1].x);
d = `M${minX},${y}L${maxX},${y}`;
}
ctx.stroke(new Path2D(d));
});
ctx.strokeStyle = '#CC4187';
this._distributedAlignLines.forEach(line => {
const bar = 10 / viewport.zoom;
let d = '';
if (line[0].x === line[1].x) {
const x = line[0].x;
const minY = Math.min(line[0].y, line[1].y);
const maxY = Math.max(line[0].y, line[1].y);
d = `M${x},${minY}L${x},${maxY}
M${x - bar},${minY}L${x + bar},${minY}
M${x - bar},${maxY}L${x + bar},${maxY} `;
} else {
const y = line[0].y;
const minX = Math.min(line[0].x, line[1].x);
const maxX = Math.max(line[0].x, line[1].x);
d = `M${minX},${y}L${maxX},${y}
M${minX},${y - bar}L${minX},${y + bar}
M${maxX},${y - bar}L${maxX},${y + bar}`;
}
ctx.stroke(new Path2D(d));
});
}
private _updateAlignCandidates(movingBound: Bound) {
movingBound = movingBound.expand(ALIGN_THRESHOLD * this.gfx.viewport.zoom);
const viewportBound = this.gfx.viewport.viewportBounds;
const horizAreaBound = new Bound(
Math.min(movingBound.x, viewportBound.x),
movingBound.y,
Math.max(movingBound.w, viewportBound.w),
movingBound.h
);
const vertAreaBound = new Bound(
movingBound.x,
Math.min(movingBound.y, viewportBound.y),
movingBound.w,
Math.max(movingBound.h, viewportBound.h)
);
const { _skippedElements: skipped } = this;
const vertCandidates = this.gfx.grid.search(vertAreaBound, {
useSet: true,
});
const horizCandidates = this.gfx.grid.search(horizAreaBound, {
useSet: true,
});
const verticalBounds: Bound[] = [];
const horizBounds: Bound[] = [];
const allBounds: Bound[] = [];
vertCandidates.forEach(candidate => {
if (skipped.has(candidate) || candidate instanceof ConnectorElementModel)
return;
verticalBounds.push(candidate.elementBound);
allBounds.push(candidate.elementBound);
});
horizCandidates.forEach(candidate => {
if (skipped.has(candidate) || candidate instanceof ConnectorElementModel)
return;
horizBounds.push(candidate.elementBound);
allBounds.push(candidate.elementBound);
});
this._referenceBounds = {
horizontal: horizBounds,
vertical: verticalBounds,
all: allBounds,
};
}
setMovingElements(
movingElements: GfxModel[],
excludes: GfxModel[] = []
): Bound {
if (movingElements.length === 0) return new Bound();
const skipped = new Set(movingElements);
excludes.forEach(e => skipped.add(e));
this._skippedElements = skipped;
return movingElements.reduce(
(prev, element) => prev.unite(element.elementBound),
movingElements[0].elementBound
);
}
}
@@ -0,0 +1,229 @@
import {
CanvasElementType,
EdgelessCRUDIdentifier,
type IModelCoord,
TextUtils,
} from '@blocksuite/affine-block-surface';
import type {
ConnectorElementModel,
FrameBlockModel,
GroupElementModel,
} from '@blocksuite/affine-model';
import { ShapeElementModel, TextElementModel } from '@blocksuite/affine-model';
import type { PointerEventState } from '@blocksuite/block-std';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import type { IVec } from '@blocksuite/global/gfx';
import { Bound } from '@blocksuite/global/gfx';
import * as Y from 'yjs';
import { EdgelessConnectorLabelEditor } from '../components/text/edgeless-connector-label-editor.js';
import { EdgelessFrameTitleEditor } from '../components/text/edgeless-frame-title-editor.js';
import { EdgelessGroupTitleEditor } from '../components/text/edgeless-group-title-editor.js';
import { EdgelessShapeTextEditor } from '../components/text/edgeless-shape-text-editor.js';
import { EdgelessTextEditor } from '../components/text/edgeless-text-editor.js';
import type { EdgelessRootBlockComponent } from '../edgeless-root-block.js';
export function mountTextElementEditor(
textElement: TextElementModel,
edgeless: EdgelessRootBlockComponent,
focusCoord?: IModelCoord
) {
if (!edgeless.mountElm) {
throw new BlockSuiteError(
ErrorCode.ValueNotExists,
"edgeless block's mount point does not exist"
);
}
let cursorIndex = textElement.text.length;
if (focusCoord) {
cursorIndex = Math.min(
TextUtils.getCursorByCoord(textElement, focusCoord),
cursorIndex
);
}
const textEditor = new EdgelessTextEditor();
textEditor.edgeless = edgeless;
textEditor.element = textElement;
edgeless.append(textEditor);
textEditor.updateComplete
.then(() => {
textEditor.inlineEditor?.focusIndex(cursorIndex);
})
.catch(console.error);
edgeless.gfx.tool.setTool('default');
edgeless.gfx.selection.set({
elements: [textElement.id],
editing: true,
});
}
export function mountShapeTextEditor(
shapeElement: ShapeElementModel,
edgeless: EdgelessRootBlockComponent
) {
if (!edgeless.mountElm) {
throw new BlockSuiteError(
ErrorCode.ValueNotExists,
"edgeless block's mount point does not exist"
);
}
if (!shapeElement.text) {
const text = new Y.Text();
edgeless.std
.get(EdgelessCRUDIdentifier)
.updateElement(shapeElement.id, { text });
}
const updatedElement = edgeless.service.crud.getElementById(shapeElement.id);
if (!(updatedElement instanceof ShapeElementModel)) {
console.error('Cannot mount text editor on a non-shape element');
return;
}
const shapeEditor = new EdgelessShapeTextEditor();
shapeEditor.element = updatedElement;
shapeEditor.edgeless = edgeless;
shapeEditor.mountEditor = mountShapeTextEditor;
edgeless.mountElm.append(shapeEditor);
edgeless.gfx.tool.setTool('default');
edgeless.gfx.selection.set({
elements: [shapeElement.id],
editing: true,
});
}
export function mountFrameTitleEditor(
frame: FrameBlockModel,
edgeless: EdgelessRootBlockComponent
) {
if (!edgeless.mountElm) {
throw new BlockSuiteError(
ErrorCode.ValueNotExists,
"edgeless block's mount point does not exist"
);
}
const frameEditor = new EdgelessFrameTitleEditor();
frameEditor.frameModel = frame;
frameEditor.edgeless = edgeless;
edgeless.mountElm.append(frameEditor);
edgeless.gfx.tool.setTool('default');
edgeless.gfx.selection.set({
elements: [frame.id],
editing: true,
});
}
export function mountGroupTitleEditor(
group: GroupElementModel,
edgeless: EdgelessRootBlockComponent
) {
if (!edgeless.mountElm) {
throw new BlockSuiteError(
ErrorCode.ValueNotExists,
"edgeless block's mount point does not exist"
);
}
const groupEditor = new EdgelessGroupTitleEditor();
groupEditor.group = group;
groupEditor.edgeless = edgeless;
edgeless.mountElm.append(groupEditor);
edgeless.gfx.tool.setTool('default');
edgeless.gfx.selection.set({
elements: [group.id],
editing: true,
});
}
/**
* @deprecated
*
* Canvas Text has been deprecated
*/
export function addText(
edgeless: EdgelessRootBlockComponent,
event: PointerEventState
) {
const [x, y] = edgeless.service.viewport.toModelCoord(event.x, event.y);
const selected = edgeless.service.gfx.getElementByPoint(x, y);
if (!selected) {
const [modelX, modelY] = edgeless.service.viewport.toModelCoord(
event.x,
event.y
);
const id = edgeless.std
.get(EdgelessCRUDIdentifier)
.addElement(CanvasElementType.TEXT, {
xywh: new Bound(modelX, modelY, 32, 32).serialize(),
text: new Y.Text(),
});
if (!id) return;
edgeless.doc.captureSync();
const textElement = edgeless.service.crud.getElementById(id);
if (!textElement) return;
if (textElement instanceof TextElementModel) {
mountTextElementEditor(textElement, edgeless);
}
}
}
export function mountConnectorLabelEditor(
connector: ConnectorElementModel,
edgeless: EdgelessRootBlockComponent,
point?: IVec
) {
if (!edgeless.mountElm) {
throw new BlockSuiteError(
ErrorCode.ValueNotExists,
"edgeless block's mount point does not exist"
);
}
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;
editor.edgeless = edgeless;
edgeless.mountElm.append(editor);
editor.updateComplete
.then(() => {
editor.inlineEditor?.focusEnd();
})
.catch(console.error);
edgeless.gfx.tool.setTool('default');
edgeless.gfx.selection.set({
elements: [connector.id],
editing: true,
});
}
@@ -0,0 +1,481 @@
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/gfx';
import { Bound } from '@blocksuite/global/gfx';
import { DisposableGroup, Slot } from '@blocksuite/global/slot';
import { assertType, noop } 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();
}
}