mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-10 13:38:49 +08:00
chore(editor): reorg packages (#10702)
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user