mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-25 02:13:00 +08:00
refactor(editor): unify directories naming (#11516)
**Directory Structure Changes** - Renamed multiple block-related directories by removing the "block-" prefix: - `block-attachment` → `attachment` - `block-bookmark` → `bookmark` - `block-callout` → `callout` - `block-code` → `code` - `block-data-view` → `data-view` - `block-database` → `database` - `block-divider` → `divider` - `block-edgeless-text` → `edgeless-text` - `block-embed` → `embed`
This commit is contained in:
117
blocksuite/affine/blocks/root/src/edgeless/clipboard/canvas.ts
Normal file
117
blocksuite/affine/blocks/root/src/edgeless/clipboard/canvas.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
CanvasElementType,
|
||||
type ClipboardConfigCreationContext,
|
||||
EdgelessCRUDIdentifier,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import type { Connection } from '@blocksuite/affine-model';
|
||||
import { TelemetryProvider } from '@blocksuite/affine-shared/services';
|
||||
import { Bound, type SerializedXYWH, Vec } from '@blocksuite/global/gfx';
|
||||
import type { BlockStdScope } from '@blocksuite/std';
|
||||
import type {
|
||||
GfxPrimitiveElementModel,
|
||||
SerializedElement,
|
||||
} from '@blocksuite/std/gfx';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
const { GROUP, MINDMAP, CONNECTOR } = CanvasElementType;
|
||||
|
||||
export function createCanvasElement(
|
||||
std: BlockStdScope,
|
||||
clipboardData: SerializedElement,
|
||||
context: ClipboardConfigCreationContext,
|
||||
newXYWH: SerializedXYWH
|
||||
) {
|
||||
if (clipboardData.type === GROUP) {
|
||||
const yMap = new Y.Map();
|
||||
const children = clipboardData.children ?? {};
|
||||
|
||||
for (const [key, value] of Object.entries(children)) {
|
||||
const newKey = context.oldToNewIdMap.get(key);
|
||||
if (!newKey) {
|
||||
console.error(
|
||||
`Copy failed: cannot find the copied child in group, key: ${key}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
yMap.set(newKey, value);
|
||||
}
|
||||
clipboardData.children = yMap;
|
||||
clipboardData.xywh = newXYWH;
|
||||
} else if (clipboardData.type === MINDMAP) {
|
||||
const yMap = new Y.Map();
|
||||
const children = clipboardData.children ?? {};
|
||||
|
||||
for (const [oldKey, oldValue] of Object.entries(children)) {
|
||||
const newKey = context.oldToNewIdMap.get(oldKey);
|
||||
const newValue = {
|
||||
...oldValue,
|
||||
};
|
||||
if (!newKey) {
|
||||
console.error(
|
||||
`Copy failed: cannot find the copied node in mind map, key: ${oldKey}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (oldValue.parent) {
|
||||
const newParent = context.oldToNewIdMap.get(oldValue.parent);
|
||||
if (!newParent) {
|
||||
console.error(
|
||||
`Copy failed: cannot find the copied node in mind map, parent: ${oldValue.parent}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
newValue.parent = newParent;
|
||||
}
|
||||
|
||||
yMap.set(newKey, newValue);
|
||||
}
|
||||
clipboardData.children = yMap;
|
||||
} else if (clipboardData.type === CONNECTOR) {
|
||||
const source = clipboardData.source as Connection;
|
||||
const target = clipboardData.target as Connection;
|
||||
|
||||
const oldBound = Bound.deserialize(clipboardData.xywh);
|
||||
const newBound = Bound.deserialize(newXYWH);
|
||||
const offset = Vec.sub([newBound.x, newBound.y], [oldBound.x, oldBound.y]);
|
||||
|
||||
if (source.id) {
|
||||
source.id = context.oldToNewIdMap.get(source.id) ?? source.id;
|
||||
} else if (source.position) {
|
||||
source.position = Vec.add(source.position, offset);
|
||||
}
|
||||
|
||||
if (target.id) {
|
||||
target.id = context.oldToNewIdMap.get(target.id) ?? target.id;
|
||||
} else if (target.position) {
|
||||
target.position = Vec.add(target.position, offset);
|
||||
}
|
||||
} else {
|
||||
clipboardData.xywh = newXYWH;
|
||||
}
|
||||
|
||||
clipboardData.lockedBySelf = false;
|
||||
|
||||
const crud = std.get(EdgelessCRUDIdentifier);
|
||||
|
||||
const id = crud.addElement(
|
||||
clipboardData.type as CanvasElementType,
|
||||
clipboardData
|
||||
);
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', {
|
||||
control: 'canvas:paste',
|
||||
page: 'whiteboard editor',
|
||||
module: 'toolbar',
|
||||
segment: 'toolbar',
|
||||
type: clipboardData.type as string,
|
||||
});
|
||||
const element = crud.getElementById(id) as GfxPrimitiveElementModel;
|
||||
if (!element) {
|
||||
console.error(`Copy failed: cannot find the copied element, id: ${id}`);
|
||||
return null;
|
||||
}
|
||||
return element;
|
||||
}
|
||||
@@ -0,0 +1,701 @@
|
||||
import { addAttachments } from '@blocksuite/affine-block-attachment';
|
||||
import { EdgelessFrameManagerIdentifier } from '@blocksuite/affine-block-frame';
|
||||
import { addImages } from '@blocksuite/affine-block-image';
|
||||
import {
|
||||
EdgelessCRUDIdentifier,
|
||||
ExportManager,
|
||||
getSurfaceComponent,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import { splitIntoLines } from '@blocksuite/affine-gfx-text';
|
||||
import type { ShapeElementModel } from '@blocksuite/affine-model';
|
||||
import {
|
||||
BookmarkStyles,
|
||||
DEFAULT_NOTE_HEIGHT,
|
||||
DEFAULT_NOTE_WIDTH,
|
||||
FrameBlockModel,
|
||||
MAX_IMAGE_WIDTH,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
ClipboardAdapter,
|
||||
decodeClipboardBlobs,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import {
|
||||
CANVAS_EXPORT_IGNORE_TAGS,
|
||||
EMBED_CARD_HEIGHT,
|
||||
EMBED_CARD_WIDTH,
|
||||
} from '@blocksuite/affine-shared/consts';
|
||||
import {
|
||||
EmbedOptionProvider,
|
||||
ParseDocUrlProvider,
|
||||
TelemetryProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
isInsidePageEditor,
|
||||
isTopLevelBlock,
|
||||
isUrlInClipboard,
|
||||
matchModels,
|
||||
referenceToNode,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import {
|
||||
Bound,
|
||||
getCommonBound,
|
||||
type IBound,
|
||||
type IVec,
|
||||
Vec,
|
||||
} from '@blocksuite/global/gfx';
|
||||
import type {
|
||||
EditorHost,
|
||||
SurfaceSelection,
|
||||
UIEventStateContext,
|
||||
} from '@blocksuite/std';
|
||||
import {
|
||||
compareLayer,
|
||||
type GfxBlockElementModel,
|
||||
GfxControllerIdentifier,
|
||||
type GfxPrimitiveElementModel,
|
||||
type SerializedElement,
|
||||
} from '@blocksuite/std/gfx';
|
||||
import { type BlockSnapshot, type SliceSnapshot } from '@blocksuite/store';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { PageClipboard } from '../../clipboard/index.js';
|
||||
import { getSortedCloneElements } from '../utils/clone-utils.js';
|
||||
import { isCanvasElementWithText } from '../utils/query.js';
|
||||
import { createElementsFromClipboardDataCommand } from './command.js';
|
||||
import {
|
||||
isPureFileInClipboard,
|
||||
prepareClipboardData,
|
||||
tryGetSvgFromClipboard,
|
||||
} from './utils.js';
|
||||
|
||||
const BLOCKSUITE_SURFACE = 'blocksuite/surface';
|
||||
|
||||
const IMAGE_PADDING = 5; // for rotated shapes some padding is needed
|
||||
|
||||
interface CanvasExportOptions {
|
||||
dpr?: number;
|
||||
padding?: number;
|
||||
background?: string;
|
||||
}
|
||||
|
||||
export class EdgelessClipboardController extends PageClipboard {
|
||||
static override key = 'affine-edgeless-clipboard';
|
||||
|
||||
private readonly _initEdgelessClipboard = () => {
|
||||
this.std.event.add('copy', ctx => {
|
||||
const { surfaceSelections, selectedIds } = this.selectionManager;
|
||||
|
||||
if (selectedIds.length === 0) return false;
|
||||
|
||||
this._onCopy(ctx, surfaceSelections).catch(console.error);
|
||||
return;
|
||||
});
|
||||
|
||||
this.std.event.add('paste', ctx => {
|
||||
this._onPaste(ctx).catch(console.error);
|
||||
});
|
||||
|
||||
this.std.event.add('cut', ctx => {
|
||||
this._onCut(ctx).catch(console.error);
|
||||
});
|
||||
};
|
||||
|
||||
private readonly _onCopy = async (
|
||||
_context: UIEventStateContext,
|
||||
surfaceSelection: SurfaceSelection[]
|
||||
) => {
|
||||
const event = _context.get('clipboardState').raw;
|
||||
event.preventDefault();
|
||||
|
||||
const elements = getSortedCloneElements(
|
||||
this.selectionManager.selectedElements
|
||||
);
|
||||
|
||||
// when note active, handle copy like page mode
|
||||
if (surfaceSelection[0] && surfaceSelection[0].editing) {
|
||||
// use build-in copy handler in rich-text when copy in surface text element
|
||||
if (isCanvasElementWithText(elements[0])) return;
|
||||
this.onPageCopy(_context);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.std.clipboard.writeToClipboard(async _items => {
|
||||
const data = await prepareClipboardData(elements, this.std);
|
||||
return {
|
||||
..._items,
|
||||
[BLOCKSUITE_SURFACE]: JSON.stringify(data),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
private readonly _onCut = async (_context: UIEventStateContext) => {
|
||||
const { surfaceSelections, selectedElements } = this.selectionManager;
|
||||
|
||||
if (selectedElements.length === 0) return;
|
||||
|
||||
const event = _context.get('clipboardState').event;
|
||||
event.preventDefault();
|
||||
|
||||
await this._onCopy(_context, surfaceSelections);
|
||||
|
||||
if (surfaceSelections[0]?.editing) {
|
||||
// use build-in cut handler in rich-text when cut in surface text element
|
||||
if (isCanvasElementWithText(selectedElements[0])) return;
|
||||
this.onPageCut(_context);
|
||||
return;
|
||||
}
|
||||
|
||||
const elements = getSortedCloneElements(
|
||||
this.selectionManager.selectedElements
|
||||
);
|
||||
this.doc.transact(() => {
|
||||
this.crud.deleteElements(elements);
|
||||
});
|
||||
|
||||
this.selectionManager.set({
|
||||
editing: false,
|
||||
elements: [],
|
||||
});
|
||||
};
|
||||
|
||||
private readonly _onPaste = async (_context: UIEventStateContext) => {
|
||||
if (
|
||||
document.activeElement instanceof HTMLInputElement ||
|
||||
document.activeElement instanceof HTMLTextAreaElement
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const event = _context.get('clipboardState').raw;
|
||||
event.preventDefault();
|
||||
|
||||
const { surfaceSelections, selectedElements } = this.selectionManager;
|
||||
|
||||
if (surfaceSelections[0]?.editing) {
|
||||
// use build-in paste handler in rich-text when paste in surface text element
|
||||
if (isCanvasElementWithText(selectedElements[0])) return;
|
||||
this.onPagePaste(_context);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = event.clipboardData;
|
||||
if (!data) return;
|
||||
|
||||
if (!this.surface) return;
|
||||
|
||||
const lastMousePos = this.toolManager.lastMousePos$.peek();
|
||||
const point: IVec = [lastMousePos.x, lastMousePos.y];
|
||||
|
||||
if (isPureFileInClipboard(data)) {
|
||||
const files = data.files;
|
||||
if (files.length === 0) return;
|
||||
|
||||
const imageFiles: File[] = [],
|
||||
attachmentFiles: File[] = [];
|
||||
|
||||
[...files].forEach(file => {
|
||||
if (file.type.startsWith('image/')) {
|
||||
imageFiles.push(file);
|
||||
} else {
|
||||
attachmentFiles.push(file);
|
||||
}
|
||||
});
|
||||
|
||||
// when only images in clipboard, add image-blocks else add all files as attachments
|
||||
if (attachmentFiles.length === 0) {
|
||||
await addImages(this.std, imageFiles, {
|
||||
point,
|
||||
maxWidth: MAX_IMAGE_WIDTH,
|
||||
});
|
||||
} else {
|
||||
await addAttachments(this.std, [...files], point);
|
||||
}
|
||||
|
||||
this.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', {
|
||||
control: 'canvas:paste',
|
||||
page: 'whiteboard editor',
|
||||
module: 'toolbar',
|
||||
segment: 'toolbar',
|
||||
type: attachmentFiles.length === 0 ? 'image' : 'attachment',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (isUrlInClipboard(data)) {
|
||||
const url = data.getData('text/plain');
|
||||
const lastMousePos = this.toolManager.lastMousePos$.peek();
|
||||
const [x, y] = this.gfx.viewport.toModelCoord(
|
||||
lastMousePos.x,
|
||||
lastMousePos.y
|
||||
);
|
||||
|
||||
// try to interpret url as affine doc url
|
||||
const parseDocUrlService = this.std.getOptional(ParseDocUrlProvider);
|
||||
const docUrlInfo = parseDocUrlService?.parseDocUrl(url);
|
||||
const options: Record<string, unknown> = {};
|
||||
|
||||
let flavour = 'affine:bookmark';
|
||||
let style = BookmarkStyles[0];
|
||||
let isInternalLink = false;
|
||||
let isLinkedBlock = false;
|
||||
|
||||
if (docUrlInfo) {
|
||||
const { docId: pageId, ...params } = docUrlInfo;
|
||||
|
||||
flavour = 'affine:embed-linked-doc';
|
||||
style = 'vertical';
|
||||
|
||||
isInternalLink = true;
|
||||
isLinkedBlock = referenceToNode({ pageId, params });
|
||||
options.pageId = pageId;
|
||||
if (params) options.params = params;
|
||||
} else {
|
||||
options.url = url;
|
||||
|
||||
const embedOptions = this.std
|
||||
.get(EmbedOptionProvider)
|
||||
.getEmbedBlockOptions(url);
|
||||
if (embedOptions) {
|
||||
flavour = embedOptions.flavour;
|
||||
style = embedOptions.styles[0];
|
||||
}
|
||||
}
|
||||
|
||||
const width = EMBED_CARD_WIDTH[style];
|
||||
const height = EMBED_CARD_HEIGHT[style];
|
||||
|
||||
options.xywh = Bound.fromCenter(
|
||||
Vec.toVec({
|
||||
x,
|
||||
y,
|
||||
}),
|
||||
width,
|
||||
height
|
||||
).serialize();
|
||||
options.style = style;
|
||||
|
||||
const id = this.crud.addBlock(flavour, options, this.surface.model.id);
|
||||
|
||||
this.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', {
|
||||
control: 'canvas:paste',
|
||||
page: 'whiteboard editor',
|
||||
module: 'toolbar',
|
||||
segment: 'toolbar',
|
||||
type: flavour.split(':')[1],
|
||||
});
|
||||
|
||||
this.std
|
||||
.getOptional(TelemetryProvider)
|
||||
?.track(isInternalLink ? 'LinkedDocCreated' : 'Link', {
|
||||
page: 'whiteboard editor',
|
||||
segment: 'whiteboard',
|
||||
category: 'pasted link',
|
||||
other: isInternalLink ? 'existing doc' : 'external link',
|
||||
type: isInternalLink ? (isLinkedBlock ? 'block' : 'doc') : 'link',
|
||||
});
|
||||
|
||||
this.selectionManager.set({
|
||||
editing: false,
|
||||
elements: [id],
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const svg = tryGetSvgFromClipboard(data);
|
||||
if (svg) {
|
||||
await addImages(this.std, [svg], { point, maxWidth: MAX_IMAGE_WIDTH });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// check for surface elements in clipboard
|
||||
const json = this.std.clipboard.readFromClipboard(data);
|
||||
const mayBeSurfaceDataJson = json[BLOCKSUITE_SURFACE];
|
||||
if (mayBeSurfaceDataJson !== undefined) {
|
||||
const elementsRawData = JSON.parse(mayBeSurfaceDataJson);
|
||||
const { snapshot, blobs } = elementsRawData;
|
||||
const job = this.std.store.getTransformer();
|
||||
const map = job.assetsManager.getAssets();
|
||||
decodeClipboardBlobs(blobs, map);
|
||||
for (const blobId of map.keys()) {
|
||||
await job.assetsManager.writeToBlob(blobId);
|
||||
}
|
||||
await this._pasteShapesAndBlocks(snapshot);
|
||||
return;
|
||||
}
|
||||
// check for slice snapshot in clipboard
|
||||
const mayBeSliceDataJson = json[ClipboardAdapter.MIME];
|
||||
if (mayBeSliceDataJson === undefined) return;
|
||||
const clipData = JSON.parse(mayBeSliceDataJson);
|
||||
const sliceSnapShot = clipData?.snapshot as SliceSnapshot;
|
||||
await this._pasteTextContentAsNote(sliceSnapShot.content);
|
||||
} catch {
|
||||
// if it is not parsable
|
||||
await this._pasteTextContentAsNote(data.getData('text/plain'));
|
||||
}
|
||||
};
|
||||
|
||||
private get _exportManager() {
|
||||
return this.std.getOptional(ExportManager);
|
||||
}
|
||||
|
||||
private get doc() {
|
||||
return this.std.store;
|
||||
}
|
||||
|
||||
private get selectionManager() {
|
||||
return this.gfx.selection;
|
||||
}
|
||||
|
||||
private get surface() {
|
||||
return getSurfaceComponent(this.std);
|
||||
}
|
||||
|
||||
private get frame() {
|
||||
return this.std.get(EdgelessFrameManagerIdentifier);
|
||||
}
|
||||
|
||||
private get gfx() {
|
||||
return this.std.get(GfxControllerIdentifier);
|
||||
}
|
||||
|
||||
private get crud() {
|
||||
return this.std.get(EdgelessCRUDIdentifier);
|
||||
}
|
||||
|
||||
private get toolManager() {
|
||||
return this.gfx.tool;
|
||||
}
|
||||
|
||||
private _checkCanContinueToCanvas(
|
||||
host: EditorHost,
|
||||
pathName: string,
|
||||
editorMode: boolean
|
||||
) {
|
||||
if (
|
||||
location.pathname !== pathName ||
|
||||
isInsidePageEditor(host) !== editorMode
|
||||
) {
|
||||
throw new Error('Unable to export content to canvas');
|
||||
}
|
||||
}
|
||||
|
||||
private async _edgelessToCanvas(
|
||||
bound: IBound,
|
||||
nodes?: GfxBlockElementModel[],
|
||||
canvasElements: GfxPrimitiveElementModel[] = [],
|
||||
{
|
||||
background,
|
||||
padding = IMAGE_PADDING,
|
||||
dpr = window.devicePixelRatio || 1,
|
||||
}: CanvasExportOptions = {}
|
||||
): Promise<HTMLCanvasElement | undefined> {
|
||||
const host = this.std.host;
|
||||
const rootModel = this.doc.root;
|
||||
if (!rootModel) return;
|
||||
|
||||
const html2canvas = (await import('html2canvas')).default;
|
||||
if (!(html2canvas instanceof Function)) return;
|
||||
if (!this.surface) return;
|
||||
|
||||
const pathname = location.pathname;
|
||||
const editorMode = isInsidePageEditor(host);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = (bound.w + padding * 2) * dpr;
|
||||
canvas.height = (bound.h + padding * 2) * dpr;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
if (background) {
|
||||
ctx.fillStyle = background;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
const replaceImgSrcWithSvg = this._exportManager?.replaceImgSrcWithSvg;
|
||||
const replaceRichTextWithSvgElementFunc =
|
||||
this._replaceRichTextWithSvgElement.bind(this);
|
||||
|
||||
const imageProxy = host.std.clipboard.configs.get('imageProxy');
|
||||
const html2canvasOption = {
|
||||
ignoreElements: function (element: Element) {
|
||||
if (
|
||||
CANVAS_EXPORT_IGNORE_TAGS.includes(element.tagName) ||
|
||||
element.classList.contains('dg')
|
||||
) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
onclone: async function (documentClone: Document, element: HTMLElement) {
|
||||
// html2canvas can't support transform feature
|
||||
element.style.setProperty('transform', 'none');
|
||||
const layer = documentClone.querySelector('.affine-edgeless-layer');
|
||||
if (layer && layer instanceof HTMLElement) {
|
||||
layer.style.setProperty('transform', 'none');
|
||||
}
|
||||
|
||||
const boxShadowElements = documentClone.querySelectorAll(
|
||||
"[style*='box-shadow']"
|
||||
);
|
||||
boxShadowElements.forEach(function (element) {
|
||||
if (element instanceof HTMLElement) {
|
||||
element.style.setProperty('box-shadow', 'none');
|
||||
}
|
||||
});
|
||||
await replaceImgSrcWithSvg?.(element);
|
||||
replaceRichTextWithSvgElementFunc(element);
|
||||
},
|
||||
backgroundColor: 'transparent',
|
||||
useCORS: imageProxy ? false : true,
|
||||
proxy: imageProxy,
|
||||
};
|
||||
|
||||
const _drawTopLevelBlock = async (
|
||||
block: GfxBlockElementModel,
|
||||
isInFrame = false
|
||||
) => {
|
||||
const blockComponent = this.std.view.getBlock(block.id);
|
||||
if (!blockComponent) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.EdgelessExportError,
|
||||
'Could not find edgeless block component.'
|
||||
);
|
||||
}
|
||||
|
||||
const blockBound = Bound.deserialize(block.xywh);
|
||||
const canvasData = await html2canvas(
|
||||
blockComponent as HTMLElement,
|
||||
html2canvasOption
|
||||
);
|
||||
ctx.drawImage(
|
||||
canvasData,
|
||||
blockBound.x - bound.x + padding,
|
||||
blockBound.y - bound.y + padding,
|
||||
blockBound.w,
|
||||
isInFrame
|
||||
? (blockBound.w / canvasData.width) * canvasData.height
|
||||
: blockBound.h
|
||||
);
|
||||
};
|
||||
|
||||
const nodeElements =
|
||||
nodes ??
|
||||
(this.gfx.getElementsByBound(bound, {
|
||||
type: 'block',
|
||||
}) as GfxBlockElementModel[]);
|
||||
for (const nodeElement of nodeElements) {
|
||||
await _drawTopLevelBlock(nodeElement);
|
||||
|
||||
if (matchModels(nodeElement, [FrameBlockModel])) {
|
||||
const blocksInsideFrame: GfxBlockElementModel[] = [];
|
||||
this.frame.getElementsInFrameBound(nodeElement, false).forEach(ele => {
|
||||
if (isTopLevelBlock(ele)) {
|
||||
blocksInsideFrame.push(ele as GfxBlockElementModel);
|
||||
} else {
|
||||
canvasElements.push(ele as GfxPrimitiveElementModel);
|
||||
}
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-for-of
|
||||
for (let i = 0; i < blocksInsideFrame.length; i++) {
|
||||
const element = blocksInsideFrame[i];
|
||||
await _drawTopLevelBlock(element, true);
|
||||
}
|
||||
}
|
||||
|
||||
this._checkCanContinueToCanvas(host, pathname, editorMode);
|
||||
}
|
||||
|
||||
const surfaceCanvas = this.surface.renderer.getCanvasByBound(
|
||||
bound,
|
||||
canvasElements
|
||||
);
|
||||
ctx.drawImage(surfaceCanvas, padding, padding, bound.w, bound.h);
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
private _elementToSvgElement(
|
||||
node: HTMLElement,
|
||||
width: number,
|
||||
height: number
|
||||
) {
|
||||
const xmlns = 'http://www.w3.org/2000/svg';
|
||||
const svg = document.createElementNS(xmlns, 'svg');
|
||||
const foreignObject = document.createElementNS(xmlns, 'foreignObject');
|
||||
|
||||
svg.setAttribute('width', `${width}`);
|
||||
svg.setAttribute('height', `${height}`);
|
||||
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
|
||||
|
||||
foreignObject.setAttribute('width', '100%');
|
||||
foreignObject.setAttribute('height', '100%');
|
||||
foreignObject.setAttribute('x', '0');
|
||||
foreignObject.setAttribute('y', '0');
|
||||
foreignObject.setAttribute('externalResourcesRequired', 'true');
|
||||
|
||||
svg.append(foreignObject);
|
||||
foreignObject.append(node);
|
||||
return svg;
|
||||
}
|
||||
|
||||
private _emitSelectionChangeAfterPaste(
|
||||
canvasElementIds: string[],
|
||||
blockIds: string[]
|
||||
) {
|
||||
const newSelected = [
|
||||
...canvasElementIds,
|
||||
...blockIds.filter(id => {
|
||||
return isTopLevelBlock(this.doc.getModelById(id));
|
||||
}),
|
||||
];
|
||||
|
||||
this.selectionManager.set({
|
||||
editing: false,
|
||||
elements: newSelected,
|
||||
});
|
||||
}
|
||||
|
||||
private async _pasteShapesAndBlocks(
|
||||
elementsRawData: (SerializedElement | BlockSnapshot)[]
|
||||
) {
|
||||
const [_, { createdElementsPromise }] = this.std.command.exec(
|
||||
createElementsFromClipboardDataCommand,
|
||||
{
|
||||
elementsRawData,
|
||||
}
|
||||
);
|
||||
if (!createdElementsPromise) return;
|
||||
const { canvasElements, blockModels } = await createdElementsPromise;
|
||||
this._emitSelectionChangeAfterPaste(
|
||||
canvasElements.map(ele => ele.id),
|
||||
blockModels.map(block => block.id)
|
||||
);
|
||||
}
|
||||
|
||||
private async _pasteTextContentAsNote(content: BlockSnapshot[] | string) {
|
||||
const lastMousePos = this.toolManager.lastMousePos$.peek();
|
||||
const [x, y] = this.gfx.viewport.toModelCoord(
|
||||
lastMousePos.x,
|
||||
lastMousePos.y
|
||||
);
|
||||
|
||||
const noteProps = {
|
||||
xywh: new Bound(
|
||||
x,
|
||||
y,
|
||||
DEFAULT_NOTE_WIDTH,
|
||||
DEFAULT_NOTE_HEIGHT
|
||||
).serialize(),
|
||||
};
|
||||
|
||||
const noteId = this.crud.addBlock(
|
||||
'affine:note',
|
||||
noteProps,
|
||||
this.doc.root!.id
|
||||
);
|
||||
|
||||
this.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', {
|
||||
control: 'canvas:paste',
|
||||
page: 'whiteboard editor',
|
||||
module: 'toolbar',
|
||||
segment: 'toolbar',
|
||||
type: 'note',
|
||||
});
|
||||
|
||||
if (typeof content === 'string') {
|
||||
splitIntoLines(content).forEach((line, idx) => {
|
||||
this.crud.addBlock(
|
||||
'affine:paragraph',
|
||||
{ text: new Y.Text(line) },
|
||||
noteId,
|
||||
idx
|
||||
);
|
||||
});
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-for-of
|
||||
for (let index = 0; index < content.length; index++) {
|
||||
const blockSnapshot = content[index];
|
||||
if (blockSnapshot.flavour === 'affine:note') {
|
||||
for (const child of blockSnapshot.children) {
|
||||
await this.onBlockSnapshotPaste(child, this.doc, noteId);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
await this.onBlockSnapshotPaste(content[index], this.doc, noteId);
|
||||
}
|
||||
}
|
||||
|
||||
this.gfx.selection.set({
|
||||
elements: [noteId],
|
||||
editing: false,
|
||||
});
|
||||
this.gfx.tool.setTool('default');
|
||||
}
|
||||
|
||||
private _replaceRichTextWithSvgElement(element: HTMLElement) {
|
||||
const richList = Array.from(element.querySelectorAll('.inline-editor'));
|
||||
richList.forEach(rich => {
|
||||
const svgEle = this._elementToSvgElement(
|
||||
rich.cloneNode(true) as HTMLElement,
|
||||
rich.clientWidth,
|
||||
rich.clientHeight + 1
|
||||
);
|
||||
rich.parentElement?.append(svgEle);
|
||||
rich.remove();
|
||||
});
|
||||
}
|
||||
|
||||
copy() {
|
||||
document.dispatchEvent(
|
||||
new Event('copy', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override mounted() {
|
||||
if (!navigator.clipboard) {
|
||||
console.error(
|
||||
'navigator.clipboard is not supported in current environment.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (this._disposables.disposed) {
|
||||
this._disposables = new DisposableGroup();
|
||||
}
|
||||
this._init();
|
||||
this._initEdgelessClipboard();
|
||||
}
|
||||
|
||||
async toCanvas(
|
||||
blocks: GfxBlockElementModel[],
|
||||
shapes: ShapeElementModel[],
|
||||
options?: CanvasExportOptions
|
||||
) {
|
||||
blocks.sort(compareLayer);
|
||||
shapes.sort(compareLayer);
|
||||
|
||||
const bounds: IBound[] = [];
|
||||
blocks.forEach(block => {
|
||||
bounds.push(Bound.deserialize(block.xywh));
|
||||
});
|
||||
shapes.forEach(shape => {
|
||||
bounds.push(shape.elementBound);
|
||||
});
|
||||
const bound = getCommonBound(bounds);
|
||||
if (!bound) {
|
||||
console.error('bound not exist');
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = await this._edgelessToCanvas(bound, blocks, shapes, options);
|
||||
return canvas;
|
||||
}
|
||||
}
|
||||
220
blocksuite/affine/blocks/root/src/edgeless/clipboard/command.ts
Normal file
220
blocksuite/affine/blocks/root/src/edgeless/clipboard/command.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import {
|
||||
type ClipboardConfigCreationContext,
|
||||
EdgelessClipboardConfigIdentifier,
|
||||
EdgelessCRUDIdentifier,
|
||||
SurfaceGroupLikeModel,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import { Bound, type IVec, type SerializedXYWH } from '@blocksuite/global/gfx';
|
||||
import { assertType } from '@blocksuite/global/utils';
|
||||
import type { BlockStdScope, Command } from '@blocksuite/std';
|
||||
import {
|
||||
type GfxBlockElementModel,
|
||||
type GfxCompatibleProps,
|
||||
GfxControllerIdentifier,
|
||||
type GfxModel,
|
||||
type GfxPrimitiveElementModel,
|
||||
type SerializedElement,
|
||||
SortOrder,
|
||||
} from '@blocksuite/std/gfx';
|
||||
import { type BlockSnapshot, BlockSnapshotSchema } from '@blocksuite/store';
|
||||
|
||||
import { createCanvasElement } from './canvas';
|
||||
import {
|
||||
createNewPresentationIndexes,
|
||||
edgelessElementsBoundFromRawData,
|
||||
} from './utils';
|
||||
|
||||
interface Input {
|
||||
elementsRawData: (SerializedElement | BlockSnapshot)[];
|
||||
pasteCenter?: IVec;
|
||||
}
|
||||
|
||||
type CreatedElements = {
|
||||
canvasElements: GfxPrimitiveElementModel[];
|
||||
blockModels: GfxBlockElementModel[];
|
||||
};
|
||||
|
||||
interface Output {
|
||||
createdElementsPromise: Promise<CreatedElements>;
|
||||
}
|
||||
|
||||
export const createElementsFromClipboardDataCommand: Command<Input, Output> = (
|
||||
ctx,
|
||||
next
|
||||
) => {
|
||||
const { std, elementsRawData } = ctx;
|
||||
let { pasteCenter } = ctx;
|
||||
|
||||
const gfx = std.get(GfxControllerIdentifier);
|
||||
const toolManager = gfx.tool;
|
||||
|
||||
const runner = async (): Promise<CreatedElements> => {
|
||||
let oldCommonBound, pasteX, pasteY;
|
||||
{
|
||||
const lastMousePos = toolManager.lastMousePos$.peek();
|
||||
pasteCenter =
|
||||
pasteCenter ??
|
||||
gfx.viewport.toModelCoord(lastMousePos.x, lastMousePos.y);
|
||||
const [modelX, modelY] = pasteCenter;
|
||||
oldCommonBound = edgelessElementsBoundFromRawData(elementsRawData);
|
||||
|
||||
pasteX = modelX - oldCommonBound.w / 2;
|
||||
pasteY = modelY - oldCommonBound.h / 2;
|
||||
}
|
||||
|
||||
const getNewXYWH = (oldXYWH: SerializedXYWH) => {
|
||||
const oldBound = Bound.deserialize(oldXYWH);
|
||||
return new Bound(
|
||||
oldBound.x + pasteX - oldCommonBound.x,
|
||||
oldBound.y + pasteY - oldCommonBound.y,
|
||||
oldBound.w,
|
||||
oldBound.h
|
||||
).serialize();
|
||||
};
|
||||
|
||||
// create blocks and canvas elements
|
||||
|
||||
const context: ClipboardConfigCreationContext = {
|
||||
oldToNewIdMap: new Map<string, string>(),
|
||||
originalIndexes: new Map<string, string>(),
|
||||
newPresentationIndexes: createNewPresentationIndexes(
|
||||
elementsRawData,
|
||||
std
|
||||
),
|
||||
};
|
||||
|
||||
const blockModels: GfxBlockElementModel[] = [];
|
||||
const canvasElements: GfxPrimitiveElementModel[] = [];
|
||||
const allElements: GfxModel[] = [];
|
||||
|
||||
for (const data of elementsRawData) {
|
||||
const { data: blockSnapshot } = BlockSnapshotSchema.safeParse(data);
|
||||
if (blockSnapshot) {
|
||||
const oldId = blockSnapshot.id;
|
||||
|
||||
const config = std.getOptional(
|
||||
EdgelessClipboardConfigIdentifier(blockSnapshot.flavour)
|
||||
);
|
||||
if (!config) continue;
|
||||
|
||||
if (typeof blockSnapshot.props.index !== 'string') {
|
||||
console.error(`Block(id: ${oldId}) does not have index property`);
|
||||
continue;
|
||||
}
|
||||
const originalIndex = (blockSnapshot.props as GfxCompatibleProps).index;
|
||||
|
||||
if (typeof blockSnapshot.props.xywh !== 'string') {
|
||||
console.error(`Block(id: ${oldId}) does not have xywh property`);
|
||||
continue;
|
||||
}
|
||||
|
||||
assertType<GfxCompatibleProps>(blockSnapshot.props);
|
||||
|
||||
blockSnapshot.props.xywh = getNewXYWH(
|
||||
blockSnapshot.props.xywh as SerializedXYWH
|
||||
);
|
||||
blockSnapshot.props.lockedBySelf = false;
|
||||
|
||||
const newId = await config.createBlock(blockSnapshot, context);
|
||||
if (!newId) continue;
|
||||
|
||||
const block = std.store.getBlock(newId);
|
||||
if (!block) continue;
|
||||
|
||||
assertType<GfxBlockElementModel>(block.model);
|
||||
blockModels.push(block.model);
|
||||
allElements.push(block.model);
|
||||
context.oldToNewIdMap.set(oldId, newId);
|
||||
context.originalIndexes.set(oldId, originalIndex);
|
||||
} else {
|
||||
assertType<SerializedElement>(data);
|
||||
const oldId = data.id;
|
||||
|
||||
const element = createCanvasElement(
|
||||
std,
|
||||
data,
|
||||
context,
|
||||
getNewXYWH(data.xywh)
|
||||
);
|
||||
|
||||
if (!element) continue;
|
||||
|
||||
canvasElements.push(element);
|
||||
allElements.push(element);
|
||||
|
||||
context.oldToNewIdMap.set(oldId, element.id);
|
||||
context.originalIndexes.set(oldId, element.index);
|
||||
}
|
||||
}
|
||||
|
||||
// remap old id to new id for the original index
|
||||
const oldIds = [...context.originalIndexes.keys()];
|
||||
oldIds.forEach(oldId => {
|
||||
const newId = context.oldToNewIdMap.get(oldId);
|
||||
const originalIndex = context.originalIndexes.get(oldId);
|
||||
if (newId && originalIndex) {
|
||||
context.originalIndexes.set(newId, originalIndex);
|
||||
context.originalIndexes.delete(oldId);
|
||||
}
|
||||
});
|
||||
|
||||
updatePastedElementsIndex(std, allElements, context.originalIndexes);
|
||||
|
||||
return {
|
||||
canvasElements: canvasElements,
|
||||
blockModels: blockModels,
|
||||
};
|
||||
};
|
||||
|
||||
return next({
|
||||
createdElementsPromise: runner(),
|
||||
});
|
||||
};
|
||||
|
||||
function updatePastedElementsIndex(
|
||||
std: BlockStdScope,
|
||||
elements: GfxModel[],
|
||||
originalIndexes: Map<string, string>
|
||||
) {
|
||||
const gfx = std.get(GfxControllerIdentifier);
|
||||
const crud = std.get(EdgelessCRUDIdentifier);
|
||||
function compare(a: GfxModel, b: GfxModel) {
|
||||
if (a instanceof SurfaceGroupLikeModel && a.hasDescendant(b)) {
|
||||
return SortOrder.BEFORE;
|
||||
} else if (b instanceof SurfaceGroupLikeModel && b.hasDescendant(a)) {
|
||||
return SortOrder.AFTER;
|
||||
} else {
|
||||
const aGroups = a.groups as SurfaceGroupLikeModel[];
|
||||
const bGroups = b.groups as SurfaceGroupLikeModel[];
|
||||
|
||||
let i = 1;
|
||||
let aGroup: GfxModel | undefined = aGroups.at(-i);
|
||||
let bGroup: GfxModel | undefined = bGroups.at(-i);
|
||||
|
||||
while (aGroup === bGroup && aGroup) {
|
||||
++i;
|
||||
aGroup = aGroups.at(-i);
|
||||
bGroup = bGroups.at(-i);
|
||||
}
|
||||
|
||||
aGroup = aGroup ?? a;
|
||||
bGroup = bGroup ?? b;
|
||||
|
||||
return originalIndexes.get(aGroup.id) === originalIndexes.get(bGroup.id)
|
||||
? SortOrder.SAME
|
||||
: originalIndexes.get(aGroup.id)! < originalIndexes.get(bGroup.id)!
|
||||
? SortOrder.BEFORE
|
||||
: SortOrder.AFTER;
|
||||
}
|
||||
}
|
||||
|
||||
const idxGenerator = gfx.layer.createIndexGenerator();
|
||||
const sortedElements = elements.sort(compare);
|
||||
sortedElements.forEach(ele => {
|
||||
const newIndex = idxGenerator();
|
||||
|
||||
crud.updateElement(ele.id, {
|
||||
index: newIndex,
|
||||
});
|
||||
});
|
||||
}
|
||||
138
blocksuite/affine/blocks/root/src/edgeless/clipboard/utils.ts
Normal file
138
blocksuite/affine/blocks/root/src/edgeless/clipboard/utils.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import {
|
||||
EdgelessFrameManager,
|
||||
EdgelessFrameManagerIdentifier,
|
||||
} from '@blocksuite/affine-block-frame';
|
||||
import type { FrameBlockProps } from '@blocksuite/affine-model';
|
||||
import { encodeClipboardBlobs } from '@blocksuite/affine-shared/adapters';
|
||||
import { Bound, getBoundWithRotation } from '@blocksuite/global/gfx';
|
||||
import type { BlockStdScope } from '@blocksuite/std';
|
||||
import {
|
||||
generateKeyBetweenV2,
|
||||
type GfxModel,
|
||||
type SerializedElement,
|
||||
} from '@blocksuite/std/gfx';
|
||||
import { type BlockSnapshot, BlockSnapshotSchema } from '@blocksuite/store';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
import { serializeElement } from '../utils/clone-utils';
|
||||
import { isAttachmentBlock, isImageBlock } from '../utils/query';
|
||||
|
||||
type FrameSnapshot = BlockSnapshot & {
|
||||
props: FrameBlockProps;
|
||||
};
|
||||
|
||||
export function createNewPresentationIndexes(
|
||||
raw: (SerializedElement | BlockSnapshot)[],
|
||||
std: BlockStdScope
|
||||
) {
|
||||
const frames = raw
|
||||
.filter((block): block is FrameSnapshot => {
|
||||
const { data } = BlockSnapshotSchema.safeParse(block);
|
||||
return data?.flavour === 'affine:frame';
|
||||
})
|
||||
.sort((a, b) => EdgelessFrameManager.framePresentationComparator(a, b));
|
||||
|
||||
const frameMgr = std.get(EdgelessFrameManagerIdentifier);
|
||||
let before = frameMgr.generatePresentationIndex();
|
||||
const result = new Map<string, string>();
|
||||
frames.forEach(frame => {
|
||||
result.set(frame.id, before);
|
||||
before = generateKeyBetweenV2(before, null);
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function prepareClipboardData(
|
||||
selectedAll: GfxModel[],
|
||||
std: BlockStdScope
|
||||
) {
|
||||
const job = std.store.getTransformer();
|
||||
const selected = await Promise.all(
|
||||
selectedAll.map(async selected => {
|
||||
const data = serializeElement(selected, selectedAll, job);
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
if (isAttachmentBlock(selected) || isImageBlock(selected)) {
|
||||
await job.assetsManager.readFromBlob(data.props.sourceId as string);
|
||||
}
|
||||
return data;
|
||||
})
|
||||
);
|
||||
const blobs = await encodeClipboardBlobs(job.assetsManager.getAssets());
|
||||
return {
|
||||
snapshot: selected.filter(d => !!d),
|
||||
blobs,
|
||||
};
|
||||
}
|
||||
|
||||
export function isPureFileInClipboard(clipboardData: DataTransfer) {
|
||||
const types = clipboardData.types;
|
||||
return (
|
||||
(types.length === 1 && types[0] === 'Files') ||
|
||||
(types.length === 2 &&
|
||||
(types.includes('text/plain') || types.includes('text/html')) &&
|
||||
types.includes('Files'))
|
||||
);
|
||||
}
|
||||
|
||||
export function tryGetSvgFromClipboard(clipboardData: DataTransfer) {
|
||||
const types = clipboardData.types;
|
||||
|
||||
if (types.length === 1 && types[0] !== 'text/plain') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parser = new DOMParser();
|
||||
const svgDoc = parser.parseFromString(
|
||||
clipboardData.getData('text/plain'),
|
||||
'image/svg+xml'
|
||||
);
|
||||
const svg = svgDoc.documentElement;
|
||||
|
||||
if (svg.tagName !== 'svg' || !svg.hasAttribute('xmlns')) {
|
||||
return null;
|
||||
}
|
||||
const svgContent = DOMPurify.sanitize(svgDoc.documentElement, {
|
||||
USE_PROFILES: { svg: true },
|
||||
});
|
||||
const blob = new Blob([svgContent], { type: 'image/svg+xml' });
|
||||
const file = new File([blob], 'pasted-image.svg', { type: 'image/svg+xml' });
|
||||
return file;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
function getBoundFromSerializedElement(element: SerializedElement) {
|
||||
return Bound.from(
|
||||
getBoundWithRotation({
|
||||
...Bound.deserialize(element.xywh),
|
||||
rotate: typeof element.rotate === 'number' ? element.rotate : 0,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function getBoundFromGfxBlockSnapshot(snapshot: BlockSnapshot) {
|
||||
if (typeof snapshot.props.xywh !== 'string') return null;
|
||||
return Bound.deserialize(snapshot.props.xywh);
|
||||
}
|
||||
@@ -0,0 +1,680 @@
|
||||
import { EdgelessFrameManagerIdentifier } from '@blocksuite/affine-block-frame';
|
||||
import {
|
||||
CanvasElementType,
|
||||
EdgelessCRUDIdentifier,
|
||||
getSurfaceBlock,
|
||||
getSurfaceComponent,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import { FontFamilyIcon } from '@blocksuite/affine-components/icons';
|
||||
import {
|
||||
mountShapeTextEditor,
|
||||
SHAPE_OVERLAY_HEIGHT,
|
||||
SHAPE_OVERLAY_WIDTH,
|
||||
ShapeComponentConfig,
|
||||
} from '@blocksuite/affine-gfx-shape';
|
||||
import {
|
||||
insertEdgelessTextCommand,
|
||||
mountTextElementEditor,
|
||||
} from '@blocksuite/affine-gfx-text';
|
||||
import type {
|
||||
Connection,
|
||||
ConnectorElementModel,
|
||||
ShapeElementModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
DEFAULT_NOTE_WIDTH,
|
||||
DefaultTheme,
|
||||
FontFamily,
|
||||
FontStyle,
|
||||
FontWeight,
|
||||
getShapeName,
|
||||
GroupElementModel,
|
||||
NoteBlockModel,
|
||||
ShapeStyle,
|
||||
TextElementModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
EditPropsStore,
|
||||
FeatureFlagService,
|
||||
ThemeProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
captureEventTarget,
|
||||
matchModels,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import type { XYWH } from '@blocksuite/global/gfx';
|
||||
import {
|
||||
Bound,
|
||||
clamp,
|
||||
normalizeDegAngle,
|
||||
serializeXYWH,
|
||||
toDegree,
|
||||
Vec,
|
||||
} from '@blocksuite/global/gfx';
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import { FrameIcon, PageIcon } from '@blocksuite/icons/lit';
|
||||
import {
|
||||
type BlockComponent,
|
||||
type BlockStdScope,
|
||||
stdContext,
|
||||
} from '@blocksuite/std';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
|
||||
import { consume } from '@lit/context';
|
||||
import { baseTheme } from '@toeverything/theme';
|
||||
import { css, html, LitElement, nothing, unsafeCSS } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import {
|
||||
type AUTO_COMPLETE_TARGET_TYPE,
|
||||
AutoCompleteFrameOverlay,
|
||||
AutoCompleteNoteOverlay,
|
||||
AutoCompleteShapeOverlay,
|
||||
AutoCompleteTextOverlay,
|
||||
capitalizeFirstLetter,
|
||||
createShapeElement,
|
||||
DEFAULT_NOTE_OVERLAY_HEIGHT,
|
||||
DEFAULT_TEXT_HEIGHT,
|
||||
DEFAULT_TEXT_WIDTH,
|
||||
Direction,
|
||||
isShape,
|
||||
PANEL_HEIGHT,
|
||||
PANEL_WIDTH,
|
||||
type TARGET_SHAPE_TYPE,
|
||||
} from './utils.js';
|
||||
|
||||
export class EdgelessAutoCompletePanel extends WithDisposable(LitElement) {
|
||||
static override styles = css`
|
||||
.auto-complete-panel-container {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
width: 136px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 0;
|
||||
gap: 8px;
|
||||
border-radius: 8px;
|
||||
background: var(--affine-background-overlay-panel-color);
|
||||
box-shadow: var(--affine-shadow-2);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.row-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 120px;
|
||||
height: 28px;
|
||||
padding: 4px 0;
|
||||
text-align: center;
|
||||
border-radius: 8px;
|
||||
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
border: 1px solid var(--affine-border-color, #e3e2e4);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
`;
|
||||
|
||||
private _overlay:
|
||||
| AutoCompleteShapeOverlay
|
||||
| AutoCompleteNoteOverlay
|
||||
| AutoCompleteFrameOverlay
|
||||
| AutoCompleteTextOverlay
|
||||
| null = null;
|
||||
|
||||
get gfx() {
|
||||
return this.std.get(GfxControllerIdentifier);
|
||||
}
|
||||
|
||||
constructor(
|
||||
position: [number, number],
|
||||
edgeless: BlockComponent,
|
||||
currentSource: ShapeElementModel | NoteBlockModel,
|
||||
connector: ConnectorElementModel
|
||||
) {
|
||||
super();
|
||||
this.position = position;
|
||||
this.edgeless = edgeless;
|
||||
this.currentSource = currentSource;
|
||||
this.connector = connector;
|
||||
}
|
||||
|
||||
get crud() {
|
||||
return this.std.get(EdgelessCRUDIdentifier);
|
||||
}
|
||||
|
||||
get surface() {
|
||||
return getSurfaceComponent(this.std);
|
||||
}
|
||||
|
||||
private _addFrame() {
|
||||
const bound = this._generateTarget(this.connector)?.nextBound;
|
||||
if (!bound) return;
|
||||
|
||||
const { h } = bound;
|
||||
const w = h / 0.75;
|
||||
const target = this._getTargetXYWH(w, h);
|
||||
if (!target) return;
|
||||
|
||||
const { xywh, position } = target;
|
||||
|
||||
const edgeless = this.edgeless;
|
||||
const surfaceBlockModel = getSurfaceBlock(this.std.store);
|
||||
if (!surfaceBlockModel) return;
|
||||
const frameMgr = this.std.get(EdgelessFrameManagerIdentifier);
|
||||
const frameIndex = frameMgr.frames.length + 1;
|
||||
const props = this.std.get(EditPropsStore).applyLastProps('affine:frame', {
|
||||
title: new Y.Text(`Frame ${frameIndex}`),
|
||||
xywh: serializeXYWH(...xywh),
|
||||
presentationIndex: frameMgr.generatePresentationIndex(),
|
||||
});
|
||||
const id = this.crud.addBlock('affine:frame', props, surfaceBlockModel);
|
||||
edgeless.doc.captureSync();
|
||||
const frame = this.crud.getElementById(id);
|
||||
if (!frame) return;
|
||||
|
||||
this.connector.target = {
|
||||
id,
|
||||
position,
|
||||
};
|
||||
|
||||
this.gfx.selection.set({
|
||||
elements: [frame.id],
|
||||
editing: false,
|
||||
});
|
||||
}
|
||||
|
||||
private _addNote() {
|
||||
const { doc } = this.edgeless;
|
||||
const target = this._getTargetXYWH(
|
||||
DEFAULT_NOTE_WIDTH,
|
||||
DEFAULT_NOTE_OVERLAY_HEIGHT
|
||||
);
|
||||
if (!target) return;
|
||||
|
||||
const { xywh, position } = target;
|
||||
const id = this.crud.addBlock(
|
||||
'affine:note',
|
||||
{
|
||||
xywh: serializeXYWH(...xywh),
|
||||
},
|
||||
doc.root?.id
|
||||
);
|
||||
const note = doc.getBlock(id)?.model;
|
||||
if (!matchModels(note, [NoteBlockModel])) {
|
||||
return;
|
||||
}
|
||||
doc.addBlock('affine:paragraph', { type: 'text' }, id);
|
||||
const group = this.currentSource.group;
|
||||
|
||||
if (group instanceof GroupElementModel) {
|
||||
group.addChild(note);
|
||||
}
|
||||
this.connector.target = {
|
||||
id,
|
||||
position: position as [number, number],
|
||||
};
|
||||
this.crud.updateElement(this.connector.id, {
|
||||
target: { id, position },
|
||||
});
|
||||
this.gfx.selection.set({
|
||||
elements: [id],
|
||||
editing: false,
|
||||
});
|
||||
}
|
||||
|
||||
private _addShape(targetType: TARGET_SHAPE_TYPE) {
|
||||
const edgeless = this.edgeless;
|
||||
const result = this._generateTarget(this.connector);
|
||||
if (!result) return;
|
||||
|
||||
const currentSource = this.currentSource;
|
||||
const { nextBound, position } = result;
|
||||
const id = createShapeElement(edgeless, currentSource, targetType);
|
||||
if (!id) return;
|
||||
|
||||
this.crud.updateElement(id, { xywh: nextBound.serialize() });
|
||||
this.crud.updateElement(this.connector.id, {
|
||||
target: { id, position },
|
||||
});
|
||||
|
||||
mountShapeTextEditor(
|
||||
this.crud.getElementById(id) as ShapeElementModel,
|
||||
this.edgeless
|
||||
);
|
||||
this.gfx.selection.set({
|
||||
elements: [id],
|
||||
editing: true,
|
||||
});
|
||||
edgeless.doc.captureSync();
|
||||
}
|
||||
|
||||
private _addText() {
|
||||
const target = this._getTargetXYWH(DEFAULT_TEXT_WIDTH, DEFAULT_TEXT_HEIGHT);
|
||||
if (!target) return;
|
||||
const { xywh, position } = target;
|
||||
const bound = Bound.fromXYWH(xywh);
|
||||
|
||||
const textFlag = this.edgeless.doc
|
||||
.get(FeatureFlagService)
|
||||
.getFlag('enable_edgeless_text');
|
||||
if (textFlag) {
|
||||
const [_, { textId }] = this.edgeless.std.command.exec(
|
||||
insertEdgelessTextCommand,
|
||||
{
|
||||
x: bound.x,
|
||||
y: bound.y,
|
||||
}
|
||||
);
|
||||
if (!textId) return;
|
||||
|
||||
const textElement = this.crud.getElementById(textId);
|
||||
if (!textElement) return;
|
||||
|
||||
this.crud.updateElement(this.connector.id, {
|
||||
target: { id: textId, position },
|
||||
});
|
||||
if (this.currentSource.group instanceof GroupElementModel) {
|
||||
this.currentSource.group.addChild(textElement);
|
||||
}
|
||||
|
||||
this.gfx.selection.set({
|
||||
elements: [textId],
|
||||
editing: false,
|
||||
});
|
||||
this.edgeless.doc.captureSync();
|
||||
} else {
|
||||
const textId = this.crud.addElement(CanvasElementType.TEXT, {
|
||||
xywh: bound.serialize(),
|
||||
text: new Y.Text(),
|
||||
textAlign: 'left',
|
||||
fontSize: 24,
|
||||
fontFamily: FontFamily.Inter,
|
||||
color: DefaultTheme.textColor,
|
||||
fontWeight: FontWeight.Regular,
|
||||
fontStyle: FontStyle.Normal,
|
||||
});
|
||||
if (!textId) return;
|
||||
const textElement = this.crud.getElementById(textId);
|
||||
if (!(textElement instanceof TextElementModel)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.crud.updateElement(this.connector.id, {
|
||||
target: { id: textId, position },
|
||||
});
|
||||
if (this.currentSource.group instanceof GroupElementModel) {
|
||||
this.currentSource.group.addChild(textElement);
|
||||
}
|
||||
|
||||
this.gfx.selection.set({
|
||||
elements: [textId],
|
||||
editing: false,
|
||||
});
|
||||
this.edgeless.doc.captureSync();
|
||||
|
||||
mountTextElementEditor(textElement, this.edgeless);
|
||||
}
|
||||
}
|
||||
|
||||
private _autoComplete(targetType: AUTO_COMPLETE_TARGET_TYPE) {
|
||||
this._removeOverlay();
|
||||
if (!this._connectorExist()) return;
|
||||
|
||||
switch (targetType) {
|
||||
case 'text':
|
||||
this._addText();
|
||||
break;
|
||||
case 'note':
|
||||
this._addNote();
|
||||
break;
|
||||
case 'frame':
|
||||
this._addFrame();
|
||||
break;
|
||||
default:
|
||||
this._addShape(targetType);
|
||||
}
|
||||
|
||||
this.remove();
|
||||
}
|
||||
|
||||
private _connectorExist() {
|
||||
return !!this.crud.getElementById(this.connector.id);
|
||||
}
|
||||
|
||||
private _generateTarget(connector: ConnectorElementModel) {
|
||||
const { currentSource } = this;
|
||||
let w = SHAPE_OVERLAY_WIDTH;
|
||||
let h = SHAPE_OVERLAY_HEIGHT;
|
||||
if (isShape(currentSource)) {
|
||||
const bound = Bound.deserialize(currentSource.xywh);
|
||||
w = bound.w;
|
||||
h = bound.h;
|
||||
}
|
||||
const point = connector.target.position;
|
||||
if (!point) return;
|
||||
|
||||
const len = connector.path.length;
|
||||
const angle = normalizeDegAngle(
|
||||
toDegree(Vec.angle(connector.path[len - 2], connector.path[len - 1]))
|
||||
);
|
||||
let nextBound: Bound;
|
||||
let position: Connection['position'];
|
||||
// direction of the connector target arrow
|
||||
let direction: Direction;
|
||||
|
||||
if (angle >= 45 && angle <= 135) {
|
||||
nextBound = new Bound(point[0] - w / 2, point[1], w, h);
|
||||
position = [0.5, 0];
|
||||
direction = Direction.Bottom;
|
||||
} else if (angle >= 135 && angle <= 225) {
|
||||
nextBound = new Bound(point[0] - w, point[1] - h / 2, w, h);
|
||||
position = [1, 0.5];
|
||||
direction = Direction.Left;
|
||||
} else if (angle >= 225 && angle <= 315) {
|
||||
nextBound = new Bound(point[0] - w / 2, point[1] - h, w, h);
|
||||
position = [0.5, 1];
|
||||
direction = Direction.Top;
|
||||
} else {
|
||||
nextBound = new Bound(point[0], point[1] - h / 2, w, h);
|
||||
position = [0, 0.5];
|
||||
direction = Direction.Right;
|
||||
}
|
||||
|
||||
return { nextBound, position, direction };
|
||||
}
|
||||
|
||||
private _getCurrentSourceInfo(): {
|
||||
style: ShapeStyle;
|
||||
type: AUTO_COMPLETE_TARGET_TYPE;
|
||||
} {
|
||||
const { currentSource } = this;
|
||||
if (isShape(currentSource)) {
|
||||
const { shapeType, shapeStyle, radius } = currentSource;
|
||||
return {
|
||||
style: shapeStyle,
|
||||
type: getShapeName(shapeType, radius),
|
||||
};
|
||||
}
|
||||
return {
|
||||
style: ShapeStyle.General,
|
||||
type: 'note',
|
||||
};
|
||||
}
|
||||
|
||||
private _getPanelPosition() {
|
||||
const { viewport } = this.gfx;
|
||||
const { boundingClientRect: viewportRect, zoom } = viewport;
|
||||
const result = this._getTargetXYWH(PANEL_WIDTH / zoom, PANEL_HEIGHT / zoom);
|
||||
const pos = result ? result.xywh.slice(0, 2) : this.position;
|
||||
const coord = viewport.toViewCoord(pos[0], pos[1]);
|
||||
const { width, height } = viewportRect;
|
||||
|
||||
coord[0] = clamp(coord[0], 20, width - 20 - PANEL_WIDTH);
|
||||
coord[1] = clamp(coord[1], 20, height - 20 - PANEL_HEIGHT);
|
||||
|
||||
return coord;
|
||||
}
|
||||
|
||||
private _getTargetXYWH(width: number, height: number) {
|
||||
const result = this._generateTarget(this.connector);
|
||||
if (!result) return null;
|
||||
|
||||
const { nextBound: bound, direction, position } = result;
|
||||
if (!bound) return null;
|
||||
|
||||
const { w, h } = bound;
|
||||
let x = bound.x;
|
||||
let y = bound.y;
|
||||
|
||||
switch (direction) {
|
||||
case Direction.Right:
|
||||
y += h / 2 - height / 2;
|
||||
break;
|
||||
case Direction.Bottom:
|
||||
x -= width / 2 - w / 2;
|
||||
break;
|
||||
case Direction.Left:
|
||||
y += h / 2 - height / 2;
|
||||
x -= width - w;
|
||||
break;
|
||||
case Direction.Top:
|
||||
x -= width / 2 - w / 2;
|
||||
y += h - height;
|
||||
break;
|
||||
}
|
||||
|
||||
const xywh = [x, y, width, height] as XYWH;
|
||||
|
||||
return { xywh, position };
|
||||
}
|
||||
|
||||
private _removeOverlay() {
|
||||
if (this._overlay && this.surface) {
|
||||
this.surface.renderer.removeOverlay(this._overlay);
|
||||
}
|
||||
}
|
||||
|
||||
private _showFrameOverlay() {
|
||||
if (!this.surface) return;
|
||||
|
||||
const bound = this._generateTarget(this.connector)?.nextBound;
|
||||
if (!bound) return;
|
||||
|
||||
const { h } = bound;
|
||||
const w = h / 0.75;
|
||||
const xywh = this._getTargetXYWH(w, h)?.xywh;
|
||||
if (!xywh) return;
|
||||
|
||||
const strokeColor = this.std
|
||||
.get(ThemeProvider)
|
||||
.getCssVariableColor('--affine-black-30');
|
||||
this._overlay = new AutoCompleteFrameOverlay(this.gfx, xywh, strokeColor);
|
||||
this.surface.renderer.addOverlay(this._overlay);
|
||||
}
|
||||
|
||||
private _showNoteOverlay() {
|
||||
const xywh = this._getTargetXYWH(
|
||||
DEFAULT_NOTE_WIDTH,
|
||||
DEFAULT_NOTE_OVERLAY_HEIGHT
|
||||
)?.xywh;
|
||||
if (!xywh) return;
|
||||
if (!this.surface) return;
|
||||
|
||||
const background = this.edgeless.std
|
||||
.get(ThemeProvider)
|
||||
.getColorValue(
|
||||
this.edgeless.std.get(EditPropsStore).lastProps$.value['affine:note']
|
||||
.background,
|
||||
DefaultTheme.noteBackgrounColor,
|
||||
true
|
||||
);
|
||||
this._overlay = new AutoCompleteNoteOverlay(this.gfx, xywh, background);
|
||||
this.surface.renderer.addOverlay(this._overlay);
|
||||
}
|
||||
|
||||
private _showOverlay(targetType: AUTO_COMPLETE_TARGET_TYPE) {
|
||||
this._removeOverlay();
|
||||
if (!this._connectorExist()) return;
|
||||
if (!this.surface) return;
|
||||
|
||||
switch (targetType) {
|
||||
case 'text':
|
||||
this._showTextOverlay();
|
||||
break;
|
||||
case 'note':
|
||||
this._showNoteOverlay();
|
||||
break;
|
||||
case 'frame':
|
||||
this._showFrameOverlay();
|
||||
break;
|
||||
default:
|
||||
this._showShapeOverlay(targetType);
|
||||
}
|
||||
|
||||
this.surface.refresh();
|
||||
}
|
||||
|
||||
private _showShapeOverlay(targetType: TARGET_SHAPE_TYPE) {
|
||||
const bound = this._generateTarget(this.connector)?.nextBound;
|
||||
if (!bound) return;
|
||||
if (!this.surface) return;
|
||||
|
||||
const { x, y, w, h } = bound;
|
||||
const xywh = [x, y, w, h] as XYWH;
|
||||
const { shapeStyle, strokeColor, fillColor, strokeWidth, roughness } =
|
||||
this.edgeless.std.get(EditPropsStore).lastProps$.value[
|
||||
`shape:${targetType}`
|
||||
];
|
||||
|
||||
const stroke = this.edgeless.std
|
||||
.get(ThemeProvider)
|
||||
.getColorValue(strokeColor, DefaultTheme.shapeStrokeColor, true);
|
||||
const fill = this.edgeless.std
|
||||
.get(ThemeProvider)
|
||||
.getColorValue(fillColor, DefaultTheme.shapeFillColor, true);
|
||||
|
||||
const options = {
|
||||
seed: 666,
|
||||
roughness: roughness,
|
||||
strokeLineDash: [0, 0],
|
||||
stroke,
|
||||
strokeWidth,
|
||||
fill,
|
||||
};
|
||||
|
||||
this._overlay = new AutoCompleteShapeOverlay(
|
||||
this.gfx,
|
||||
xywh,
|
||||
targetType,
|
||||
options,
|
||||
shapeStyle
|
||||
);
|
||||
|
||||
this.surface.renderer.addOverlay(this._overlay);
|
||||
}
|
||||
|
||||
private _showTextOverlay() {
|
||||
const xywh = this._getTargetXYWH(
|
||||
DEFAULT_TEXT_WIDTH,
|
||||
DEFAULT_TEXT_HEIGHT
|
||||
)?.xywh;
|
||||
if (!xywh) return;
|
||||
if (!this.surface) return;
|
||||
|
||||
this._overlay = new AutoCompleteTextOverlay(this.gfx, xywh);
|
||||
this.surface.renderer.addOverlay(this._overlay);
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.edgeless.handleEvent('click', ctx => {
|
||||
const { target } = ctx.get('pointerState').raw;
|
||||
const element = captureEventTarget(target);
|
||||
const clickAway = !element?.closest('edgeless-auto-complete-panel');
|
||||
if (clickAway) this.remove();
|
||||
});
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._removeOverlay();
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
this.disposables.add(
|
||||
this.gfx.viewport.viewportUpdated.subscribe(() => this.requestUpdate())
|
||||
);
|
||||
}
|
||||
|
||||
override render() {
|
||||
const position = this._getPanelPosition();
|
||||
if (!position) return nothing;
|
||||
|
||||
const style = styleMap({
|
||||
left: `${position[0]}px`,
|
||||
top: `${position[1]}px`,
|
||||
});
|
||||
const { style: currentSourceStyle, type: currentSourceType } =
|
||||
this._getCurrentSourceInfo();
|
||||
|
||||
const shapeButtons = repeat(
|
||||
ShapeComponentConfig,
|
||||
({ name, generalIcon, scribbledIcon, tooltip }) => html`
|
||||
<edgeless-tool-icon-button
|
||||
.tooltip=${tooltip}
|
||||
.iconSize=${'20px'}
|
||||
@pointerenter=${() => this._showOverlay(name)}
|
||||
@pointerleave=${() => this._removeOverlay()}
|
||||
@click=${() => this._autoComplete(name)}
|
||||
>
|
||||
${currentSourceStyle === 'General' ? generalIcon : scribbledIcon}
|
||||
</edgeless-tool-icon-button>
|
||||
`
|
||||
);
|
||||
|
||||
return html`<div class="auto-complete-panel-container" style=${style}>
|
||||
${shapeButtons}
|
||||
|
||||
<edgeless-tool-icon-button
|
||||
.tooltip=${'Text'}
|
||||
.iconSize=${'20px'}
|
||||
@pointerenter=${() => this._showOverlay('text')}
|
||||
@pointerleave=${() => this._removeOverlay()}
|
||||
@click=${() => this._autoComplete('text')}
|
||||
>
|
||||
${FontFamilyIcon}
|
||||
</edgeless-tool-icon-button>
|
||||
<edgeless-tool-icon-button
|
||||
.tooltip=${'Note'}
|
||||
.iconSize=${'20px'}
|
||||
@pointerenter=${() => this._showOverlay('note')}
|
||||
@pointerleave=${() => this._removeOverlay()}
|
||||
@click=${() => this._autoComplete('note')}
|
||||
>
|
||||
${PageIcon()}
|
||||
</edgeless-tool-icon-button>
|
||||
<edgeless-tool-icon-button
|
||||
.tooltip=${'Frame'}
|
||||
.iconSize=${'20px'}
|
||||
@pointerenter=${() => this._showOverlay('frame')}
|
||||
@pointerleave=${() => this._removeOverlay()}
|
||||
@click=${() => this._autoComplete('frame')}
|
||||
>
|
||||
${FrameIcon()}
|
||||
</edgeless-tool-icon-button>
|
||||
|
||||
<edgeless-tool-icon-button
|
||||
.iconContainerPadding=${0}
|
||||
.tooltip=${capitalizeFirstLetter(currentSourceType)}
|
||||
@pointerenter=${() => this._showOverlay(currentSourceType)}
|
||||
@pointerleave=${() => this._removeOverlay()}
|
||||
@click=${() => this._autoComplete(currentSourceType)}
|
||||
>
|
||||
<div class="row-button">Add a same object</div>
|
||||
</edgeless-tool-icon-button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor connector: ConnectorElementModel;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor currentSource: ShapeElementModel | NoteBlockModel;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor edgeless: BlockComponent;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor position: [number, number];
|
||||
|
||||
@consume({
|
||||
context: stdContext,
|
||||
})
|
||||
accessor std!: BlockStdScope;
|
||||
}
|
||||
@@ -0,0 +1,762 @@
|
||||
import {
|
||||
CanvasElementType,
|
||||
type ConnectionOverlay,
|
||||
ConnectorPathGenerator,
|
||||
EdgelessCRUDIdentifier,
|
||||
getSurfaceBlock,
|
||||
getSurfaceComponent,
|
||||
isNoteBlock,
|
||||
Overlay,
|
||||
OverlayIdentifier,
|
||||
type RoughCanvas,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import { mountShapeTextEditor } from '@blocksuite/affine-gfx-shape';
|
||||
import type {
|
||||
Connection,
|
||||
ConnectorElementModel,
|
||||
NoteBlockModel,
|
||||
ShapeType,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
DEFAULT_NOTE_HEIGHT,
|
||||
DefaultTheme,
|
||||
LayoutType,
|
||||
MindmapElementModel,
|
||||
ShapeElementModel,
|
||||
shapeMethods,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { handleNativeRangeAtPoint } from '@blocksuite/affine-shared/utils';
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import type { Bound, IVec } from '@blocksuite/global/gfx';
|
||||
import { Vec } from '@blocksuite/global/gfx';
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import {
|
||||
ArrowUpBigIcon,
|
||||
PlusIcon,
|
||||
SiblingNodeIcon,
|
||||
SubNodeIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import {
|
||||
type BlockComponent,
|
||||
type BlockStdScope,
|
||||
stdContext,
|
||||
} from '@blocksuite/std';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
|
||||
import { consume } from '@lit/context';
|
||||
import { css, html, LitElement, nothing } from 'lit';
|
||||
import { property, state } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import type { SelectedRect } from '../rects/edgeless-selected-rect.js';
|
||||
import { EdgelessAutoCompletePanel } from './auto-complete-panel.js';
|
||||
import {
|
||||
createEdgelessElement,
|
||||
Direction,
|
||||
getPosition,
|
||||
isShape,
|
||||
MAIN_GAP,
|
||||
nextBound,
|
||||
} from './utils.js';
|
||||
|
||||
class AutoCompleteOverlay extends Overlay {
|
||||
linePoints: IVec[] = [];
|
||||
|
||||
renderShape: ((ctx: CanvasRenderingContext2D) => void) | null = null;
|
||||
|
||||
stroke = '';
|
||||
|
||||
override render(ctx: CanvasRenderingContext2D, _rc: RoughCanvas) {
|
||||
if (this.linePoints.length && this.renderShape) {
|
||||
ctx.setLineDash([2, 2]);
|
||||
ctx.strokeStyle = this.stroke;
|
||||
ctx.beginPath();
|
||||
this.linePoints.forEach((p, index) => {
|
||||
if (index === 0) ctx.moveTo(p[0], p[1]);
|
||||
else ctx.lineTo(p[0], p[1]);
|
||||
});
|
||||
ctx.stroke();
|
||||
|
||||
this.renderShape(ctx);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class EdgelessAutoComplete extends WithDisposable(LitElement) {
|
||||
static override styles = css`
|
||||
.edgeless-auto-complete-container {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
.edgeless-auto-complete-arrow-wrapper {
|
||||
width: 72px;
|
||||
height: 44px;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.edgeless-auto-complete-arrow-wrapper.hidden {
|
||||
display: none;
|
||||
}
|
||||
.edgeless-auto-complete-arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 19px;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
transition:
|
||||
background 0.3s linear,
|
||||
box-shadow 0.2s linear;
|
||||
}
|
||||
.edgeless-auto-complete-arrow-wrapper.mindmap {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.edgeless-auto-complete-arrow-wrapper:hover
|
||||
> .edgeless-auto-complete-arrow {
|
||||
border: 1px solid var(--affine-border-color);
|
||||
box-shadow: var(--affine-shadow-1);
|
||||
background: var(--affine-white);
|
||||
}
|
||||
|
||||
.edgeless-auto-complete-arrow-wrapper
|
||||
> .edgeless-auto-complete-arrow:hover {
|
||||
border: 1px solid var(--affine-white-10);
|
||||
box-shadow: var(--affine-shadow-1);
|
||||
background: var(--affine-primary-color);
|
||||
}
|
||||
|
||||
.edgeless-auto-complete-arrow-wrapper.mindmap
|
||||
> .edgeless-auto-complete-arrow {
|
||||
border: 1px solid var(--affine-border-color);
|
||||
box-shadow: var(--affine-shadow-1);
|
||||
background: var(--affine-white);
|
||||
|
||||
transition:
|
||||
background 0.3s linear,
|
||||
color 0.2s linear;
|
||||
}
|
||||
|
||||
.edgeless-auto-complete-arrow-wrapper.mindmap
|
||||
> .edgeless-auto-complete-arrow:hover {
|
||||
border: 1px solid var(--affine-white-10);
|
||||
box-shadow: var(--affine-shadow-1);
|
||||
background: var(--affine-primary-color);
|
||||
}
|
||||
|
||||
.edgeless-auto-complete-arrow svg {
|
||||
fill: #77757d;
|
||||
color: #77757d;
|
||||
}
|
||||
.edgeless-auto-complete-arrow:hover svg {
|
||||
fill: #ffffff;
|
||||
color: #ffffff;
|
||||
}
|
||||
`;
|
||||
|
||||
private get _surface() {
|
||||
return getSurfaceBlock(this.std.store);
|
||||
}
|
||||
|
||||
private _autoCompleteOverlay!: AutoCompleteOverlay;
|
||||
|
||||
private readonly _onPointerDown = (e: PointerEvent, type: Direction) => {
|
||||
const viewportRect = this.gfx.viewport.boundingClientRect;
|
||||
const start = this.gfx.viewport.toModelCoord(
|
||||
e.clientX - viewportRect.left,
|
||||
e.clientY - viewportRect.top
|
||||
);
|
||||
|
||||
if (!this.edgeless.std.event) return;
|
||||
|
||||
let connector: ConnectorElementModel | null;
|
||||
|
||||
this._disposables.addFromEvent(document, 'pointermove', e => {
|
||||
const point = this.gfx.viewport.toModelCoord(
|
||||
e.clientX - viewportRect.left,
|
||||
e.clientY - viewportRect.top
|
||||
);
|
||||
if (Vec.dist(start, point) > 8 && !this._isMoving) {
|
||||
if (!this.canShowAutoComplete) return;
|
||||
this._isMoving = true;
|
||||
const { startPosition } = getPosition(type);
|
||||
connector = this._addConnector(
|
||||
{
|
||||
id: this.current.id,
|
||||
position: startPosition,
|
||||
},
|
||||
{
|
||||
position: point,
|
||||
}
|
||||
);
|
||||
}
|
||||
if (this._isMoving) {
|
||||
if (!connector) {
|
||||
return;
|
||||
}
|
||||
const otherSideId = connector.source.id;
|
||||
|
||||
connector.target = this.connectionOverlay.renderConnector(
|
||||
point,
|
||||
otherSideId ? [otherSideId] : []
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
this._disposables.addFromEvent(document, 'pointerup', e => {
|
||||
if (!this._isMoving) {
|
||||
this._generateElementOnClick(type);
|
||||
} else if (connector && !connector.target.id) {
|
||||
this.gfx.selection.clear();
|
||||
this._createAutoCompletePanel(e, connector);
|
||||
}
|
||||
|
||||
this._isMoving = false;
|
||||
this.connectionOverlay.clear();
|
||||
this._disposables.dispose();
|
||||
this._disposables = new DisposableGroup();
|
||||
});
|
||||
};
|
||||
|
||||
private _pathGenerator!: ConnectorPathGenerator;
|
||||
|
||||
private _timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
get canShowAutoComplete() {
|
||||
const { current } = this;
|
||||
return isShape(current) || isNoteBlock(current);
|
||||
}
|
||||
|
||||
get connectionOverlay() {
|
||||
return this.std.get(OverlayIdentifier('connection')) as ConnectionOverlay;
|
||||
}
|
||||
|
||||
get crud() {
|
||||
return this.std.get(EdgelessCRUDIdentifier);
|
||||
}
|
||||
|
||||
get gfx() {
|
||||
return this.std.get(GfxControllerIdentifier);
|
||||
}
|
||||
|
||||
private _addConnector(source: Connection, target: Connection) {
|
||||
const id = this.crud.addElement(CanvasElementType.CONNECTOR, {
|
||||
source,
|
||||
target,
|
||||
});
|
||||
if (!id) return null;
|
||||
return this.crud.getElementById(id) as ConnectorElementModel;
|
||||
}
|
||||
|
||||
private _addMindmapNode(target: 'sibling' | 'child') {
|
||||
const mindmap = this.current.group;
|
||||
|
||||
if (!(mindmap instanceof MindmapElementModel)) return;
|
||||
|
||||
const parent =
|
||||
target === 'sibling'
|
||||
? (mindmap.getParentNode(this.current.id) ?? this.current)
|
||||
: this.current;
|
||||
|
||||
const parentNode = mindmap.getNode(parent.id);
|
||||
|
||||
if (!parentNode) return;
|
||||
|
||||
const newNode = mindmap.addNode(
|
||||
parentNode.id,
|
||||
target === 'sibling' ? this.current.id : undefined,
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
|
||||
if (parentNode.detail.collapsed) {
|
||||
mindmap.toggleCollapse(parentNode);
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
mountShapeTextEditor(
|
||||
this.crud.getElementById(newNode) as ShapeElementModel,
|
||||
this.edgeless
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private _computeLine(
|
||||
type: Direction,
|
||||
curShape: ShapeElementModel,
|
||||
nextBound: Bound
|
||||
) {
|
||||
const startBound = this.current.elementBound;
|
||||
const { startPosition, endPosition } = getPosition(type);
|
||||
const nextShape = {
|
||||
xywh: nextBound.serialize(),
|
||||
rotate: curShape.rotate,
|
||||
shapeType: curShape.shapeType,
|
||||
};
|
||||
const startPoint = curShape.getRelativePointLocation(startPosition);
|
||||
const endPoint = curShape.getRelativePointLocation.call(
|
||||
nextShape,
|
||||
endPosition
|
||||
);
|
||||
|
||||
return this._pathGenerator.generateOrthogonalConnectorPath({
|
||||
startBound,
|
||||
endBound: nextBound,
|
||||
startPoint,
|
||||
endPoint,
|
||||
});
|
||||
}
|
||||
|
||||
private _computeNextBound(type: Direction) {
|
||||
if (isShape(this.current)) {
|
||||
const connectedShapes = this._getConnectedElements(this.current).filter(
|
||||
e => e instanceof ShapeElementModel
|
||||
) as ShapeElementModel[];
|
||||
return nextBound(type, this.current, connectedShapes);
|
||||
} else {
|
||||
const bound = this.current.elementBound;
|
||||
switch (type) {
|
||||
case Direction.Right: {
|
||||
bound.x += bound.w + MAIN_GAP;
|
||||
break;
|
||||
}
|
||||
case Direction.Bottom: {
|
||||
bound.y += bound.h + MAIN_GAP;
|
||||
break;
|
||||
}
|
||||
case Direction.Left: {
|
||||
bound.x -= bound.w + MAIN_GAP;
|
||||
break;
|
||||
}
|
||||
case Direction.Top: {
|
||||
bound.y -= bound.h + MAIN_GAP;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return bound;
|
||||
}
|
||||
}
|
||||
|
||||
private _createAutoCompletePanel(
|
||||
e: PointerEvent,
|
||||
connector: ConnectorElementModel
|
||||
) {
|
||||
if (!this.canShowAutoComplete) return;
|
||||
|
||||
const position = this.gfx.viewport.toModelCoord(e.clientX, e.clientY);
|
||||
const autoCompletePanel = new EdgelessAutoCompletePanel(
|
||||
position,
|
||||
this.edgeless,
|
||||
this.current,
|
||||
connector
|
||||
);
|
||||
|
||||
this.edgeless.append(autoCompletePanel);
|
||||
}
|
||||
|
||||
private _generateElementOnClick(type: Direction) {
|
||||
const { doc } = this.edgeless;
|
||||
const bound = this._computeNextBound(type);
|
||||
const id = createEdgelessElement(this.edgeless, this.current, bound);
|
||||
if (!id) return;
|
||||
if (isShape(this.current)) {
|
||||
const { startPosition, endPosition } = getPosition(type);
|
||||
this._addConnector(
|
||||
{
|
||||
id: this.current.id,
|
||||
position: startPosition,
|
||||
},
|
||||
{
|
||||
id,
|
||||
position: endPosition,
|
||||
}
|
||||
);
|
||||
|
||||
mountShapeTextEditor(
|
||||
this.crud.getElementById(id) as ShapeElementModel,
|
||||
this.edgeless
|
||||
);
|
||||
} else {
|
||||
const model = doc.getModelById(id);
|
||||
if (!model) {
|
||||
return;
|
||||
}
|
||||
const [x, y] = this.gfx.viewport.toViewCoord(
|
||||
bound.center[0],
|
||||
bound.y + DEFAULT_NOTE_HEIGHT / 2
|
||||
);
|
||||
requestAnimationFrame(() => {
|
||||
handleNativeRangeAtPoint(x, y);
|
||||
});
|
||||
}
|
||||
|
||||
this.gfx.selection.set({
|
||||
elements: [id],
|
||||
editing: true,
|
||||
});
|
||||
this.removeOverlay();
|
||||
}
|
||||
|
||||
private _getConnectedElements(element: ShapeElementModel) {
|
||||
if (!this._surface) return [];
|
||||
|
||||
return this._surface.getConnectors(element.id).reduce((prev, current) => {
|
||||
if (current.target.id === element.id && current.source.id) {
|
||||
prev.push(
|
||||
this.crud.getElementById(current.source.id) as ShapeElementModel
|
||||
);
|
||||
}
|
||||
if (current.source.id === element.id && current.target.id) {
|
||||
prev.push(
|
||||
this.crud.getElementById(current.target.id) as ShapeElementModel
|
||||
);
|
||||
}
|
||||
|
||||
return prev;
|
||||
}, [] as ShapeElementModel[]);
|
||||
}
|
||||
|
||||
private _getMindmapButtons() {
|
||||
const mindmap = this.current.group as MindmapElementModel;
|
||||
const mindmapDirection =
|
||||
this.current instanceof ShapeElementModel &&
|
||||
mindmap instanceof MindmapElementModel
|
||||
? mindmap.getLayoutDir(this.current.id)
|
||||
: null;
|
||||
const isRoot = mindmap?.tree.id === this.current.id;
|
||||
const mindmapNode = mindmap.getNode(this.current.id);
|
||||
|
||||
let buttons: [
|
||||
Direction,
|
||||
'child' | 'sibling',
|
||||
LayoutType.LEFT | LayoutType.RIGHT,
|
||||
][] = [];
|
||||
|
||||
switch (mindmapDirection) {
|
||||
case LayoutType.LEFT:
|
||||
buttons = [[Direction.Left, 'child', LayoutType.LEFT]];
|
||||
|
||||
if (!isRoot) {
|
||||
buttons.push([Direction.Bottom, 'sibling', mindmapDirection]);
|
||||
}
|
||||
break;
|
||||
case LayoutType.RIGHT:
|
||||
buttons = [[Direction.Right, 'child', LayoutType.RIGHT]];
|
||||
|
||||
if (!isRoot) {
|
||||
buttons.push([Direction.Bottom, 'sibling', mindmapDirection]);
|
||||
}
|
||||
break;
|
||||
case LayoutType.BALANCE:
|
||||
buttons = [
|
||||
[Direction.Right, 'child', LayoutType.RIGHT],
|
||||
[Direction.Left, 'child', LayoutType.LEFT],
|
||||
];
|
||||
break;
|
||||
default:
|
||||
buttons = [];
|
||||
}
|
||||
|
||||
return buttons.length
|
||||
? {
|
||||
mindmapNode,
|
||||
buttons,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
private _initOverlay() {
|
||||
const surface = getSurfaceComponent(this.std);
|
||||
if (!surface) return;
|
||||
this._autoCompleteOverlay = new AutoCompleteOverlay(this.gfx);
|
||||
surface.renderer.addOverlay(this._autoCompleteOverlay);
|
||||
}
|
||||
|
||||
private _renderArrow() {
|
||||
const isShape = this.current instanceof ShapeElementModel;
|
||||
const { selectedRect } = this;
|
||||
const { zoom } = this.gfx.viewport;
|
||||
const width = 72;
|
||||
const height = 44;
|
||||
|
||||
// Auto-complete arrows for shape and note are different
|
||||
// Shape: right, bottom, left, top
|
||||
// Note: right, left
|
||||
const arrowDirections = isShape
|
||||
? [Direction.Right, Direction.Bottom, Direction.Left, Direction.Top]
|
||||
: [Direction.Right, Direction.Left];
|
||||
const arrowMargin = isShape ? height / 2 : height * (2 / 3);
|
||||
const Arrows = arrowDirections.map(type => {
|
||||
let transform = '';
|
||||
|
||||
const iconSize = { width: '16px', height: '16px' };
|
||||
const icon = (isShape ? ArrowUpBigIcon : PlusIcon)(iconSize);
|
||||
|
||||
switch (type) {
|
||||
case Direction.Top:
|
||||
transform += `translate(${
|
||||
selectedRect.width / 2
|
||||
}px, ${-arrowMargin}px)`;
|
||||
break;
|
||||
case Direction.Right:
|
||||
transform += `translate(${selectedRect.width + arrowMargin}px, ${
|
||||
selectedRect.height / 2
|
||||
}px)`;
|
||||
|
||||
isShape && (transform += `rotate(90deg)`);
|
||||
break;
|
||||
case Direction.Bottom:
|
||||
transform += `translate(${selectedRect.width / 2}px, ${
|
||||
selectedRect.height + arrowMargin
|
||||
}px)`;
|
||||
isShape && (transform += `rotate(180deg)`);
|
||||
break;
|
||||
case Direction.Left:
|
||||
transform += `translate(${-arrowMargin}px, ${
|
||||
selectedRect.height / 2
|
||||
}px)`;
|
||||
isShape && (transform += `rotate(-90deg)`);
|
||||
break;
|
||||
}
|
||||
transform += `translate(${-width / 2}px, ${-height / 2}px)`;
|
||||
const arrowWrapperClasses = classMap({
|
||||
'edgeless-auto-complete-arrow-wrapper': true,
|
||||
hidden: !isShape && type === Direction.Left && zoom >= 1.5,
|
||||
});
|
||||
|
||||
return html`<div
|
||||
class=${arrowWrapperClasses}
|
||||
style=${styleMap({
|
||||
transform,
|
||||
transformOrigin: 'left top',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
class="edgeless-auto-complete-arrow"
|
||||
@mouseenter=${() => {
|
||||
this._timer = setTimeout(() => {
|
||||
if (this.current instanceof ShapeElementModel) {
|
||||
const bound = this._computeNextBound(type);
|
||||
const path = this._computeLine(type, this.current, bound);
|
||||
this._showNextShape(
|
||||
this.current,
|
||||
bound,
|
||||
path,
|
||||
this.current.shapeType
|
||||
);
|
||||
}
|
||||
}, 300);
|
||||
}}
|
||||
@mouseleave=${() => {
|
||||
this.removeOverlay();
|
||||
}}
|
||||
@pointerdown=${(e: PointerEvent) => {
|
||||
this._onPointerDown(e, type);
|
||||
}}
|
||||
>
|
||||
${icon}
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
return Arrows;
|
||||
}
|
||||
|
||||
private _renderMindMapButtons() {
|
||||
const mindmapButtons = this._getMindmapButtons();
|
||||
|
||||
if (!mindmapButtons) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { selectedRect } = this;
|
||||
const { zoom } = this.gfx.viewport;
|
||||
const size = 26;
|
||||
const buttonMargin =
|
||||
(mindmapButtons.mindmapNode?.children.length ?? 0) > 0
|
||||
? size / 2 + 32 * zoom
|
||||
: size / 2 + 6;
|
||||
const verticalMargin = size / 2 + 6;
|
||||
|
||||
return mindmapButtons.buttons.map(type => {
|
||||
let transform = '';
|
||||
|
||||
const [position, target, layout] = type;
|
||||
const isLeftLayout = layout === LayoutType.LEFT;
|
||||
const icon = (target === 'child' ? SubNodeIcon : SiblingNodeIcon)({
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
});
|
||||
|
||||
switch (position) {
|
||||
case Direction.Bottom:
|
||||
transform += `translate(${selectedRect.width / 2}px, ${
|
||||
selectedRect.height + verticalMargin
|
||||
}px)`;
|
||||
isLeftLayout && (transform += `scale(-1)`);
|
||||
break;
|
||||
case Direction.Right:
|
||||
transform += `translate(${selectedRect.width + buttonMargin}px, ${
|
||||
selectedRect.height / 2
|
||||
}px)`;
|
||||
break;
|
||||
case Direction.Left:
|
||||
transform += `translate(${-buttonMargin}px, ${
|
||||
selectedRect.height / 2
|
||||
}px)`;
|
||||
|
||||
transform += `scale(-1)`;
|
||||
break;
|
||||
}
|
||||
|
||||
transform += `translate(${-size / 2}px, ${-size / 2}px)`;
|
||||
|
||||
const arrowWrapperClasses = classMap({
|
||||
'edgeless-auto-complete-arrow-wrapper': true,
|
||||
hidden: position === Direction.Left && zoom >= 1.5,
|
||||
mindmap: true,
|
||||
});
|
||||
|
||||
return html`<div
|
||||
class=${arrowWrapperClasses}
|
||||
style=${styleMap({
|
||||
transform,
|
||||
transformOrigin: 'left top',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
class="edgeless-auto-complete-arrow"
|
||||
@pointerdown=${() => {
|
||||
this._addMindmapNode(target);
|
||||
}}
|
||||
>
|
||||
${icon}
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
}
|
||||
|
||||
private _showNextShape(
|
||||
current: ShapeElementModel,
|
||||
bound: Bound,
|
||||
path: IVec[],
|
||||
targetType: ShapeType
|
||||
) {
|
||||
const surface = getSurfaceComponent(this.std);
|
||||
if (!surface) return;
|
||||
|
||||
this._autoCompleteOverlay.stroke = surface.renderer.getColorValue(
|
||||
current.strokeColor,
|
||||
DefaultTheme.shapeStrokeColor,
|
||||
true
|
||||
);
|
||||
this._autoCompleteOverlay.linePoints = path;
|
||||
this._autoCompleteOverlay.renderShape = ctx => {
|
||||
shapeMethods[targetType].draw(ctx, { ...bound, rotate: current.rotate });
|
||||
};
|
||||
surface.refresh();
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this._pathGenerator = new ConnectorPathGenerator({
|
||||
getElementById: id => this.crud.getElementById(id),
|
||||
});
|
||||
this._initOverlay();
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
const { _disposables, edgeless, gfx } = this;
|
||||
|
||||
_disposables.add(
|
||||
this.gfx.selection.slots.updated.subscribe(() => {
|
||||
this._autoCompleteOverlay.linePoints = [];
|
||||
this._autoCompleteOverlay.renderShape = null;
|
||||
})
|
||||
);
|
||||
|
||||
_disposables.add(() => this.removeOverlay());
|
||||
|
||||
_disposables.add(
|
||||
edgeless.host.event.add('pointerMove', ctx => {
|
||||
const evt = ctx.get('pointerState');
|
||||
const [x, y] = gfx.viewport.toModelCoord(evt.x, evt.y);
|
||||
const elm = gfx.getElementByPoint(x, y);
|
||||
|
||||
if (!elm) {
|
||||
this._isHover = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this._isHover = elm === this.current ? true : false;
|
||||
})
|
||||
);
|
||||
|
||||
this.edgeless.handleEvent('dragStart', () => {
|
||||
this._isMoving = true;
|
||||
});
|
||||
this.edgeless.handleEvent('dragEnd', () => {
|
||||
this._isMoving = false;
|
||||
});
|
||||
}
|
||||
|
||||
removeOverlay() {
|
||||
this._timer && clearTimeout(this._timer);
|
||||
const surface = getSurfaceComponent(this.std);
|
||||
if (!surface) return;
|
||||
surface.renderer.removeOverlay(this._autoCompleteOverlay);
|
||||
}
|
||||
|
||||
override render() {
|
||||
const isShape = this.current instanceof ShapeElementModel;
|
||||
const isMindMap = this.current.group instanceof MindmapElementModel;
|
||||
|
||||
if (this._isMoving || (this._isHover && !isShape)) {
|
||||
this.removeOverlay();
|
||||
return nothing;
|
||||
}
|
||||
const { selectedRect } = this;
|
||||
|
||||
return html`<div
|
||||
class="edgeless-auto-complete-container"
|
||||
style=${styleMap({
|
||||
top: selectedRect.top + 'px',
|
||||
left: selectedRect.left + 'px',
|
||||
width: selectedRect.width + 'px',
|
||||
height: selectedRect.height + 'px',
|
||||
transform: `rotate(${selectedRect.rotate}deg)`,
|
||||
})}
|
||||
>
|
||||
${isMindMap ? this._renderMindMapButtons() : this._renderArrow()}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@state()
|
||||
private accessor _isHover = true;
|
||||
|
||||
@state()
|
||||
private accessor _isMoving = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor current!: ShapeElementModel | NoteBlockModel;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor edgeless!: BlockComponent;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor selectedRect!: SelectedRect;
|
||||
|
||||
@consume({
|
||||
context: stdContext,
|
||||
})
|
||||
accessor std!: BlockStdScope;
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
import {
|
||||
EdgelessCRUDIdentifier,
|
||||
type Options,
|
||||
Overlay,
|
||||
type RoughCanvas,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import { type Shape, ShapeFactory } from '@blocksuite/affine-gfx-shape';
|
||||
import {
|
||||
type Connection,
|
||||
getShapeRadius,
|
||||
getShapeType,
|
||||
GroupElementModel,
|
||||
type NoteBlockModel,
|
||||
ShapeElementModel,
|
||||
type ShapeName,
|
||||
type ShapeStyle,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import { Bound, normalizeDegAngle, type XYWH } from '@blocksuite/global/gfx';
|
||||
import { assertType } from '@blocksuite/global/utils';
|
||||
import type { BlockComponent } from '@blocksuite/std';
|
||||
import type { GfxController, GfxModel } from '@blocksuite/std/gfx';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
export enum Direction {
|
||||
Right,
|
||||
Bottom,
|
||||
Left,
|
||||
Top,
|
||||
}
|
||||
|
||||
export const PANEL_WIDTH = 136;
|
||||
export const PANEL_HEIGHT = 108;
|
||||
|
||||
export const MAIN_GAP = 100;
|
||||
export const SECOND_GAP = 20;
|
||||
export const DEFAULT_NOTE_OVERLAY_HEIGHT = 110;
|
||||
export const DEFAULT_TEXT_WIDTH = 116;
|
||||
export const DEFAULT_TEXT_HEIGHT = 24;
|
||||
|
||||
export type TARGET_SHAPE_TYPE = ShapeName;
|
||||
export type AUTO_COMPLETE_TARGET_TYPE =
|
||||
| TARGET_SHAPE_TYPE
|
||||
| 'text'
|
||||
| 'note'
|
||||
| 'frame';
|
||||
|
||||
class AutoCompleteTargetOverlay extends Overlay {
|
||||
xywh: XYWH;
|
||||
|
||||
constructor(gfx: GfxController, xywh: XYWH) {
|
||||
super(gfx);
|
||||
this.xywh = xywh;
|
||||
}
|
||||
|
||||
override render(_ctx: CanvasRenderingContext2D, _rc: RoughCanvas) {}
|
||||
}
|
||||
|
||||
export class AutoCompleteTextOverlay extends AutoCompleteTargetOverlay {
|
||||
constructor(gfx: GfxController, xywh: XYWH) {
|
||||
super(gfx, xywh);
|
||||
}
|
||||
|
||||
override render(ctx: CanvasRenderingContext2D, _rc: RoughCanvas) {
|
||||
const [x, y, w, h] = this.xywh;
|
||||
|
||||
ctx.globalAlpha = 0.4;
|
||||
ctx.strokeStyle = '#1e96eb';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(x, y, w, h);
|
||||
|
||||
// fill text placeholder
|
||||
ctx.font = '15px sans-serif';
|
||||
ctx.fillStyle = '#C0BFC1';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText("Type '/' to insert", x + w / 2, y + h / 2);
|
||||
}
|
||||
}
|
||||
|
||||
export class AutoCompleteNoteOverlay extends AutoCompleteTargetOverlay {
|
||||
private readonly _background: string;
|
||||
|
||||
constructor(gfx: GfxController, xywh: XYWH, background: string) {
|
||||
super(gfx, xywh);
|
||||
this._background = background;
|
||||
}
|
||||
|
||||
override render(ctx: CanvasRenderingContext2D, _rc: RoughCanvas) {
|
||||
const [x, y, w, h] = this.xywh;
|
||||
|
||||
ctx.globalAlpha = 0.4;
|
||||
ctx.fillStyle = this._background;
|
||||
ctx.strokeStyle = 'rgba(0, 0, 0, 0.10)';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x, y, w, h, 8);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
// fill text placeholder
|
||||
ctx.font = '15px sans-serif';
|
||||
ctx.fillStyle = 'black';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText("Type '/' for command", x + 24, y + h / 2);
|
||||
}
|
||||
}
|
||||
|
||||
export class AutoCompleteFrameOverlay extends AutoCompleteTargetOverlay {
|
||||
private readonly _strokeColor;
|
||||
|
||||
constructor(gfx: GfxController, xywh: XYWH, strokeColor: string) {
|
||||
super(gfx, xywh);
|
||||
this._strokeColor = strokeColor;
|
||||
}
|
||||
|
||||
override render(ctx: CanvasRenderingContext2D, _rc: RoughCanvas) {
|
||||
const [x, y, w, h] = this.xywh;
|
||||
// frame title background
|
||||
const titleWidth = 72;
|
||||
const titleHeight = 30;
|
||||
const titleY = y - titleHeight - 10;
|
||||
|
||||
ctx.globalAlpha = 0.4;
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.8)';
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x, titleY, titleWidth, titleHeight, 4);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
// fill title text
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.font = '14px sans-serif';
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText('Frame', x + titleWidth / 2, titleY + titleHeight / 2);
|
||||
|
||||
// frame stroke
|
||||
ctx.globalAlpha = 0.4;
|
||||
ctx.strokeStyle = this._strokeColor;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x, y, w, h, 8);
|
||||
ctx.closePath();
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
export class AutoCompleteShapeOverlay extends Overlay {
|
||||
private readonly _shape: Shape;
|
||||
|
||||
constructor(
|
||||
gfx: GfxController,
|
||||
xywh: XYWH,
|
||||
type: TARGET_SHAPE_TYPE,
|
||||
options: Options,
|
||||
shapeStyle: ShapeStyle
|
||||
) {
|
||||
super(gfx);
|
||||
this._shape = ShapeFactory.createShape(xywh, type, options, shapeStyle);
|
||||
}
|
||||
|
||||
override render(ctx: CanvasRenderingContext2D, rc: RoughCanvas) {
|
||||
ctx.globalAlpha = 0.4;
|
||||
this._shape.draw(ctx, rc);
|
||||
}
|
||||
}
|
||||
|
||||
export function nextBound(
|
||||
type: Direction,
|
||||
curShape: ShapeElementModel,
|
||||
elements: ShapeElementModel[]
|
||||
) {
|
||||
const bound = Bound.deserialize(curShape.xywh);
|
||||
const { x, y, w, h } = bound;
|
||||
let nextBound: Bound;
|
||||
let angle = 0;
|
||||
switch (type) {
|
||||
case Direction.Right:
|
||||
angle = 0;
|
||||
break;
|
||||
case Direction.Bottom:
|
||||
angle = 90;
|
||||
break;
|
||||
case Direction.Left:
|
||||
angle = 180;
|
||||
break;
|
||||
case Direction.Top:
|
||||
angle = 270;
|
||||
break;
|
||||
}
|
||||
angle = normalizeDegAngle(angle + curShape.rotate);
|
||||
|
||||
if (angle >= 45 && angle <= 135) {
|
||||
nextBound = new Bound(x, y + h + MAIN_GAP, w, h);
|
||||
} else if (angle >= 135 && angle <= 225) {
|
||||
nextBound = new Bound(x - w - MAIN_GAP, y, w, h);
|
||||
} else if (angle >= 225 && angle <= 315) {
|
||||
nextBound = new Bound(x, y - h - MAIN_GAP, w, h);
|
||||
} else {
|
||||
nextBound = new Bound(x + w + MAIN_GAP, y, w, h);
|
||||
}
|
||||
|
||||
function isValidBound(bound: Bound) {
|
||||
return !elements.some(a => bound.isOverlapWithBound(a.elementBound));
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
function findValidBound() {
|
||||
count++;
|
||||
const number = Math.ceil(count / 2);
|
||||
const next = nextBound.clone();
|
||||
switch (type) {
|
||||
case Direction.Right:
|
||||
case Direction.Left:
|
||||
next.y =
|
||||
count % 2 === 1
|
||||
? nextBound.y - (h + SECOND_GAP) * number
|
||||
: nextBound.y + (h + SECOND_GAP) * number;
|
||||
break;
|
||||
case Direction.Bottom:
|
||||
case Direction.Top:
|
||||
next.x =
|
||||
count % 2 === 1
|
||||
? nextBound.x - (w + SECOND_GAP) * number
|
||||
: nextBound.x + (w + SECOND_GAP) * number;
|
||||
break;
|
||||
}
|
||||
if (isValidBound(next)) return next;
|
||||
return findValidBound();
|
||||
}
|
||||
|
||||
return isValidBound(nextBound) ? nextBound : findValidBound();
|
||||
}
|
||||
|
||||
export function getPosition(type: Direction) {
|
||||
let startPosition: Connection['position'];
|
||||
let endPosition: Connection['position'];
|
||||
|
||||
switch (type) {
|
||||
case Direction.Right:
|
||||
startPosition = [1, 0.5];
|
||||
endPosition = [0, 0.5];
|
||||
break;
|
||||
case Direction.Bottom:
|
||||
startPosition = [0.5, 1];
|
||||
endPosition = [0.5, 0];
|
||||
break;
|
||||
case Direction.Left:
|
||||
startPosition = [0, 0.5];
|
||||
endPosition = [1, 0.5];
|
||||
break;
|
||||
case Direction.Top:
|
||||
startPosition = [0.5, 0];
|
||||
endPosition = [0.5, 1];
|
||||
break;
|
||||
}
|
||||
return { startPosition, endPosition };
|
||||
}
|
||||
|
||||
export function isShape(element: unknown): element is ShapeElementModel {
|
||||
return element instanceof ShapeElementModel;
|
||||
}
|
||||
|
||||
export function capitalizeFirstLetter(str: string) {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
}
|
||||
|
||||
export function createEdgelessElement(
|
||||
edgeless: BlockComponent,
|
||||
current: ShapeElementModel | NoteBlockModel,
|
||||
bound: Bound
|
||||
) {
|
||||
const crud = edgeless.std.get(EdgelessCRUDIdentifier);
|
||||
|
||||
let id;
|
||||
let element: GfxModel | null = null;
|
||||
|
||||
if (isShape(current)) {
|
||||
id = crud.addElement(current.type, {
|
||||
...current.serialize(),
|
||||
text: new Y.Text(),
|
||||
xywh: bound.serialize(),
|
||||
});
|
||||
if (!id) return null;
|
||||
element = crud.getElementById(id);
|
||||
} else {
|
||||
const { doc } = edgeless;
|
||||
id = doc.addBlock(
|
||||
'affine:note',
|
||||
{
|
||||
background: current.props.background,
|
||||
displayMode: current.props.displayMode,
|
||||
edgeless: current.props.edgeless,
|
||||
xywh: bound.serialize(),
|
||||
},
|
||||
edgeless.model.id
|
||||
);
|
||||
const note = doc.getBlock(id)?.model;
|
||||
if (!note) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.GfxBlockElementError,
|
||||
'Note block is not found after creation'
|
||||
);
|
||||
}
|
||||
assertType<NoteBlockModel>(note);
|
||||
doc.updateBlock(note, () => {
|
||||
note.props.edgeless.collapse = true;
|
||||
});
|
||||
doc.addBlock('affine:paragraph', {}, note.id);
|
||||
|
||||
element = note;
|
||||
}
|
||||
|
||||
if (!element) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.GfxBlockElementError,
|
||||
'Element is not found after creation'
|
||||
);
|
||||
}
|
||||
|
||||
const group = current.group;
|
||||
if (group instanceof GroupElementModel) {
|
||||
group.addChild(element);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
export function createShapeElement(
|
||||
edgeless: BlockComponent,
|
||||
current: ShapeElementModel | NoteBlockModel,
|
||||
targetType: TARGET_SHAPE_TYPE
|
||||
) {
|
||||
const crud = edgeless.std.get(EdgelessCRUDIdentifier);
|
||||
const id = crud.addElement('shape', {
|
||||
shapeType: getShapeType(targetType),
|
||||
radius: getShapeRadius(targetType),
|
||||
text: new Y.Text(),
|
||||
});
|
||||
if (!id) return null;
|
||||
const element = crud.getElementById(id);
|
||||
const group = current.group;
|
||||
if (group instanceof GroupElementModel && element) {
|
||||
group.addChild(element);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
import type { NoteBlockComponent } from '@blocksuite/affine-block-note';
|
||||
import {
|
||||
EdgelessLegacySlotIdentifier,
|
||||
getSurfaceComponent,
|
||||
isNoteBlock,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
DEFAULT_NOTE_HEIGHT,
|
||||
type NoteBlockModel,
|
||||
type RootBlockModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { EDGELESS_BLOCK_CHILD_PADDING } from '@blocksuite/affine-shared/consts';
|
||||
import { TelemetryProvider } from '@blocksuite/affine-shared/services';
|
||||
import { getRectByBlockComponent } from '@blocksuite/affine-shared/utils';
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import { deserializeXYWH, Point, serializeXYWH } from '@blocksuite/global/gfx';
|
||||
import { ScissorsIcon } from '@blocksuite/icons/lit';
|
||||
import { WidgetComponent } from '@blocksuite/std';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
|
||||
import { css, html, nothing, type PropertyValues } from 'lit';
|
||||
import { state } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import type { EdgelessSelectedRectWidget } from '../rects/edgeless-selected-rect';
|
||||
|
||||
const DIVIDING_LINE_OFFSET = 4;
|
||||
const NEW_NOTE_GAP = 40;
|
||||
|
||||
const styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.note-slicer-container {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.note-slicer-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
border-radius: 4px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--affine-icon-color);
|
||||
border: 1px solid var(--affine-border-color);
|
||||
background-color: var(--affine-background-overlay-panel-color);
|
||||
box-shadow: var(--affine-menu-shadow);
|
||||
cursor: pointer;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
transform-origin: left top;
|
||||
z-index: var(--affine-z-index-popover);
|
||||
opacity: 0;
|
||||
transition: opacity 150ms cubic-bezier(0.25, 0.1, 0.25, 1);
|
||||
}
|
||||
|
||||
.note-slicer-dividing-line-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.note-slicer-dividing-line {
|
||||
display: block;
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
z-index: var(--affine-z-index-popover);
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
var(--affine-black-10) 50%,
|
||||
transparent 50%
|
||||
);
|
||||
background-size: 4px 100%;
|
||||
}
|
||||
.note-slicer-dividing-line-container.active .note-slicer-dividing-line {
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
var(--affine-black-60) 50%,
|
||||
transparent 50%
|
||||
);
|
||||
animation: slide 0.3s linear infinite;
|
||||
}
|
||||
@keyframes slide {
|
||||
0% {
|
||||
background-position: 0 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -4px 0;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const NOTE_SLICER_WIDGET = 'note-slicer';
|
||||
|
||||
export class NoteSlicer extends WidgetComponent<RootBlockModel> {
|
||||
static override styles = styles;
|
||||
|
||||
private _divingLinePositions: Point[] = [];
|
||||
|
||||
private _hidden = false;
|
||||
|
||||
private _noteBlockIds: string[] = [];
|
||||
|
||||
private _noteDisposables: DisposableGroup | null = null;
|
||||
|
||||
get _editorHost() {
|
||||
return this.std.host;
|
||||
}
|
||||
|
||||
get _noteBlock() {
|
||||
if (!this._editorHost) return null;
|
||||
const noteBlock = this._editorHost.view.getBlock(
|
||||
this._anchorNote?.id ?? ''
|
||||
);
|
||||
return noteBlock ? (noteBlock as NoteBlockComponent) : null;
|
||||
}
|
||||
|
||||
get _selection() {
|
||||
return this.gfx.selection;
|
||||
}
|
||||
|
||||
get _viewportOffset() {
|
||||
const { viewport } = this.gfx;
|
||||
return {
|
||||
left: viewport.left ?? 0,
|
||||
top: viewport.top ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
get _zoom() {
|
||||
return this.gfx.viewport.zoom;
|
||||
}
|
||||
|
||||
get gfx() {
|
||||
return this.std.get(GfxControllerIdentifier);
|
||||
}
|
||||
|
||||
get selectedRectEle() {
|
||||
return this.host.view.getWidget(
|
||||
'edgeless-selected-rect',
|
||||
this.host.id
|
||||
) as EdgelessSelectedRectWidget | null;
|
||||
}
|
||||
|
||||
private _sliceNote() {
|
||||
if (!this._anchorNote || !this._noteBlockIds.length) return;
|
||||
const doc = this.doc;
|
||||
|
||||
const {
|
||||
index: originIndex,
|
||||
xywh,
|
||||
background,
|
||||
displayMode,
|
||||
} = this._anchorNote.props;
|
||||
const { children } = this._anchorNote;
|
||||
const {
|
||||
collapse: _,
|
||||
collapsedHeight: __,
|
||||
...restOfEdgeless
|
||||
} = this._anchorNote.props.edgeless;
|
||||
const anchorBlockId = this._noteBlockIds[this._activeSlicerIndex];
|
||||
if (!anchorBlockId) return;
|
||||
const sliceIndex = children.findIndex(block => block.id === anchorBlockId);
|
||||
const resetBlocks = children.slice(sliceIndex + 1);
|
||||
const [x, , width] = deserializeXYWH(xywh);
|
||||
const sliceVerticalPos =
|
||||
this._divingLinePositions[this._activeSlicerIndex].y;
|
||||
const newY = this.gfx.viewport.toModelCoord(x, sliceVerticalPos)[1];
|
||||
const newNoteId = this.doc.addBlock(
|
||||
'affine:note',
|
||||
{
|
||||
background,
|
||||
displayMode,
|
||||
xywh: serializeXYWH(x, newY + NEW_NOTE_GAP, width, DEFAULT_NOTE_HEIGHT),
|
||||
index: originIndex + 1,
|
||||
edgeless: restOfEdgeless,
|
||||
},
|
||||
doc.root?.id
|
||||
);
|
||||
|
||||
doc.moveBlocks(resetBlocks, doc.getModelById(newNoteId) as NoteBlockModel);
|
||||
|
||||
this._activeSlicerIndex = 0;
|
||||
this._selection.set({
|
||||
elements: [newNoteId],
|
||||
editing: false,
|
||||
});
|
||||
|
||||
this.std.getOptional(TelemetryProvider)?.track('SplitNote', {
|
||||
control: 'NoteSlicer',
|
||||
});
|
||||
}
|
||||
|
||||
private _updateActiveSlicerIndex(pos: Point) {
|
||||
const { _divingLinePositions } = this;
|
||||
const curY = pos.y + DIVIDING_LINE_OFFSET * this._zoom;
|
||||
let index = -1;
|
||||
for (let i = 0; i < _divingLinePositions.length; i++) {
|
||||
const currentY = _divingLinePositions[i].y;
|
||||
const previousY = i > 0 ? _divingLinePositions[i - 1].y : 0;
|
||||
const midY = (currentY + previousY) / 2;
|
||||
if (curY < midY) {
|
||||
break;
|
||||
}
|
||||
index++;
|
||||
}
|
||||
|
||||
if (index < 0) index = 0;
|
||||
this._activeSlicerIndex = index;
|
||||
}
|
||||
|
||||
private _updateDivingLineAndBlockIds() {
|
||||
if (!this._anchorNote || !this._noteBlock) {
|
||||
this._divingLinePositions = [];
|
||||
this._noteBlockIds = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const divingLinePositions: Point[] = [];
|
||||
const noteBlockIds: string[] = [];
|
||||
const noteRect = this._noteBlock.getBoundingClientRect();
|
||||
const noteTop = noteRect.top;
|
||||
const noteBottom = noteRect.bottom;
|
||||
|
||||
for (let i = 0; i < this._anchorNote.children.length - 1; i++) {
|
||||
const child = this._anchorNote.children[i];
|
||||
const rect = this.host.view.getBlock(child.id)?.getBoundingClientRect();
|
||||
|
||||
if (rect && rect.bottom > noteTop && rect.bottom < noteBottom) {
|
||||
const x = rect.x - this._viewportOffset.left;
|
||||
const y =
|
||||
rect.bottom +
|
||||
DIVIDING_LINE_OFFSET * this._zoom -
|
||||
this._viewportOffset.top;
|
||||
divingLinePositions.push(new Point(x, y));
|
||||
noteBlockIds.push(child.id);
|
||||
}
|
||||
}
|
||||
|
||||
this._divingLinePositions = divingLinePositions;
|
||||
this._noteBlockIds = noteBlockIds;
|
||||
}
|
||||
|
||||
private _updateSlicedNote() {
|
||||
const { selectedElements } = this.gfx.selection;
|
||||
|
||||
if (
|
||||
!this.gfx.selection.editing &&
|
||||
selectedElements.length === 1 &&
|
||||
isNoteBlock(selectedElements[0])
|
||||
) {
|
||||
this._anchorNote = selectedElements[0];
|
||||
} else {
|
||||
this._anchorNote = null;
|
||||
}
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
const { disposables, std, gfx } = this;
|
||||
|
||||
this._updateDivingLineAndBlockIds();
|
||||
|
||||
const slots = std.get(EdgelessLegacySlotIdentifier);
|
||||
|
||||
disposables.add(
|
||||
slots.elementResizeStart.subscribe(() => {
|
||||
this._isResizing = true;
|
||||
})
|
||||
);
|
||||
|
||||
disposables.add(
|
||||
slots.elementResizeEnd.subscribe(() => {
|
||||
this._isResizing = false;
|
||||
})
|
||||
);
|
||||
|
||||
disposables.add(
|
||||
std.event.add('pointerMove', ctx => {
|
||||
if (this._hidden) this._hidden = false;
|
||||
|
||||
const state = ctx.get('pointerState');
|
||||
const pos = new Point(state.x, state.y);
|
||||
this._updateActiveSlicerIndex(pos);
|
||||
})
|
||||
);
|
||||
|
||||
disposables.add(
|
||||
gfx.viewport.viewportUpdated.subscribe(() => {
|
||||
this._hidden = true;
|
||||
this.requestUpdate();
|
||||
})
|
||||
);
|
||||
|
||||
disposables.add(
|
||||
gfx.selection.slots.updated.subscribe(() => {
|
||||
this._enableNoteSlicer = false;
|
||||
this._updateSlicedNote();
|
||||
|
||||
if (this.selectedRectEle) {
|
||||
this.selectedRectEle.autoCompleteOff = false;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
disposables.add(
|
||||
slots.toggleNoteSlicer.subscribe(() => {
|
||||
this._enableNoteSlicer = !this._enableNoteSlicer;
|
||||
|
||||
if (this.selectedRectEle && this._enableNoteSlicer) {
|
||||
this.selectedRectEle.autoCompleteOff = true;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const surface = getSurfaceComponent(std);
|
||||
if (surface?.isConnected && std.event) {
|
||||
disposables.add(
|
||||
std.event.add('click', ctx => {
|
||||
const event = ctx.get('pointerState');
|
||||
const { raw } = event;
|
||||
const target = raw.target as HTMLElement;
|
||||
if (!target) return;
|
||||
|
||||
if (target.closest('note-slicer')) {
|
||||
this._sliceNote();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.disposables.dispose();
|
||||
this._noteDisposables?.dispose();
|
||||
this._noteDisposables = null;
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
if (!this.block?.service) return;
|
||||
this.disposables.add(
|
||||
this.block.service.uiEventDispatcher.add('wheel', () => {
|
||||
this._hidden = true;
|
||||
this.requestUpdate();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (
|
||||
this.doc.readonly ||
|
||||
this._hidden ||
|
||||
this._isResizing ||
|
||||
!this._anchorNote ||
|
||||
!this._enableNoteSlicer
|
||||
) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
this._updateDivingLineAndBlockIds();
|
||||
|
||||
const noteBlock = this._noteBlock;
|
||||
if (!noteBlock || !this._divingLinePositions.length) return nothing;
|
||||
|
||||
const rect = getRectByBlockComponent(noteBlock);
|
||||
const width = rect.width - 2 * EDGELESS_BLOCK_CHILD_PADDING * this._zoom;
|
||||
const buttonPosition = this._divingLinePositions[this._activeSlicerIndex];
|
||||
|
||||
return html`<div class="note-slicer-container">
|
||||
<div
|
||||
class="note-slicer-button"
|
||||
style=${styleMap({
|
||||
left: `${buttonPosition.x - 66 * this._zoom}px`,
|
||||
top: `${buttonPosition.y}px`,
|
||||
opacity: 1,
|
||||
transform: 'translateY(-50%)',
|
||||
})}
|
||||
>
|
||||
${ScissorsIcon({ width: '16px', height: '16px' })}
|
||||
</div>
|
||||
${this._divingLinePositions.map((pos, idx) => {
|
||||
const dividingLineClasses = classMap({
|
||||
'note-slicer-dividing-line-container': true,
|
||||
active: idx === this._activeSlicerIndex,
|
||||
});
|
||||
return html`<div
|
||||
class=${dividingLineClasses}
|
||||
style=${styleMap({
|
||||
left: `${pos.x}px`,
|
||||
top: `${pos.y}px`,
|
||||
width: `${width}px`,
|
||||
})}
|
||||
>
|
||||
<span class="note-slicer-dividing-line"></span>
|
||||
</div>`;
|
||||
})}
|
||||
</div> `;
|
||||
}
|
||||
|
||||
protected override updated(_changedProperties: PropertyValues) {
|
||||
super.updated(_changedProperties);
|
||||
if (_changedProperties.has('anchorNote')) {
|
||||
this._noteDisposables?.dispose();
|
||||
this._noteDisposables = null;
|
||||
if (this._anchorNote) {
|
||||
this._noteDisposables = new DisposableGroup();
|
||||
this._noteDisposables.add(
|
||||
this._anchorNote.propsUpdated.subscribe(({ key }) => {
|
||||
if (key === 'children' || key === 'xywh') {
|
||||
this.requestUpdate();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@state()
|
||||
private accessor _activeSlicerIndex = 0;
|
||||
|
||||
@state()
|
||||
private accessor _anchorNote: NoteBlockModel | null = null;
|
||||
|
||||
@state()
|
||||
private accessor _enableNoteSlicer = false;
|
||||
|
||||
@state()
|
||||
private accessor _isResizing = false;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { RootBlockModel } from '@blocksuite/affine-model';
|
||||
import { WidgetComponent } from '@blocksuite/std';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { css, html, nothing, unsafeCSS } from 'lit';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import type { EdgelessRootBlockComponent } from '../../edgeless-root-block.js';
|
||||
import { DefaultTool } from '../../gfx-tool/default-tool.js';
|
||||
import { DefaultModeDragType } from '../../gfx-tool/default-tool-ext/ext.js';
|
||||
|
||||
export const EDGELESS_DRAGGING_AREA_WIDGET = 'edgeless-dragging-area-rect';
|
||||
|
||||
export class EdgelessDraggingAreaRectWidget extends WidgetComponent<
|
||||
RootBlockModel,
|
||||
EdgelessRootBlockComponent
|
||||
> {
|
||||
static override styles = css`
|
||||
.affine-edgeless-dragging-area {
|
||||
position: absolute;
|
||||
background: ${unsafeCSS(
|
||||
cssVarV2('edgeless/selection/selectionMarqueeBackground', '#1E96EB14')
|
||||
)};
|
||||
box-sizing: border-box;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: ${unsafeCSS(
|
||||
cssVarV2('edgeless/selection/selectionMarqueeBorder', '#1E96EB')
|
||||
)};
|
||||
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
`;
|
||||
|
||||
override render() {
|
||||
if (!this.block) {
|
||||
return nothing;
|
||||
}
|
||||
const rect = this.block.gfx.tool.draggingViewArea$.value;
|
||||
const tool = this.block.gfx.tool.currentTool$.value;
|
||||
|
||||
if (
|
||||
rect.w === 0 ||
|
||||
rect.h === 0 ||
|
||||
!(tool instanceof DefaultTool) ||
|
||||
tool.dragType !== DefaultModeDragType.Selecting
|
||||
)
|
||||
return nothing;
|
||||
|
||||
const style = {
|
||||
left: rect.x + 'px',
|
||||
top: rect.y + 'px',
|
||||
width: rect.w + 'px',
|
||||
height: rect.h + 'px',
|
||||
};
|
||||
|
||||
return html`
|
||||
<div class="affine-edgeless-dragging-area" style=${styleMap(style)}></div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,219 @@
|
||||
import type { IVec } from '@blocksuite/global/gfx';
|
||||
import { html, nothing } from 'lit';
|
||||
|
||||
export enum HandleDirection {
|
||||
Bottom = 'bottom',
|
||||
BottomLeft = 'bottom-left',
|
||||
BottomRight = 'bottom-right',
|
||||
Left = 'left',
|
||||
Right = 'right',
|
||||
Top = 'top',
|
||||
TopLeft = 'top-left',
|
||||
TopRight = 'top-right',
|
||||
}
|
||||
|
||||
function ResizeHandle(
|
||||
handleDirection: HandleDirection,
|
||||
onPointerDown?: (e: PointerEvent, direction: HandleDirection) => void,
|
||||
updateCursor?: (
|
||||
dragging: boolean,
|
||||
options?: {
|
||||
type: 'resize' | 'rotate';
|
||||
target?: HTMLElement;
|
||||
point?: IVec;
|
||||
}
|
||||
) => void,
|
||||
hideEdgeHandle?: boolean
|
||||
) {
|
||||
const handlerPointerDown = (e: PointerEvent) => {
|
||||
e.stopPropagation();
|
||||
onPointerDown && onPointerDown(e, handleDirection);
|
||||
};
|
||||
|
||||
const pointerEnter = (type: 'resize' | 'rotate') => (e: PointerEvent) => {
|
||||
e.stopPropagation();
|
||||
if (e.buttons === 1 || !updateCursor) return;
|
||||
|
||||
const { clientX, clientY } = e;
|
||||
const target = e.target as HTMLElement;
|
||||
const point: IVec = [clientX, clientY];
|
||||
|
||||
updateCursor(true, { type, point, target });
|
||||
};
|
||||
|
||||
const pointerLeave = (e: PointerEvent) => {
|
||||
e.stopPropagation();
|
||||
if (e.buttons === 1 || !updateCursor) return;
|
||||
|
||||
updateCursor(false);
|
||||
};
|
||||
|
||||
const rotationTpl =
|
||||
handleDirection === HandleDirection.Top ||
|
||||
handleDirection === HandleDirection.Bottom ||
|
||||
handleDirection === HandleDirection.Left ||
|
||||
handleDirection === HandleDirection.Right
|
||||
? nothing
|
||||
: html`<div
|
||||
class="rotate"
|
||||
@pointerover=${pointerEnter('rotate')}
|
||||
@pointerout=${pointerLeave}
|
||||
></div>`;
|
||||
|
||||
return html`<div
|
||||
class="handle"
|
||||
aria-label=${handleDirection}
|
||||
@pointerdown=${handlerPointerDown}
|
||||
>
|
||||
${rotationTpl}
|
||||
<div
|
||||
class="resize${hideEdgeHandle && ' transparent-handle'}"
|
||||
@pointerover=${pointerEnter('resize')}
|
||||
@pointerout=${pointerLeave}
|
||||
></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate how selected elements can be resized.
|
||||
*
|
||||
* - edge: The selected elements can only be resized dragging edge, usually when note element is selected
|
||||
* - all: The selected elements can be resize both dragging edge or corner, usually when all elements are `shape`
|
||||
* - none: The selected elements can't be resized, usually when all elements are `connector`
|
||||
* - corner: The selected elements can only be resize dragging corner, this is by default mode
|
||||
* - edgeAndCorner: The selected elements can be resize both dragging left right edge or corner, usually when all elements are 'text'
|
||||
*/
|
||||
export type ResizeMode = 'edge' | 'all' | 'none' | 'corner' | 'edgeAndCorner';
|
||||
|
||||
export function ResizeHandles(
|
||||
resizeMode: ResizeMode,
|
||||
onPointerDown: (e: PointerEvent, direction: HandleDirection) => void,
|
||||
updateCursor?: (
|
||||
dragging: boolean,
|
||||
options?: {
|
||||
type: 'resize' | 'rotate';
|
||||
target?: HTMLElement;
|
||||
point?: IVec;
|
||||
}
|
||||
) => void
|
||||
) {
|
||||
const getCornerHandles = () => {
|
||||
const handleTopLeft = ResizeHandle(
|
||||
HandleDirection.TopLeft,
|
||||
onPointerDown,
|
||||
updateCursor
|
||||
);
|
||||
const handleTopRight = ResizeHandle(
|
||||
HandleDirection.TopRight,
|
||||
onPointerDown,
|
||||
updateCursor
|
||||
);
|
||||
const handleBottomLeft = ResizeHandle(
|
||||
HandleDirection.BottomLeft,
|
||||
onPointerDown,
|
||||
updateCursor
|
||||
);
|
||||
const handleBottomRight = ResizeHandle(
|
||||
HandleDirection.BottomRight,
|
||||
onPointerDown,
|
||||
updateCursor
|
||||
);
|
||||
return {
|
||||
handleTopLeft,
|
||||
handleTopRight,
|
||||
handleBottomLeft,
|
||||
handleBottomRight,
|
||||
};
|
||||
};
|
||||
const getEdgeHandles = (hideEdgeHandle?: boolean) => {
|
||||
const handleLeft = ResizeHandle(
|
||||
HandleDirection.Left,
|
||||
onPointerDown,
|
||||
updateCursor,
|
||||
hideEdgeHandle
|
||||
);
|
||||
const handleRight = ResizeHandle(
|
||||
HandleDirection.Right,
|
||||
onPointerDown,
|
||||
updateCursor,
|
||||
hideEdgeHandle
|
||||
);
|
||||
return { handleLeft, handleRight };
|
||||
};
|
||||
const getEdgeVerticalHandles = (hideEdgeHandle?: boolean) => {
|
||||
const handleTop = ResizeHandle(
|
||||
HandleDirection.Top,
|
||||
onPointerDown,
|
||||
updateCursor,
|
||||
hideEdgeHandle
|
||||
);
|
||||
const handleBottom = ResizeHandle(
|
||||
HandleDirection.Bottom,
|
||||
onPointerDown,
|
||||
updateCursor,
|
||||
hideEdgeHandle
|
||||
);
|
||||
return { handleTop, handleBottom };
|
||||
};
|
||||
switch (resizeMode) {
|
||||
case 'corner': {
|
||||
const {
|
||||
handleTopLeft,
|
||||
handleTopRight,
|
||||
handleBottomLeft,
|
||||
handleBottomRight,
|
||||
} = getCornerHandles();
|
||||
|
||||
// prettier-ignore
|
||||
return html`
|
||||
${handleTopLeft}
|
||||
${handleTopRight}
|
||||
${handleBottomLeft}
|
||||
${handleBottomRight}
|
||||
`;
|
||||
}
|
||||
case 'edge': {
|
||||
const { handleLeft, handleRight } = getEdgeHandles();
|
||||
return html`${handleLeft} ${handleRight}`;
|
||||
}
|
||||
case 'all': {
|
||||
const {
|
||||
handleTopLeft,
|
||||
handleTopRight,
|
||||
handleBottomLeft,
|
||||
handleBottomRight,
|
||||
} = getCornerHandles();
|
||||
const { handleLeft, handleRight } = getEdgeHandles(true);
|
||||
const { handleTop, handleBottom } = getEdgeVerticalHandles(true);
|
||||
|
||||
// prettier-ignore
|
||||
return html`
|
||||
${handleTopLeft}
|
||||
${handleTop}
|
||||
${handleTopRight}
|
||||
${handleRight}
|
||||
${handleBottomRight}
|
||||
${handleBottom}
|
||||
${handleBottomLeft}
|
||||
${handleLeft}
|
||||
`;
|
||||
}
|
||||
case 'edgeAndCorner': {
|
||||
const {
|
||||
handleTopLeft,
|
||||
handleTopRight,
|
||||
handleBottomLeft,
|
||||
handleBottomRight,
|
||||
} = getCornerHandles();
|
||||
const { handleLeft, handleRight } = getEdgeHandles(true);
|
||||
|
||||
return html`
|
||||
${handleTopLeft} ${handleTopRight} ${handleRight} ${handleBottomRight}
|
||||
${handleBottomLeft} ${handleLeft}
|
||||
`;
|
||||
}
|
||||
case 'none': {
|
||||
return nothing;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,705 @@
|
||||
import { NOTE_MIN_WIDTH } from '@blocksuite/affine-model';
|
||||
import {
|
||||
Bound,
|
||||
getQuadBoundWithRotation,
|
||||
type IPoint,
|
||||
type IVec,
|
||||
type PointLocation,
|
||||
rotatePoints,
|
||||
} from '@blocksuite/global/gfx';
|
||||
|
||||
import type { SelectableProps } from '../../utils/query.js';
|
||||
import { HandleDirection, type ResizeMode } from './resize-handles.js';
|
||||
|
||||
// 15deg
|
||||
const SHIFT_LOCKING_ANGLE = Math.PI / 12;
|
||||
|
||||
type DragStartHandler = () => void;
|
||||
type DragEndHandler = () => void;
|
||||
|
||||
type ResizeMoveHandler = (
|
||||
bounds: Map<
|
||||
string,
|
||||
{
|
||||
bound: Bound;
|
||||
path?: PointLocation[];
|
||||
matrix?: DOMMatrix;
|
||||
}
|
||||
>,
|
||||
direction: HandleDirection
|
||||
) => void;
|
||||
|
||||
type RotateMoveHandler = (point: IPoint, rotate: number) => void;
|
||||
|
||||
export class HandleResizeManager {
|
||||
private _aspectRatio = 1;
|
||||
|
||||
private _bounds = new Map<
|
||||
string,
|
||||
{
|
||||
bound: Bound;
|
||||
rotate: number;
|
||||
}
|
||||
>();
|
||||
|
||||
/**
|
||||
* Current rect of selected elements, it may change during resizing or moving
|
||||
*/
|
||||
private _currentRect = new DOMRect();
|
||||
|
||||
private _dragDirection: HandleDirection = HandleDirection.Left;
|
||||
|
||||
private _dragging = false;
|
||||
|
||||
private _dragPos: {
|
||||
start: { x: number; y: number };
|
||||
end: { x: number; y: number };
|
||||
} = {
|
||||
start: { x: 0, y: 0 },
|
||||
end: { x: 0, y: 0 },
|
||||
};
|
||||
|
||||
private _locked = false;
|
||||
|
||||
private readonly _onDragEnd: DragEndHandler;
|
||||
|
||||
private readonly _onDragStart: DragStartHandler;
|
||||
|
||||
private readonly _onResizeMove: ResizeMoveHandler;
|
||||
|
||||
private readonly _onRotateMove: RotateMoveHandler;
|
||||
|
||||
private _origin: { x: number; y: number } = { x: 0, y: 0 };
|
||||
|
||||
/**
|
||||
* Record inital rect of selected elements
|
||||
*/
|
||||
private _originalRect = new DOMRect();
|
||||
|
||||
private _proportion = false;
|
||||
|
||||
private _proportional = false;
|
||||
|
||||
private _resizeMode: ResizeMode = 'none';
|
||||
|
||||
private _rotate = 0;
|
||||
|
||||
private _rotation = false;
|
||||
|
||||
private _shiftKey = false;
|
||||
|
||||
private _target: HTMLElement | null = null;
|
||||
|
||||
private _zoom = 1;
|
||||
|
||||
onPointerDown = (
|
||||
e: PointerEvent,
|
||||
direction: HandleDirection,
|
||||
proportional = false
|
||||
) => {
|
||||
// Prevent selection action from being triggered
|
||||
e.stopPropagation();
|
||||
|
||||
this._locked = false;
|
||||
this._target = e.target as HTMLElement;
|
||||
this._dragDirection = direction;
|
||||
this._dragPos.start = { x: e.x, y: e.y };
|
||||
this._dragPos.end = { x: e.x, y: e.y };
|
||||
this._rotation = this._target.classList.contains('rotate');
|
||||
this._proportional = proportional;
|
||||
|
||||
if (this._rotation) {
|
||||
const rect = this._target
|
||||
.closest('.affine-edgeless-selected-rect')
|
||||
?.getBoundingClientRect();
|
||||
if (!rect) {
|
||||
return;
|
||||
}
|
||||
const { left, top, right, bottom } = rect;
|
||||
const x = (left + right) / 2;
|
||||
const y = (top + bottom) / 2;
|
||||
// center of `selected-rect` in viewport
|
||||
this._origin = { x, y };
|
||||
}
|
||||
|
||||
this._dragging = true;
|
||||
this._onDragStart();
|
||||
|
||||
const _onPointerMove = ({ x, y, shiftKey }: PointerEvent) => {
|
||||
if (this._resizeMode === 'none') return;
|
||||
|
||||
this._shiftKey = shiftKey;
|
||||
this._dragPos.end = { x, y };
|
||||
|
||||
const proportional = this._proportional || this._shiftKey;
|
||||
|
||||
if (this._rotation) {
|
||||
this._onRotate(proportional);
|
||||
return;
|
||||
}
|
||||
|
||||
this._onResize(proportional);
|
||||
};
|
||||
|
||||
const _onPointerUp = (_: PointerEvent) => {
|
||||
this._dragging = false;
|
||||
this._onDragEnd();
|
||||
|
||||
const { x, y, width, height } = this._currentRect;
|
||||
this._originalRect = new DOMRect(x, y, width, height);
|
||||
|
||||
this._locked = true;
|
||||
this._shiftKey = false;
|
||||
this._rotation = false;
|
||||
this._dragPos = {
|
||||
start: { x: 0, y: 0 },
|
||||
end: { x: 0, y: 0 },
|
||||
};
|
||||
|
||||
document.removeEventListener('pointermove', _onPointerMove);
|
||||
document.removeEventListener('pointerup', _onPointerUp);
|
||||
};
|
||||
|
||||
document.addEventListener('pointermove', _onPointerMove);
|
||||
document.addEventListener('pointerup', _onPointerUp);
|
||||
};
|
||||
|
||||
get bounds() {
|
||||
return this._bounds;
|
||||
}
|
||||
|
||||
get currentRect() {
|
||||
return this._currentRect;
|
||||
}
|
||||
|
||||
get dragDirection() {
|
||||
return this._dragDirection;
|
||||
}
|
||||
|
||||
get dragging() {
|
||||
return this._dragging;
|
||||
}
|
||||
|
||||
get originalRect() {
|
||||
return this._originalRect;
|
||||
}
|
||||
|
||||
get rotation() {
|
||||
return this._rotation;
|
||||
}
|
||||
|
||||
constructor(
|
||||
onDragStart: DragStartHandler,
|
||||
onResizeMove: ResizeMoveHandler,
|
||||
onRotateMove: RotateMoveHandler,
|
||||
onDragEnd: DragEndHandler
|
||||
) {
|
||||
this._onDragStart = onDragStart;
|
||||
this._onResizeMove = onResizeMove;
|
||||
this._onRotateMove = onRotateMove;
|
||||
this._onDragEnd = onDragEnd;
|
||||
}
|
||||
|
||||
private _onResize(proportion: boolean) {
|
||||
const {
|
||||
_aspectRatio,
|
||||
_dragDirection,
|
||||
_dragPos,
|
||||
_rotate,
|
||||
_resizeMode,
|
||||
_zoom,
|
||||
_originalRect,
|
||||
_currentRect,
|
||||
} = this;
|
||||
proportion ||= this._proportion;
|
||||
|
||||
const isAll = _resizeMode === 'all';
|
||||
const isCorner = _resizeMode === 'corner';
|
||||
const isEdgeAndCorner = _resizeMode === 'edgeAndCorner';
|
||||
|
||||
const {
|
||||
start: { x: startX, y: startY },
|
||||
end: { x: endX, y: endY },
|
||||
} = _dragPos;
|
||||
|
||||
const { left: minX, top: minY, right: maxX, bottom: maxY } = _originalRect;
|
||||
const original = {
|
||||
w: maxX - minX,
|
||||
h: maxY - minY,
|
||||
cx: (minX + maxX) / 2,
|
||||
cy: (minY + maxY) / 2,
|
||||
};
|
||||
const rect = { ...original };
|
||||
const scale = { x: 1, y: 1 };
|
||||
const flip = { x: 1, y: 1 };
|
||||
const direction = { x: 1, y: 1 };
|
||||
const fixedPoint = new DOMPoint(0, 0);
|
||||
const draggingPoint = new DOMPoint(0, 0);
|
||||
|
||||
const deltaX = (endX - startX) / _zoom;
|
||||
const deltaY = (endY - startY) / _zoom;
|
||||
|
||||
const m0 = new DOMMatrix()
|
||||
.translateSelf(original.cx, original.cy)
|
||||
.rotateSelf(_rotate)
|
||||
.translateSelf(-original.cx, -original.cy);
|
||||
|
||||
if (isCorner || isAll || isEdgeAndCorner) {
|
||||
switch (_dragDirection) {
|
||||
case HandleDirection.TopLeft: {
|
||||
direction.x = -1;
|
||||
direction.y = -1;
|
||||
fixedPoint.x = maxX;
|
||||
fixedPoint.y = maxY;
|
||||
draggingPoint.x = minX;
|
||||
draggingPoint.y = minY;
|
||||
break;
|
||||
}
|
||||
case HandleDirection.TopRight: {
|
||||
direction.x = 1;
|
||||
direction.y = -1;
|
||||
fixedPoint.x = minX;
|
||||
fixedPoint.y = maxY;
|
||||
draggingPoint.x = maxX;
|
||||
draggingPoint.y = minY;
|
||||
break;
|
||||
}
|
||||
case HandleDirection.BottomRight: {
|
||||
direction.x = 1;
|
||||
direction.y = 1;
|
||||
fixedPoint.x = minX;
|
||||
fixedPoint.y = minY;
|
||||
draggingPoint.x = maxX;
|
||||
draggingPoint.y = maxY;
|
||||
break;
|
||||
}
|
||||
case HandleDirection.BottomLeft: {
|
||||
direction.x = -1;
|
||||
direction.y = 1;
|
||||
fixedPoint.x = maxX;
|
||||
fixedPoint.y = minY;
|
||||
draggingPoint.x = minX;
|
||||
draggingPoint.y = maxY;
|
||||
break;
|
||||
}
|
||||
case HandleDirection.Left: {
|
||||
direction.x = -1;
|
||||
direction.y = 1;
|
||||
fixedPoint.x = maxX;
|
||||
fixedPoint.y = original.cy;
|
||||
draggingPoint.x = minX;
|
||||
draggingPoint.y = original.cy;
|
||||
break;
|
||||
}
|
||||
case HandleDirection.Right: {
|
||||
direction.x = 1;
|
||||
direction.y = 1;
|
||||
fixedPoint.x = minX;
|
||||
fixedPoint.y = original.cy;
|
||||
draggingPoint.x = maxX;
|
||||
draggingPoint.y = original.cy;
|
||||
break;
|
||||
}
|
||||
case HandleDirection.Top: {
|
||||
const cx = (minX + maxX) / 2;
|
||||
direction.x = 1;
|
||||
direction.y = -1;
|
||||
fixedPoint.x = cx;
|
||||
fixedPoint.y = maxY;
|
||||
draggingPoint.x = cx;
|
||||
draggingPoint.y = minY;
|
||||
break;
|
||||
}
|
||||
case HandleDirection.Bottom: {
|
||||
const cx = (minX + maxX) / 2;
|
||||
direction.x = 1;
|
||||
direction.y = 1;
|
||||
fixedPoint.x = cx;
|
||||
fixedPoint.y = minY;
|
||||
draggingPoint.x = cx;
|
||||
draggingPoint.y = maxY;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// force adjustment by aspect ratio
|
||||
proportion ||= this._bounds.size > 1;
|
||||
|
||||
const fp = fixedPoint.matrixTransform(m0);
|
||||
let dp = draggingPoint.matrixTransform(m0);
|
||||
|
||||
dp.x += deltaX;
|
||||
dp.y += deltaY;
|
||||
|
||||
if (
|
||||
_dragDirection === HandleDirection.Left ||
|
||||
_dragDirection === HandleDirection.Right ||
|
||||
_dragDirection === HandleDirection.Top ||
|
||||
_dragDirection === HandleDirection.Bottom
|
||||
) {
|
||||
const dpo = draggingPoint.matrixTransform(m0);
|
||||
const coorPoint: IVec = [0, 0];
|
||||
const [[x1, y1]] = rotatePoints([[dpo.x, dpo.y]], coorPoint, -_rotate);
|
||||
const [[x2, y2]] = rotatePoints([[dp.x, dp.y]], coorPoint, -_rotate);
|
||||
const point = { x: 0, y: 0 };
|
||||
if (
|
||||
_dragDirection === HandleDirection.Left ||
|
||||
_dragDirection === HandleDirection.Right
|
||||
) {
|
||||
point.x = x2;
|
||||
point.y = y1;
|
||||
} else {
|
||||
point.x = x1;
|
||||
point.y = y2;
|
||||
}
|
||||
|
||||
const [[x3, y3]] = rotatePoints(
|
||||
[[point.x, point.y]],
|
||||
coorPoint,
|
||||
_rotate
|
||||
);
|
||||
|
||||
dp.x = x3;
|
||||
dp.y = y3;
|
||||
}
|
||||
|
||||
const cx = (fp.x + dp.x) / 2;
|
||||
const cy = (fp.y + dp.y) / 2;
|
||||
|
||||
const m1 = new DOMMatrix()
|
||||
.translateSelf(cx, cy)
|
||||
.rotateSelf(-_rotate)
|
||||
.translateSelf(-cx, -cy);
|
||||
|
||||
const f = fp.matrixTransform(m1);
|
||||
const d = dp.matrixTransform(m1);
|
||||
|
||||
switch (_dragDirection) {
|
||||
case HandleDirection.TopLeft: {
|
||||
rect.w = f.x - d.x;
|
||||
rect.h = f.y - d.y;
|
||||
break;
|
||||
}
|
||||
case HandleDirection.TopRight: {
|
||||
rect.w = d.x - f.x;
|
||||
rect.h = f.y - d.y;
|
||||
break;
|
||||
}
|
||||
case HandleDirection.BottomRight: {
|
||||
rect.w = d.x - f.x;
|
||||
rect.h = d.y - f.y;
|
||||
break;
|
||||
}
|
||||
case HandleDirection.BottomLeft: {
|
||||
rect.w = f.x - d.x;
|
||||
rect.h = d.y - f.y;
|
||||
break;
|
||||
}
|
||||
case HandleDirection.Left: {
|
||||
rect.w = f.x - d.x;
|
||||
break;
|
||||
}
|
||||
case HandleDirection.Right: {
|
||||
rect.w = d.x - f.x;
|
||||
break;
|
||||
}
|
||||
case HandleDirection.Top: {
|
||||
rect.h = f.y - d.y;
|
||||
break;
|
||||
}
|
||||
case HandleDirection.Bottom: {
|
||||
rect.h = d.y - f.y;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
rect.cx = (d.x + f.x) / 2;
|
||||
rect.cy = (d.y + f.y) / 2;
|
||||
scale.x = rect.w / original.w;
|
||||
scale.y = rect.h / original.h;
|
||||
flip.x = scale.x < 0 ? -1 : 1;
|
||||
flip.y = scale.y < 0 ? -1 : 1;
|
||||
|
||||
const isDraggingCorner =
|
||||
_dragDirection === HandleDirection.TopLeft ||
|
||||
_dragDirection === HandleDirection.TopRight ||
|
||||
_dragDirection === HandleDirection.BottomRight ||
|
||||
_dragDirection === HandleDirection.BottomLeft;
|
||||
|
||||
// lock aspect ratio
|
||||
if (proportion && isDraggingCorner) {
|
||||
const newAspectRatio = Math.abs(rect.w / rect.h);
|
||||
if (_aspectRatio < newAspectRatio) {
|
||||
scale.y = Math.abs(scale.x) * flip.y;
|
||||
rect.h = scale.y * original.h;
|
||||
} else {
|
||||
scale.x = Math.abs(scale.y) * flip.x;
|
||||
rect.w = scale.x * original.w;
|
||||
}
|
||||
draggingPoint.x = fixedPoint.x + rect.w * direction.x;
|
||||
draggingPoint.y = fixedPoint.y + rect.h * direction.y;
|
||||
|
||||
dp = draggingPoint.matrixTransform(m0);
|
||||
|
||||
rect.cx = (fp.x + dp.x) / 2;
|
||||
rect.cy = (fp.y + dp.y) / 2;
|
||||
}
|
||||
} else {
|
||||
// handle notes
|
||||
switch (_dragDirection) {
|
||||
case HandleDirection.Left: {
|
||||
direction.x = -1;
|
||||
fixedPoint.x = maxX;
|
||||
draggingPoint.x = minX + deltaX;
|
||||
rect.w = fixedPoint.x - draggingPoint.x;
|
||||
break;
|
||||
}
|
||||
case HandleDirection.Right: {
|
||||
direction.x = 1;
|
||||
fixedPoint.x = minX;
|
||||
draggingPoint.x = maxX + deltaX;
|
||||
rect.w = draggingPoint.x - fixedPoint.x;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
scale.x = rect.w / original.w;
|
||||
flip.x = scale.x < 0 ? -1 : 1;
|
||||
|
||||
if (Math.abs(rect.w) < NOTE_MIN_WIDTH) {
|
||||
rect.w = NOTE_MIN_WIDTH * flip.x;
|
||||
scale.x = rect.w / original.w;
|
||||
draggingPoint.x = fixedPoint.x + rect.w * direction.x;
|
||||
}
|
||||
|
||||
rect.cx = (draggingPoint.x + fixedPoint.x) / 2;
|
||||
}
|
||||
|
||||
const width = Math.abs(rect.w);
|
||||
const height = Math.abs(rect.h);
|
||||
const x = rect.cx - width / 2;
|
||||
const y = rect.cy - height / 2;
|
||||
|
||||
_currentRect.x = x;
|
||||
_currentRect.y = y;
|
||||
_currentRect.width = width;
|
||||
_currentRect.height = height;
|
||||
|
||||
const newBounds = new Map<
|
||||
string,
|
||||
{
|
||||
bound: Bound;
|
||||
path?: PointLocation[];
|
||||
matrix?: DOMMatrix;
|
||||
}
|
||||
>();
|
||||
|
||||
let process: (value: SelectableProps, key: string) => void;
|
||||
|
||||
if (isCorner || isAll || isEdgeAndCorner) {
|
||||
if (this._bounds.size === 1) {
|
||||
process = (_, id) => {
|
||||
newBounds.set(id, {
|
||||
bound: new Bound(x, y, width, height),
|
||||
});
|
||||
};
|
||||
} else {
|
||||
const fp = fixedPoint.matrixTransform(m0);
|
||||
const m2 = new DOMMatrix()
|
||||
.translateSelf(fp.x, fp.y)
|
||||
.rotateSelf(_rotate)
|
||||
.translateSelf(-fp.x, -fp.y)
|
||||
.scaleSelf(scale.x, scale.y, 1, fp.x, fp.y, 0)
|
||||
.translateSelf(fp.x, fp.y)
|
||||
.rotateSelf(-_rotate)
|
||||
.translateSelf(-fp.x, -fp.y);
|
||||
|
||||
// TODO: on same rotate
|
||||
process = ({ bound: { x, y, w, h }, path }, id) => {
|
||||
const cx = x + w / 2;
|
||||
const cy = y + h / 2;
|
||||
const center = new DOMPoint(cx, cy).matrixTransform(m2);
|
||||
const newWidth = Math.abs(w * scale.x);
|
||||
const newHeight = Math.abs(h * scale.y);
|
||||
|
||||
newBounds.set(id, {
|
||||
bound: new Bound(
|
||||
center.x - newWidth / 2,
|
||||
center.y - newHeight / 2,
|
||||
newWidth,
|
||||
newHeight
|
||||
),
|
||||
matrix: m2,
|
||||
path,
|
||||
});
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// include notes, <---->
|
||||
const m2 = new DOMMatrix().scaleSelf(
|
||||
scale.x,
|
||||
scale.y,
|
||||
1,
|
||||
fixedPoint.x,
|
||||
fixedPoint.y,
|
||||
0
|
||||
);
|
||||
process = ({ bound: { x, y, w, h }, rotate = 0, path }, id) => {
|
||||
const cx = x + w / 2;
|
||||
const cy = y + h / 2;
|
||||
|
||||
const center = new DOMPoint(cx, cy).matrixTransform(m2);
|
||||
|
||||
let newWidth: number;
|
||||
let newHeight: number;
|
||||
|
||||
// TODO: determine if it is a note
|
||||
if (rotate) {
|
||||
const { width } = getQuadBoundWithRotation({ x, y, w, h, rotate });
|
||||
const hrw = width / 2;
|
||||
|
||||
center.y = cy;
|
||||
|
||||
if (_currentRect.width <= width) {
|
||||
newWidth = w * (_currentRect.width / width);
|
||||
newHeight = newWidth / (w / h);
|
||||
center.x = _currentRect.left + _currentRect.width / 2;
|
||||
} else {
|
||||
const p = (cx - hrw - _originalRect.left) / _originalRect.width;
|
||||
const lx = _currentRect.left + p * _currentRect.width + hrw;
|
||||
center.x = Math.max(
|
||||
_currentRect.left + hrw,
|
||||
Math.min(lx, _currentRect.left + _currentRect.width - hrw)
|
||||
);
|
||||
newWidth = w;
|
||||
newHeight = h;
|
||||
}
|
||||
} else {
|
||||
newWidth = Math.abs(w * scale.x);
|
||||
newHeight = Math.abs(h * scale.y);
|
||||
}
|
||||
|
||||
newBounds.set(id, {
|
||||
bound: new Bound(
|
||||
center.x - newWidth / 2,
|
||||
center.y - newHeight / 2,
|
||||
newWidth,
|
||||
newHeight
|
||||
),
|
||||
matrix: m2,
|
||||
path,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
this._bounds.forEach(process);
|
||||
this._onResizeMove(newBounds, this._dragDirection);
|
||||
}
|
||||
|
||||
private _onRotate(shiftKey = false) {
|
||||
const {
|
||||
_originalRect: { left: minX, top: minY, right: maxX, bottom: maxY },
|
||||
_dragPos: {
|
||||
start: { x: startX, y: startY },
|
||||
end: { x: endX, y: endY },
|
||||
},
|
||||
_origin: { x: centerX, y: centerY },
|
||||
_rotate,
|
||||
} = this;
|
||||
|
||||
const startRad = Math.atan2(startY - centerY, startX - centerX);
|
||||
const endRad = Math.atan2(endY - centerY, endX - centerX);
|
||||
let deltaRad = endRad - startRad;
|
||||
|
||||
// snap angle
|
||||
// 15deg * n = 0, 15, 30, 45, ... 360
|
||||
if (shiftKey) {
|
||||
const prevRad = (_rotate * Math.PI) / 180;
|
||||
let angle = prevRad + deltaRad;
|
||||
angle += SHIFT_LOCKING_ANGLE / 2;
|
||||
angle -= angle % SHIFT_LOCKING_ANGLE;
|
||||
deltaRad = angle - prevRad;
|
||||
}
|
||||
|
||||
const delta = (deltaRad * 180) / Math.PI;
|
||||
|
||||
let x = endX;
|
||||
let y = endY;
|
||||
if (shiftKey) {
|
||||
const point = new DOMPoint(startX, startY).matrixTransform(
|
||||
new DOMMatrix()
|
||||
.translateSelf(centerX, centerY)
|
||||
.rotateSelf(delta)
|
||||
.translateSelf(-centerX, -centerY)
|
||||
);
|
||||
x = point.x;
|
||||
y = point.y;
|
||||
}
|
||||
|
||||
this._onRotateMove(
|
||||
// center of element in suface
|
||||
{ x: (minX + maxX) / 2, y: (minY + maxY) / 2 },
|
||||
delta
|
||||
);
|
||||
|
||||
this._dragPos.start = { x, y };
|
||||
this._rotate += delta;
|
||||
}
|
||||
|
||||
onPressShiftKey(pressed: boolean) {
|
||||
if (!this._target) return;
|
||||
if (this._locked) return;
|
||||
|
||||
if (this._shiftKey === pressed) return;
|
||||
this._shiftKey = pressed;
|
||||
|
||||
const proportional = this._proportional || this._shiftKey;
|
||||
|
||||
if (this._rotation) {
|
||||
this._onRotate(proportional);
|
||||
return;
|
||||
}
|
||||
|
||||
this._onResize(proportional);
|
||||
}
|
||||
|
||||
updateBounds(bounds: Map<string, SelectableProps>) {
|
||||
this._bounds = bounds;
|
||||
}
|
||||
|
||||
updateRectPosition(delta: { x: number; y: number }) {
|
||||
this._currentRect.x += delta.x;
|
||||
this._currentRect.y += delta.y;
|
||||
this._originalRect.x = this._currentRect.x;
|
||||
this._originalRect.y = this._currentRect.y;
|
||||
|
||||
return this._originalRect;
|
||||
}
|
||||
|
||||
updateState(
|
||||
resizeMode: ResizeMode,
|
||||
rotate: number,
|
||||
zoom: number,
|
||||
position?: { x: number; y: number },
|
||||
originalRect?: DOMRect,
|
||||
proportion = false
|
||||
) {
|
||||
this._resizeMode = resizeMode;
|
||||
this._rotate = rotate;
|
||||
this._zoom = zoom;
|
||||
this._proportion = proportion;
|
||||
|
||||
if (position) {
|
||||
this._currentRect.x = position.x;
|
||||
this._currentRect.y = position.y;
|
||||
this._originalRect.x = this._currentRect.x;
|
||||
this._originalRect.y = this._currentRect.y;
|
||||
}
|
||||
|
||||
if (originalRect) {
|
||||
this._originalRect = originalRect;
|
||||
this._aspectRatio = originalRect.width / originalRect.height;
|
||||
this._currentRect = DOMRect.fromRect(originalRect);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import {
|
||||
type EdgelessToolbarSlots,
|
||||
edgelessToolbarSlotsContext,
|
||||
} from '@blocksuite/affine-widget-edgeless-toolbar';
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import { ArrowRightSmallIcon } from '@blocksuite/icons/lit';
|
||||
import { consume } from '@lit/context';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
export class EdgelessSlideMenu extends WithDisposable(LitElement) {
|
||||
static override styles = css`
|
||||
:host {
|
||||
max-width: 100%;
|
||||
}
|
||||
::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.slide-menu-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
.menu-container {
|
||||
background: var(--affine-background-overlay-panel-color);
|
||||
border-radius: 8px 8px 0 0;
|
||||
border: 1px solid var(--affine-border-color);
|
||||
border-bottom: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
position: relative;
|
||||
height: calc(var(--menu-height) + 1px);
|
||||
box-sizing: border-box;
|
||||
padding-left: 10px;
|
||||
scroll-snap-type: x mandatory;
|
||||
}
|
||||
.menu-container-scrollable {
|
||||
overflow-x: auto;
|
||||
overscroll-behavior: none;
|
||||
scrollbar-width: none;
|
||||
height: 100%;
|
||||
padding-right: 10px;
|
||||
}
|
||||
.slide-menu-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
transition: left 0.5s ease-in-out;
|
||||
width: fit-content;
|
||||
}
|
||||
.next-slide-button,
|
||||
.previous-slide-button {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--affine-border-color);
|
||||
background: var(--affine-background-overlay-panel-color);
|
||||
box-shadow: var(--affine-shadow-2);
|
||||
color: var(--affine-icon-color);
|
||||
transition:
|
||||
transform 0.3s ease-in-out,
|
||||
opacity 0.5s ease-in-out;
|
||||
z-index: 12;
|
||||
}
|
||||
.next-slide-button {
|
||||
opacity: 0;
|
||||
display: flex;
|
||||
top: 50%;
|
||||
right: 0;
|
||||
transform: translate(50%, -50%) scale(0.5);
|
||||
}
|
||||
.next-slide-button:hover {
|
||||
cursor: pointer;
|
||||
transform: translate(50%, -50%) scale(1);
|
||||
}
|
||||
.previous-slide-button {
|
||||
opacity: 0;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
transform: translate(-50%, -50%) scale(0.5);
|
||||
}
|
||||
.previous-slide-button:hover {
|
||||
cursor: pointer;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
.previous-slide-button svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
`;
|
||||
|
||||
private _handleSlideButtonClick(direction: 'left' | 'right') {
|
||||
const totalWidth = this._slideMenuContent.clientWidth;
|
||||
const currentScrollLeft = this._menuContainer.scrollLeft;
|
||||
const menuWidth = this._menuContainer.clientWidth;
|
||||
const newLeft =
|
||||
currentScrollLeft + (direction === 'left' ? -menuWidth : menuWidth);
|
||||
this._menuContainer.scrollTo({
|
||||
left: Math.max(0, Math.min(newLeft, totalWidth)),
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
|
||||
private _handleWheel(event: WheelEvent) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
private _toggleSlideButton() {
|
||||
const scrollLeft = this._menuContainer.scrollLeft;
|
||||
const menuWidth = this._menuContainer.clientWidth;
|
||||
|
||||
const leftMin = 0;
|
||||
const leftMax = this._slideMenuContent.clientWidth - menuWidth;
|
||||
this.showPrevious = scrollLeft > leftMin;
|
||||
this.showNext = scrollLeft < leftMax;
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
setTimeout(this._toggleSlideButton.bind(this), 0);
|
||||
this._disposables.addFromEvent(this._menuContainer, 'scrollend', () => {
|
||||
this._toggleSlideButton();
|
||||
});
|
||||
this._disposables.add(
|
||||
this.toolbarSlots.resize.subscribe(() => this._toggleSlideButton())
|
||||
);
|
||||
}
|
||||
|
||||
override render() {
|
||||
const iconSize = { width: '32px', height: '32px' };
|
||||
|
||||
return html`
|
||||
<div class="slide-menu-wrapper">
|
||||
<div
|
||||
class="previous-slide-button"
|
||||
@click=${() => this._handleSlideButtonClick('left')}
|
||||
style=${styleMap({ opacity: this.showPrevious ? '1' : '0' })}
|
||||
>
|
||||
${ArrowRightSmallIcon(iconSize)}
|
||||
</div>
|
||||
<div
|
||||
class="menu-container"
|
||||
style=${styleMap({ '--menu-height': this.height })}
|
||||
>
|
||||
<slot name="prefix"></slot>
|
||||
<div class="menu-container-scrollable">
|
||||
<div class="slide-menu-content" @wheel=${this._handleWheel}>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style=${styleMap({ opacity: this.showNext ? '1' : '0' })}
|
||||
class="next-slide-button"
|
||||
@click=${() => this._handleSlideButtonClick('right')}
|
||||
>
|
||||
${ArrowRightSmallIcon(iconSize)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@query('.menu-container-scrollable')
|
||||
private accessor _menuContainer!: HTMLDivElement;
|
||||
|
||||
@query('.slide-menu-content')
|
||||
private accessor _slideMenuContent!: HTMLDivElement;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor height = '40px';
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor showNext = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor showPrevious = false;
|
||||
|
||||
@consume({ context: edgelessToolbarSlotsContext })
|
||||
accessor toolbarSlots!: EdgelessToolbarSlots;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ArrowUpSmallIcon } from '@blocksuite/icons/lit';
|
||||
import { ShadowlessElement } from '@blocksuite/std';
|
||||
import { css, html } from 'lit';
|
||||
|
||||
export class ToolbarArrowUpIcon extends ShadowlessElement {
|
||||
static override styles = css`
|
||||
.arrow-up-icon {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: -2px;
|
||||
}
|
||||
`;
|
||||
|
||||
override render() {
|
||||
return html`<span class="arrow-up-icon"> ${ArrowUpSmallIcon()} </span>`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { QuickToolMixin } from '@blocksuite/affine-widget-edgeless-toolbar';
|
||||
import { HandIcon, SelectIcon } from '@blocksuite/icons/lit';
|
||||
import type { GfxToolsFullOptionValue } from '@blocksuite/std/gfx';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
import { query } from 'lit/decorators.js';
|
||||
|
||||
export class EdgelessDefaultToolButton extends QuickToolMixin(LitElement) {
|
||||
static override styles = css`
|
||||
.current-icon {
|
||||
transition: 100ms;
|
||||
}
|
||||
.current-icon > svg {
|
||||
display: block;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
`;
|
||||
|
||||
override type: GfxToolsFullOptionValue['type'][] = ['default', 'pan'];
|
||||
|
||||
private _changeTool() {
|
||||
if (this.toolbar.activePopper) {
|
||||
// click manually always closes the popper
|
||||
this.toolbar.activePopper.dispose();
|
||||
}
|
||||
const type = this.edgelessTool?.type;
|
||||
if (type !== 'default' && type !== 'pan') {
|
||||
if (localStorage.defaultTool === 'default') {
|
||||
this.setEdgelessTool('default');
|
||||
} else if (localStorage.defaultTool === 'pan') {
|
||||
this.setEdgelessTool('pan', { panning: false });
|
||||
}
|
||||
return;
|
||||
}
|
||||
this._fadeOut();
|
||||
// wait for animation to finish
|
||||
setTimeout(() => {
|
||||
if (type === 'default') {
|
||||
this.setEdgelessTool('pan', { panning: false });
|
||||
} else if (type === 'pan') {
|
||||
this.setEdgelessTool('default');
|
||||
}
|
||||
this._fadeIn();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
private _fadeIn() {
|
||||
this.currentIcon.style.opacity = '1';
|
||||
this.currentIcon.style.transform = `translateY(0px)`;
|
||||
}
|
||||
|
||||
private _fadeOut() {
|
||||
this.currentIcon.style.opacity = '0';
|
||||
this.currentIcon.style.transform = `translateY(-5px)`;
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
if (!localStorage.defaultTool) {
|
||||
localStorage.defaultTool = 'default';
|
||||
}
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
const tool = this.gfx.tool.currentToolName$.value;
|
||||
if (tool === 'default' || tool === 'pan') {
|
||||
localStorage.defaultTool = tool;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override render() {
|
||||
const type = this.edgelessTool?.type;
|
||||
const { active } = this;
|
||||
const tipInfo =
|
||||
type === 'pan'
|
||||
? { tip: 'Hand', shortcut: 'H' }
|
||||
: { tip: 'Select', shortcut: 'V' };
|
||||
return html`
|
||||
<edgeless-tool-icon-button
|
||||
class="edgeless-default-button ${type}"
|
||||
.tooltip=${html`<affine-tooltip-content-with-shortcut
|
||||
data-tip="${tipInfo.tip}"
|
||||
data-shortcut="${tipInfo.shortcut}"
|
||||
></affine-tooltip-content-with-shortcut>`}
|
||||
.tooltipOffset=${17}
|
||||
.active=${active}
|
||||
.iconContainerPadding=${6}
|
||||
.iconSize=${'24px'}
|
||||
@click=${this._changeTool}
|
||||
>
|
||||
<div class="current-icon">
|
||||
${localStorage.defaultTool === 'default' ? SelectIcon() : HandIcon()}
|
||||
</div>
|
||||
<toolbar-arrow-up-icon></toolbar-arrow-up-icon>
|
||||
</edgeless-tool-icon-button>
|
||||
`;
|
||||
}
|
||||
|
||||
@query('.current-icon')
|
||||
accessor currentIcon!: HTMLInputElement;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { insertLinkByQuickSearchCommand } from '@blocksuite/affine-block-bookmark';
|
||||
import { menu } from '@blocksuite/affine-components/context-menu';
|
||||
import { LinkIcon } from '@blocksuite/affine-components/icons';
|
||||
import { TelemetryProvider } from '@blocksuite/affine-shared/services';
|
||||
import type { DenseMenuBuilder } from '@blocksuite/affine-widget-edgeless-toolbar';
|
||||
|
||||
export const buildLinkDenseMenu: DenseMenuBuilder = edgeless =>
|
||||
menu.action({
|
||||
name: 'Link',
|
||||
prefix: LinkIcon,
|
||||
select: () => {
|
||||
const [_, { insertedLinkType }] = edgeless.std.command.exec(
|
||||
insertLinkByQuickSearchCommand
|
||||
);
|
||||
|
||||
insertedLinkType
|
||||
?.then(type => {
|
||||
const flavour = type?.flavour;
|
||||
if (!flavour) return;
|
||||
|
||||
edgeless.std
|
||||
.getOptional(TelemetryProvider)
|
||||
?.track('CanvasElementAdded', {
|
||||
control: 'toolbar:general',
|
||||
page: 'whiteboard editor',
|
||||
module: 'toolbar',
|
||||
type: flavour.split(':')[1],
|
||||
});
|
||||
})
|
||||
.catch(console.error);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { insertLinkByQuickSearchCommand } from '@blocksuite/affine-block-bookmark';
|
||||
import { LinkIcon } from '@blocksuite/affine-components/icons';
|
||||
import { TelemetryProvider } from '@blocksuite/affine-shared/services';
|
||||
import { QuickToolMixin } from '@blocksuite/affine-widget-edgeless-toolbar';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
|
||||
export class EdgelessLinkToolButton extends QuickToolMixin(LitElement) {
|
||||
static override styles = css`
|
||||
.link-icon,
|
||||
.link-icon > svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
`;
|
||||
|
||||
override type = 'default' as const;
|
||||
|
||||
private _onClick() {
|
||||
const [_, { insertedLinkType }] = this.edgeless.std.command.exec(
|
||||
insertLinkByQuickSearchCommand
|
||||
);
|
||||
insertedLinkType
|
||||
?.then(type => {
|
||||
const flavour = type?.flavour;
|
||||
if (!flavour) return;
|
||||
|
||||
this.edgeless.std
|
||||
.getOptional(TelemetryProvider)
|
||||
?.track('CanvasElementAdded', {
|
||||
control: 'toolbar:general',
|
||||
page: 'whiteboard editor',
|
||||
module: 'toolbar',
|
||||
segment: 'toolbar',
|
||||
type: flavour.split(':')[1],
|
||||
});
|
||||
|
||||
this.edgeless.std
|
||||
.getOptional(TelemetryProvider)
|
||||
?.track('LinkedDocCreated', {
|
||||
control: 'links',
|
||||
page: 'whiteboard editor',
|
||||
module: 'edgeless toolbar',
|
||||
segment: 'whiteboard',
|
||||
type: flavour.split(':')[1],
|
||||
other: 'existing doc',
|
||||
});
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`<edgeless-tool-icon-button
|
||||
.iconContainerPadding="${6}"
|
||||
.tooltip="${html`<affine-tooltip-content-with-shortcut
|
||||
data-tip="${'Link'}"
|
||||
data-shortcut="${'@'}"
|
||||
></affine-tooltip-content-with-shortcut>`}"
|
||||
.tooltipOffset=${17}
|
||||
class="edgeless-link-tool-button"
|
||||
@click=${this._onClick}
|
||||
>
|
||||
<span class="link-icon">${LinkIcon}</span>
|
||||
</edgeless-tool-icon-button>`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { frameQuickTool } from '@blocksuite/affine-block-frame';
|
||||
import { penSeniorTool } from '@blocksuite/affine-gfx-brush';
|
||||
import { connectorQuickTool } from '@blocksuite/affine-gfx-connector';
|
||||
import { mindMapSeniorTool } from '@blocksuite/affine-gfx-mindmap';
|
||||
import { noteSeniorTool } from '@blocksuite/affine-gfx-note';
|
||||
import { shapeSeniorTool } from '@blocksuite/affine-gfx-shape';
|
||||
import { templateSeniorTool } from '@blocksuite/affine-gfx-template';
|
||||
import { QuickToolExtension } from '@blocksuite/affine-widget-edgeless-toolbar';
|
||||
import { html } from 'lit';
|
||||
|
||||
import { buildLinkDenseMenu } from './link/link-dense-menu.js';
|
||||
|
||||
const defaultQuickTool = QuickToolExtension('default', ({ block }) => {
|
||||
return {
|
||||
type: 'default',
|
||||
content: html`<edgeless-default-tool-button
|
||||
.edgeless=${block}
|
||||
></edgeless-default-tool-button>`,
|
||||
};
|
||||
});
|
||||
|
||||
const linkQuickTool = QuickToolExtension('link', ({ block, gfx }) => {
|
||||
return {
|
||||
content: html`<edgeless-link-tool-button
|
||||
.edgeless=${block}
|
||||
></edgeless-link-tool-button>`,
|
||||
menu: buildLinkDenseMenu(block, gfx),
|
||||
};
|
||||
});
|
||||
|
||||
export const quickTools = [
|
||||
defaultQuickTool,
|
||||
frameQuickTool,
|
||||
connectorQuickTool,
|
||||
linkQuickTool,
|
||||
];
|
||||
|
||||
export const seniorTools = [
|
||||
noteSeniorTool,
|
||||
penSeniorTool,
|
||||
shapeSeniorTool,
|
||||
mindMapSeniorTool,
|
||||
templateSeniorTool,
|
||||
];
|
||||
143
blocksuite/affine/blocks/root/src/edgeless/components/utils.ts
Normal file
143
blocksuite/affine/blocks/root/src/edgeless/components/utils.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import type { IVec } from '@blocksuite/global/gfx';
|
||||
import { normalizeDegAngle, Vec } from '@blocksuite/global/gfx';
|
||||
import type { CursorType, StandardCursor } from '@blocksuite/std/gfx';
|
||||
|
||||
export function generateCursorUrl(
|
||||
angle = 0,
|
||||
fallback: StandardCursor = 'default'
|
||||
): CursorType {
|
||||
return `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 32 32'%3E%3Cg transform='rotate(${angle} 16 16)'%3E%3Cpath fill='white' d='M13.7,18.5h3.9l0-1.5c0-1.4-1.2-2.6-2.6-2.6h-1.5v3.9l-5.8-5.8l5.8-5.8v3.9h2.3c3.1,0,5.6,2.5,5.6,5.6v2.3h3.9l-5.8,5.8L13.7,18.5z'/%3E%3Cpath d='M20.4,19.4v-3.2c0-2.6-2.1-4.7-4.7-4.7h-3.2l0,0V9L9,12.6l3.6,3.6v-2.6l0,0H15c1.9,0,3.5,1.6,3.5,3.5v2.4l0,0h-2.6l3.6,3.6l3.6-3.6L20.4,19.4L20.4,19.4z'/%3E%3C/g%3E%3C/svg%3E") 16 16, ${fallback}`;
|
||||
}
|
||||
|
||||
export function getCommonRectStyle(
|
||||
rect: DOMRect,
|
||||
active = false,
|
||||
selected = false,
|
||||
rotate = 0
|
||||
) {
|
||||
return {
|
||||
'--affine-border-width': `${active ? 2 : 1}px`,
|
||||
width: `${rect.width}px`,
|
||||
height: `${rect.height}px`,
|
||||
transform: `translate(${rect.x}px, ${rect.y}px) rotate(${rotate}deg)`,
|
||||
backgroundColor: !active && selected ? 'var(--affine-hover-color)' : '',
|
||||
};
|
||||
}
|
||||
|
||||
const RESIZE_CURSORS: CursorType[] = [
|
||||
'ew-resize',
|
||||
'nwse-resize',
|
||||
'ns-resize',
|
||||
'nesw-resize',
|
||||
];
|
||||
export function rotateResizeCursor(angle: number): StandardCursor {
|
||||
const a = Math.round(angle / (Math.PI / 4));
|
||||
const cursor = RESIZE_CURSORS[a % RESIZE_CURSORS.length];
|
||||
return cursor as StandardCursor;
|
||||
}
|
||||
|
||||
export function calcAngle(target: HTMLElement, point: IVec, offset = 0) {
|
||||
const rect = target
|
||||
.closest('.affine-edgeless-selected-rect')
|
||||
?.getBoundingClientRect();
|
||||
|
||||
if (!rect) {
|
||||
console.error('rect not found when calc angle');
|
||||
return 0;
|
||||
}
|
||||
const { left, top, right, bottom } = rect;
|
||||
const center = Vec.med([left, top], [right, bottom]);
|
||||
return normalizeDegAngle(
|
||||
((Vec.angle(center, point) + offset) * 180) / Math.PI
|
||||
);
|
||||
}
|
||||
|
||||
export function calcAngleWithRotation(
|
||||
target: HTMLElement,
|
||||
point: IVec,
|
||||
rect: DOMRect,
|
||||
rotate: number
|
||||
) {
|
||||
const handle = target.parentElement;
|
||||
const ariaLabel = handle?.getAttribute('aria-label');
|
||||
const { left, top, right, bottom, width, height } = rect;
|
||||
const size = Math.min(width, height);
|
||||
const sx = size / width;
|
||||
const sy = size / height;
|
||||
const center = Vec.med([left, top], [right, bottom]);
|
||||
const draggingPoint = [0, 0];
|
||||
|
||||
switch (ariaLabel) {
|
||||
case 'top-left': {
|
||||
draggingPoint[0] = left;
|
||||
draggingPoint[1] = top;
|
||||
break;
|
||||
}
|
||||
case 'top-right': {
|
||||
draggingPoint[0] = right;
|
||||
draggingPoint[1] = top;
|
||||
break;
|
||||
}
|
||||
case 'bottom-right': {
|
||||
draggingPoint[0] = right;
|
||||
draggingPoint[1] = bottom;
|
||||
break;
|
||||
}
|
||||
case 'bottom-left': {
|
||||
draggingPoint[0] = left;
|
||||
draggingPoint[1] = bottom;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const dp = new DOMMatrix()
|
||||
.translateSelf(center[0], center[1])
|
||||
.rotateSelf(rotate)
|
||||
.translateSelf(-center[0], -center[1])
|
||||
.transformPoint(new DOMPoint(...draggingPoint));
|
||||
|
||||
const m = new DOMMatrix()
|
||||
.translateSelf(dp.x, dp.y)
|
||||
.rotateSelf(rotate)
|
||||
.translateSelf(-dp.x, -dp.y)
|
||||
.scaleSelf(sx, sy, 1, dp.x, dp.y, 0)
|
||||
.translateSelf(dp.x, dp.y)
|
||||
.rotateSelf(-rotate)
|
||||
.translateSelf(-dp.x, -dp.y);
|
||||
|
||||
const c = new DOMPoint(...center).matrixTransform(m);
|
||||
|
||||
return normalizeDegAngle((Vec.angle([c.x, c.y], point) * 180) / Math.PI);
|
||||
}
|
||||
|
||||
export function calcAngleEdgeWithRotation(target: HTMLElement, rotate: number) {
|
||||
let angleWithEdge = 0;
|
||||
const handle = target.parentElement;
|
||||
const ariaLabel = handle?.getAttribute('aria-label');
|
||||
switch (ariaLabel) {
|
||||
case 'top': {
|
||||
angleWithEdge = 270;
|
||||
break;
|
||||
}
|
||||
case 'bottom': {
|
||||
angleWithEdge = 90;
|
||||
break;
|
||||
}
|
||||
case 'left': {
|
||||
angleWithEdge = 180;
|
||||
break;
|
||||
}
|
||||
case 'right': {
|
||||
angleWithEdge = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return angleWithEdge + rotate;
|
||||
}
|
||||
|
||||
export function getResizeLabel(target: HTMLElement) {
|
||||
const handle = target.parentElement;
|
||||
const ariaLabel = handle?.getAttribute('aria-label');
|
||||
return ariaLabel;
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import {
|
||||
autoArrangeElementsCommand,
|
||||
autoResizeElementsCommand,
|
||||
EdgelessCRUDIdentifier,
|
||||
updateXYWH,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import { EditorChevronDown } from '@blocksuite/affine-components/toolbar';
|
||||
import type { ToolbarContext } from '@blocksuite/affine-shared/services';
|
||||
import type {
|
||||
Menu,
|
||||
MenuItem,
|
||||
} from '@blocksuite/affine-widget-edgeless-toolbar';
|
||||
import { renderMenuItems } from '@blocksuite/affine-widget-edgeless-toolbar';
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
import {
|
||||
AlignBottomIcon,
|
||||
AlignHorizontalCenterIcon,
|
||||
AlignLeftIcon,
|
||||
AlignRightIcon,
|
||||
AlignTopIcon,
|
||||
AlignVerticalCenterIcon,
|
||||
AutoTidyUpIcon,
|
||||
DistributeHorizontalIcon,
|
||||
DistributeVerticalIcon,
|
||||
ResizeTidyUpIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import type { GfxModel } from '@blocksuite/std/gfx';
|
||||
import { html } from 'lit';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
enum Alignment {
|
||||
None,
|
||||
AutoArrange,
|
||||
AutoResize,
|
||||
Bottom,
|
||||
DistributeHorizontally,
|
||||
DistributeVertically,
|
||||
Horizontally,
|
||||
Left,
|
||||
Right,
|
||||
Top,
|
||||
Vertically,
|
||||
}
|
||||
|
||||
type AlignmentMap = Record<
|
||||
Alignment,
|
||||
(ctx: ToolbarContext, elements: GfxModel[]) => void
|
||||
>;
|
||||
|
||||
const HORIZONTAL_ALIGNMENT = [
|
||||
{
|
||||
key: 'Align left',
|
||||
value: Alignment.Left,
|
||||
icon: AlignLeftIcon(),
|
||||
},
|
||||
{
|
||||
key: 'Align horizontally',
|
||||
value: Alignment.Horizontally,
|
||||
icon: AlignHorizontalCenterIcon(),
|
||||
},
|
||||
{
|
||||
key: 'Align right',
|
||||
value: Alignment.Right,
|
||||
icon: AlignRightIcon(),
|
||||
},
|
||||
{
|
||||
key: 'Distribute horizontally',
|
||||
value: Alignment.DistributeHorizontally,
|
||||
icon: DistributeHorizontalIcon(),
|
||||
},
|
||||
] as const satisfies MenuItem<Alignment>[];
|
||||
|
||||
const VERTICAL_ALIGNMENT = [
|
||||
{
|
||||
key: 'Align top',
|
||||
value: Alignment.Top,
|
||||
icon: AlignTopIcon(),
|
||||
},
|
||||
{
|
||||
key: 'Align vertically',
|
||||
value: Alignment.Vertically,
|
||||
icon: AlignVerticalCenterIcon(),
|
||||
},
|
||||
{
|
||||
key: 'Align bottom',
|
||||
value: Alignment.Bottom,
|
||||
icon: AlignBottomIcon(),
|
||||
},
|
||||
{
|
||||
key: 'Distribute vertically',
|
||||
value: Alignment.DistributeVertically,
|
||||
icon: DistributeVerticalIcon(),
|
||||
},
|
||||
] as const satisfies MenuItem<Alignment>[];
|
||||
|
||||
const AUTO_ALIGNMENT = [
|
||||
{
|
||||
key: 'Auto arrange',
|
||||
value: Alignment.AutoArrange,
|
||||
icon: AutoTidyUpIcon(),
|
||||
},
|
||||
{
|
||||
key: 'Resize & Align',
|
||||
value: Alignment.AutoResize,
|
||||
icon: ResizeTidyUpIcon(),
|
||||
},
|
||||
] as const satisfies MenuItem<Alignment>[];
|
||||
|
||||
const alignment = {
|
||||
// None: do nothing
|
||||
[Alignment.None]() {},
|
||||
|
||||
// Horizontal
|
||||
[Alignment.Left](ctx: ToolbarContext, elements: GfxModel[]) {
|
||||
const bounds = elements.map(a => a.elementBound);
|
||||
const left = Math.min(...bounds.map(b => b.minX));
|
||||
|
||||
for (const [index, element] of elements.entries()) {
|
||||
const elementBound = bounds[index];
|
||||
const bound = Bound.deserialize(element.xywh);
|
||||
const offset = bound.minX - elementBound.minX;
|
||||
bound.x = left + offset;
|
||||
|
||||
updateXYWHWith(ctx, element, bound);
|
||||
}
|
||||
},
|
||||
|
||||
[Alignment.Horizontally](ctx: ToolbarContext, elements: GfxModel[]) {
|
||||
const bounds = elements.map(a => a.elementBound);
|
||||
const left = Math.min(...bounds.map(b => b.minX));
|
||||
const right = Math.max(...bounds.map(b => b.maxX));
|
||||
const centerX = (left + right) / 2;
|
||||
|
||||
for (const element of elements) {
|
||||
const bound = Bound.deserialize(element.xywh);
|
||||
bound.x = centerX - bound.w / 2;
|
||||
|
||||
updateXYWHWith(ctx, element, bound);
|
||||
}
|
||||
},
|
||||
|
||||
[Alignment.Right](ctx: ToolbarContext, elements: GfxModel[]) {
|
||||
const bounds = elements.map(a => a.elementBound);
|
||||
const right = Math.max(...bounds.map(b => b.maxX));
|
||||
|
||||
for (const [i, element] of elements.entries()) {
|
||||
const elementBound = bounds[i];
|
||||
const bound = Bound.deserialize(element.xywh);
|
||||
const offset = bound.maxX - elementBound.maxX;
|
||||
bound.x = right - bound.w + offset;
|
||||
|
||||
updateXYWHWith(ctx, element, bound);
|
||||
}
|
||||
},
|
||||
|
||||
[Alignment.DistributeHorizontally](
|
||||
ctx: ToolbarContext,
|
||||
elements: GfxModel[]
|
||||
) {
|
||||
elements.sort((a, b) => a.elementBound.minX - b.elementBound.minX);
|
||||
const bounds = elements.map(a => a.elementBound);
|
||||
const left = bounds[0].minX;
|
||||
const right = bounds[bounds.length - 1].maxX;
|
||||
|
||||
const totalWidth = right - left;
|
||||
const totalGap =
|
||||
totalWidth - elements.reduce((prev, ele) => prev + ele.elementBound.w, 0);
|
||||
const gap = totalGap / (elements.length - 1);
|
||||
let next = bounds[0].maxX + gap;
|
||||
|
||||
for (let i = 1; i < elements.length - 1; i++) {
|
||||
const bound = Bound.deserialize(elements[i].xywh);
|
||||
bound.x = next + bounds[i].w / 2 - bound.w / 2;
|
||||
next += gap + bounds[i].w;
|
||||
|
||||
updateXYWHWith(ctx, elements[i], bound);
|
||||
}
|
||||
},
|
||||
|
||||
// Vertical
|
||||
[Alignment.Top](ctx: ToolbarContext, elements: GfxModel[]) {
|
||||
const bounds = elements.map(a => a.elementBound);
|
||||
const top = Math.min(...bounds.map(b => b.minY));
|
||||
|
||||
for (const [i, element] of elements.entries()) {
|
||||
const elementBound = bounds[i];
|
||||
const bound = Bound.deserialize(element.xywh);
|
||||
const offset = bound.minY - elementBound.minY;
|
||||
bound.y = top + offset;
|
||||
|
||||
updateXYWHWith(ctx, element, bound);
|
||||
}
|
||||
},
|
||||
|
||||
[Alignment.Vertically](ctx: ToolbarContext, elements: GfxModel[]) {
|
||||
const bounds = elements.map(a => a.elementBound);
|
||||
const top = Math.min(...bounds.map(b => b.minY));
|
||||
const bottom = Math.max(...bounds.map(b => b.maxY));
|
||||
const centerY = (top + bottom) / 2;
|
||||
|
||||
for (const element of elements) {
|
||||
const bound = Bound.deserialize(element.xywh);
|
||||
bound.y = centerY - bound.h / 2;
|
||||
|
||||
updateXYWHWith(ctx, element, bound);
|
||||
}
|
||||
},
|
||||
|
||||
[Alignment.Bottom](ctx: ToolbarContext, elements: GfxModel[]) {
|
||||
const bounds = elements.map(a => a.elementBound);
|
||||
const bottom = Math.max(...bounds.map(b => b.maxY));
|
||||
|
||||
for (const [i, element] of elements.entries()) {
|
||||
const elementBound = bounds[i];
|
||||
const bound = Bound.deserialize(element.xywh);
|
||||
const offset = bound.maxY - elementBound.maxY;
|
||||
bound.y = bottom - bound.h + offset;
|
||||
|
||||
updateXYWHWith(ctx, element, bound);
|
||||
}
|
||||
},
|
||||
|
||||
[Alignment.DistributeVertically](ctx: ToolbarContext, elements: GfxModel[]) {
|
||||
elements.sort((a, b) => a.elementBound.minY - b.elementBound.minY);
|
||||
const bounds = elements.map(a => a.elementBound);
|
||||
const top = bounds[0].minY;
|
||||
const bottom = bounds[bounds.length - 1].maxY;
|
||||
|
||||
const totalHeight = bottom - top;
|
||||
const totalGap =
|
||||
totalHeight -
|
||||
elements.reduce((prev, ele) => prev + ele.elementBound.h, 0);
|
||||
const gap = totalGap / (elements.length - 1);
|
||||
let next = bounds[0].maxY + gap;
|
||||
for (let i = 1; i < elements.length - 1; i++) {
|
||||
const bound = Bound.deserialize(elements[i].xywh);
|
||||
bound.y = next + bounds[i].h / 2 - bound.h / 2;
|
||||
next += gap + bounds[i].h;
|
||||
|
||||
updateXYWHWith(ctx, elements[i], bound);
|
||||
}
|
||||
},
|
||||
|
||||
// Auto
|
||||
[Alignment.AutoArrange](ctx: ToolbarContext) {
|
||||
ctx.command.exec(autoArrangeElementsCommand);
|
||||
},
|
||||
|
||||
[Alignment.AutoResize](ctx: ToolbarContext) {
|
||||
ctx.command.exec(autoResizeElementsCommand);
|
||||
},
|
||||
} as const satisfies AlignmentMap;
|
||||
|
||||
const updateXYWHWith = (ctx: ToolbarContext, model: GfxModel, bound: Bound) => {
|
||||
updateXYWH(
|
||||
model,
|
||||
bound,
|
||||
ctx.std.get(EdgelessCRUDIdentifier).updateElement,
|
||||
ctx.store.updateBlock
|
||||
);
|
||||
};
|
||||
|
||||
export function renderAlignmentMenu(
|
||||
ctx: ToolbarContext,
|
||||
models: GfxModel[],
|
||||
{ label, tooltip, icon }: Pick<Menu<Alignment>, 'label' | 'tooltip' | 'icon'>,
|
||||
onPick = (type: Alignment) => alignment[type](ctx, models)
|
||||
) {
|
||||
return html`
|
||||
<editor-menu-button
|
||||
aria-label="alignment-menu"
|
||||
.contentPadding="${'8px'}"
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label="${label}"
|
||||
.tooltip="${tooltip ?? label}"
|
||||
>
|
||||
${icon} ${EditorChevronDown}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<div data-orientation="vertical">
|
||||
<div style=${styleMap({ display: 'grid', gridGap: '8px', gridTemplateColumns: 'repeat(4, 1fr)' })}>
|
||||
${renderMenuItems(HORIZONTAL_ALIGNMENT, Alignment.None, onPick)}
|
||||
${renderMenuItems(VERTICAL_ALIGNMENT, Alignment.None, onPick)}
|
||||
</div>
|
||||
<editor-toolbar-separator data-orientation="horizontal"></editor-toolbar-separator>
|
||||
<div style=${styleMap({ display: 'grid', gridGap: '8px', gridTemplateColumns: 'repeat(4, 1fr)' })}>
|
||||
${renderMenuItems(AUTO_ALIGNMENT, Alignment.None, onPick)}
|
||||
</div>
|
||||
</editor-menu-button>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { edgelessTextToolbarExtension } from '@blocksuite/affine-block-edgeless-text';
|
||||
import { frameToolbarExtension } from '@blocksuite/affine-block-frame';
|
||||
import {
|
||||
brushToolbarExtension,
|
||||
highlighterToolbarExtension,
|
||||
} from '@blocksuite/affine-gfx-brush';
|
||||
import { connectorToolbarExtension } from '@blocksuite/affine-gfx-connector';
|
||||
import { groupToolbarExtension } from '@blocksuite/affine-gfx-group';
|
||||
import {
|
||||
mindmapToolbarExtension,
|
||||
shapeMindmapToolbarExtension,
|
||||
} from '@blocksuite/affine-gfx-mindmap';
|
||||
import { shapeToolbarExtension } from '@blocksuite/affine-gfx-shape';
|
||||
import { textToolbarExtension } from '@blocksuite/affine-gfx-text';
|
||||
import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services';
|
||||
import { BlockFlavourIdentifier } from '@blocksuite/std';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
|
||||
import { builtinLockedToolbarConfig, builtinMiscToolbarConfig } from './misc';
|
||||
|
||||
export const EdgelessElementToolbarExtension: ExtensionType[] = [
|
||||
frameToolbarExtension,
|
||||
|
||||
groupToolbarExtension,
|
||||
|
||||
brushToolbarExtension,
|
||||
|
||||
highlighterToolbarExtension,
|
||||
|
||||
connectorToolbarExtension,
|
||||
|
||||
shapeToolbarExtension,
|
||||
|
||||
shapeMindmapToolbarExtension,
|
||||
|
||||
mindmapToolbarExtension,
|
||||
|
||||
textToolbarExtension,
|
||||
|
||||
edgelessTextToolbarExtension,
|
||||
|
||||
ToolbarModuleExtension({
|
||||
id: BlockFlavourIdentifier('affine:surface:*'),
|
||||
config: builtinMiscToolbarConfig,
|
||||
}),
|
||||
|
||||
// Special Scenarios
|
||||
// Only display the `unlock` button when the selection includes a locked element.
|
||||
ToolbarModuleExtension({
|
||||
id: BlockFlavourIdentifier('affine:surface:locked'),
|
||||
config: builtinLockedToolbarConfig,
|
||||
}),
|
||||
];
|
||||
@@ -0,0 +1,368 @@
|
||||
import { EdgelessFrameManagerIdentifier } from '@blocksuite/affine-block-frame';
|
||||
import {
|
||||
EdgelessCRUDIdentifier,
|
||||
getSurfaceComponent,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
createGroupCommand,
|
||||
createGroupFromSelectedCommand,
|
||||
ungroupCommand,
|
||||
} from '@blocksuite/affine-gfx-group';
|
||||
import {
|
||||
ConnectorElementModel,
|
||||
DEFAULT_CONNECTOR_MODE,
|
||||
GroupElementModel,
|
||||
MindmapElementModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
ActionPlacement,
|
||||
type ElementLockEvent,
|
||||
type ToolbarAction,
|
||||
type ToolbarContext,
|
||||
type ToolbarModuleConfig,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
import {
|
||||
AlignLeftIcon,
|
||||
ConnectorCIcon,
|
||||
FrameIcon,
|
||||
GroupingIcon,
|
||||
LockIcon,
|
||||
ReleaseFromGroupIcon,
|
||||
UnlockIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import type { GfxModel } from '@blocksuite/std/gfx';
|
||||
import { html } from 'lit';
|
||||
|
||||
import { renderAlignmentMenu } from './alignment';
|
||||
import { moreActions } from './more';
|
||||
|
||||
export const builtinMiscToolbarConfig = {
|
||||
actions: [
|
||||
{
|
||||
placement: ActionPlacement.Start,
|
||||
id: 'a.release-from-group',
|
||||
tooltip: 'Release from group',
|
||||
icon: ReleaseFromGroupIcon(),
|
||||
when(ctx) {
|
||||
const models = ctx.getSurfaceModels();
|
||||
if (models.length !== 1) return false;
|
||||
return ctx.matchModel(models[0].group, GroupElementModel);
|
||||
},
|
||||
run(ctx) {
|
||||
const models = ctx.getSurfaceModels();
|
||||
if (models.length !== 1) return;
|
||||
|
||||
const firstModel = models[0];
|
||||
if (firstModel.isLocked()) return;
|
||||
if (!ctx.matchModel(firstModel.group, GroupElementModel)) return;
|
||||
|
||||
const group = firstModel.group;
|
||||
|
||||
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
|
||||
group.removeChild(firstModel);
|
||||
|
||||
firstModel.index = ctx.gfx.layer.generateIndex();
|
||||
|
||||
const parent = group.group;
|
||||
if (parent && parent instanceof GroupElementModel) {
|
||||
parent.addChild(firstModel);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
placement: ActionPlacement.Start,
|
||||
id: 'b.add-frame',
|
||||
label: 'Frame',
|
||||
showLabel: true,
|
||||
tooltip: 'Frame',
|
||||
icon: FrameIcon(),
|
||||
when(ctx) {
|
||||
const models = ctx.getSurfaceModels();
|
||||
if (models.length < 2) return false;
|
||||
if (
|
||||
models.some(model => ctx.matchModel(model.group, MindmapElementModel))
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
models.length ===
|
||||
models.filter(model => model instanceof ConnectorElementModel).length
|
||||
)
|
||||
return false;
|
||||
|
||||
return true;
|
||||
},
|
||||
run(ctx) {
|
||||
const models = ctx.getSurfaceModels();
|
||||
if (models.length < 2) return;
|
||||
|
||||
const surface = getSurfaceComponent(ctx.std);
|
||||
if (!surface) return;
|
||||
|
||||
const frameManager = ctx.std.get(EdgelessFrameManagerIdentifier);
|
||||
|
||||
const frame = frameManager.createFrameOnSelected();
|
||||
if (!frame) return;
|
||||
|
||||
// TODO(@fundon): should be a command
|
||||
surface.fitToViewport(Bound.deserialize(frame.xywh));
|
||||
|
||||
ctx.track('CanvasElementAdded', {
|
||||
control: 'context-menu',
|
||||
type: 'frame',
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
placement: ActionPlacement.Start,
|
||||
id: 'c.add-group',
|
||||
label: 'Group',
|
||||
showLabel: true,
|
||||
tooltip: 'Group',
|
||||
icon: GroupingIcon(),
|
||||
when(ctx) {
|
||||
const models = ctx.getSurfaceModels();
|
||||
if (models.length < 2) return false;
|
||||
if (ctx.matchModel(models[0], GroupElementModel)) return false;
|
||||
if (
|
||||
models.some(model => ctx.matchModel(model.group, MindmapElementModel))
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
models.length ===
|
||||
models.filter(model => ctx.matchModel(model, ConnectorElementModel))
|
||||
.length
|
||||
)
|
||||
return false;
|
||||
|
||||
return true;
|
||||
},
|
||||
run(ctx) {
|
||||
const models = ctx.getSurfaceModels();
|
||||
if (models.length < 2) return;
|
||||
|
||||
// TODO(@fundon): should be a command
|
||||
ctx.command.exec(createGroupFromSelectedCommand);
|
||||
},
|
||||
},
|
||||
{
|
||||
placement: ActionPlacement.Start,
|
||||
id: 'd.alignment',
|
||||
when(ctx) {
|
||||
const models = ctx.getSurfaceModels();
|
||||
if (models.length < 2) return false;
|
||||
if (models.some(model => model.group instanceof MindmapElementModel))
|
||||
return false;
|
||||
if (
|
||||
models.length ===
|
||||
models.filter(model => model instanceof ConnectorElementModel).length
|
||||
)
|
||||
return false;
|
||||
|
||||
return true;
|
||||
},
|
||||
content(ctx) {
|
||||
const models = ctx.getSurfaceModels();
|
||||
if (models.length < 2) return null;
|
||||
|
||||
return renderAlignmentMenu(ctx, models, {
|
||||
icon: AlignLeftIcon(),
|
||||
label: 'Align objects',
|
||||
tooltip: 'Align objects',
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
placement: ActionPlacement.End,
|
||||
id: 'a.draw-connector',
|
||||
label: 'Draw connector',
|
||||
tooltip: 'Draw connector',
|
||||
icon: ConnectorCIcon(),
|
||||
when(ctx) {
|
||||
const models = ctx.getSurfaceModels();
|
||||
if (models.length !== 1) return false;
|
||||
return !ctx.matchModel(models[0], ConnectorElementModel);
|
||||
},
|
||||
content(ctx) {
|
||||
const models = ctx.getSurfaceModels();
|
||||
if (!models.length) return null;
|
||||
|
||||
const { label, icon, tooltip } = this;
|
||||
|
||||
const quickConnect = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const { x, y } = e;
|
||||
const point = ctx.gfx.viewport.toViewCoordFromClientCoord([x, y]);
|
||||
|
||||
ctx.store.captureSync();
|
||||
ctx.gfx.tool.setTool('connector', { mode: DEFAULT_CONNECTOR_MODE });
|
||||
|
||||
const ctc = ctx.gfx.tool.get('connector');
|
||||
ctc.quickConnect(point, models[0]);
|
||||
};
|
||||
|
||||
return html`
|
||||
<editor-icon-button
|
||||
data-testid="${'draw-connector'}"
|
||||
aria-label=${label}
|
||||
.tooltip=${tooltip}
|
||||
@click=${quickConnect}
|
||||
>
|
||||
${icon}
|
||||
</editor-icon-button>
|
||||
`;
|
||||
},
|
||||
} satisfies ToolbarAction,
|
||||
{
|
||||
placement: ActionPlacement.End,
|
||||
id: 'b.lock',
|
||||
tooltip: 'Lock',
|
||||
icon: LockIcon(),
|
||||
run(ctx) {
|
||||
const models = ctx.getSurfaceModels();
|
||||
if (!models.length) return;
|
||||
|
||||
// get most top selected elements(*) from tree, like in a tree below
|
||||
// G0
|
||||
// / \
|
||||
// E1* G1
|
||||
// / \
|
||||
// E2* E3*
|
||||
//
|
||||
// (*) selected elements, [E1, E2, E3]
|
||||
// return [E1]
|
||||
|
||||
const elements = Array.from(
|
||||
new Set(
|
||||
models.map(model =>
|
||||
ctx.matchModel(model.group, MindmapElementModel)
|
||||
? model.group
|
||||
: model
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const levels = elements.map(element => element.groups.length);
|
||||
const topElement = elements[levels.indexOf(Math.min(...levels))];
|
||||
const otherElements = elements.filter(
|
||||
element => element !== topElement
|
||||
);
|
||||
|
||||
ctx.store.captureSync();
|
||||
|
||||
// release other elements from their groups and group with top element
|
||||
otherElements.forEach(element => {
|
||||
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
|
||||
element.group?.removeChild(element);
|
||||
topElement.group?.addChild(element);
|
||||
});
|
||||
|
||||
if (otherElements.length === 0) {
|
||||
topElement.lock();
|
||||
|
||||
ctx.gfx.selection.set({
|
||||
editing: false,
|
||||
elements: [topElement.id],
|
||||
});
|
||||
|
||||
track(ctx, topElement, 'lock');
|
||||
return;
|
||||
}
|
||||
|
||||
const [_, { groupId }] = ctx.command.exec(createGroupCommand, {
|
||||
elements: [topElement, ...otherElements],
|
||||
});
|
||||
|
||||
if (groupId) {
|
||||
const element = ctx.std
|
||||
.get(EdgelessCRUDIdentifier)
|
||||
.getElementById(groupId);
|
||||
|
||||
if (element) {
|
||||
element.lock();
|
||||
ctx.gfx.selection.set({
|
||||
editing: false,
|
||||
elements: [groupId],
|
||||
});
|
||||
|
||||
track(ctx, element, 'group-lock');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
for (const element of elements) {
|
||||
element.lock();
|
||||
|
||||
track(ctx, element, 'lock');
|
||||
}
|
||||
|
||||
ctx.gfx.selection.set({
|
||||
editing: false,
|
||||
elements: elements.map(e => e.id),
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
// More actions
|
||||
...moreActions.map(action => ({
|
||||
...action,
|
||||
placement: ActionPlacement.More,
|
||||
})),
|
||||
],
|
||||
when(ctx) {
|
||||
const models = ctx.getSurfaceModels();
|
||||
return models.length > 0 && !models.some(model => model.isLocked());
|
||||
},
|
||||
} as const satisfies ToolbarModuleConfig;
|
||||
|
||||
export const builtinLockedToolbarConfig = {
|
||||
actions: [
|
||||
{
|
||||
placement: ActionPlacement.End,
|
||||
id: 'b.unlock',
|
||||
label: 'Click to unlock',
|
||||
showLabel: true,
|
||||
icon: UnlockIcon(),
|
||||
run(ctx) {
|
||||
const models = ctx.getSurfaceModels();
|
||||
if (!models.length) return;
|
||||
|
||||
const elements = new Set(
|
||||
models.map(model =>
|
||||
ctx.matchModel(model.group, MindmapElementModel)
|
||||
? model.group
|
||||
: model
|
||||
)
|
||||
);
|
||||
|
||||
ctx.store.captureSync();
|
||||
|
||||
for (const element of elements) {
|
||||
if (element instanceof GroupElementModel) {
|
||||
ctx.command.exec(ungroupCommand, { group: element });
|
||||
} else {
|
||||
element.lockedBySelf = false;
|
||||
}
|
||||
|
||||
track(ctx, element, 'unlock');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
when: ctx => ctx.getSurfaceModels().some(model => model.isLocked()),
|
||||
} as const satisfies ToolbarModuleConfig;
|
||||
|
||||
function track(
|
||||
ctx: ToolbarContext,
|
||||
element: GfxModel,
|
||||
control: ElementLockEvent['control']
|
||||
) {
|
||||
ctx.track('EdgelessElementLocked', {
|
||||
control,
|
||||
type:
|
||||
'type' in element
|
||||
? element.type
|
||||
: (element.flavour.split(':')[1] ?? element.flavour),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
import { AttachmentBlockComponent } from '@blocksuite/affine-block-attachment';
|
||||
import { BookmarkBlockComponent } from '@blocksuite/affine-block-bookmark';
|
||||
import {
|
||||
isExternalEmbedBlockComponent,
|
||||
notifyDocCreated,
|
||||
promptDocTitle,
|
||||
} from '@blocksuite/affine-block-embed';
|
||||
import { EdgelessFrameManagerIdentifier } from '@blocksuite/affine-block-frame';
|
||||
import { ImageBlockComponent } from '@blocksuite/affine-block-image';
|
||||
import {
|
||||
EdgelessCRUDIdentifier,
|
||||
getSurfaceComponent,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import { createGroupFromSelectedCommand } from '@blocksuite/affine-gfx-group';
|
||||
import {
|
||||
AttachmentBlockModel,
|
||||
BookmarkBlockModel,
|
||||
EmbedLinkedDocBlockSchema,
|
||||
EmbedLinkedDocModel,
|
||||
EmbedSyncedDocBlockSchema,
|
||||
EmbedSyncedDocModel,
|
||||
FrameBlockModel,
|
||||
ImageBlockModel,
|
||||
isExternalEmbedModel,
|
||||
NoteBlockModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import type {
|
||||
ToolbarActions,
|
||||
ToolbarContext,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { type ReorderingType } from '@blocksuite/affine-shared/utils';
|
||||
import { Bound, getCommonBoundWithRotation } from '@blocksuite/global/gfx';
|
||||
import {
|
||||
ArrowDownBigBottomIcon,
|
||||
ArrowDownBigIcon,
|
||||
ArrowUpBigIcon,
|
||||
ArrowUpBigTopIcon,
|
||||
CopyIcon,
|
||||
DeleteIcon,
|
||||
DuplicateIcon,
|
||||
FrameIcon,
|
||||
GroupIcon,
|
||||
LinkedPageIcon,
|
||||
ResetIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import type { BlockComponent } from '@blocksuite/std';
|
||||
import { GfxBlockElementModel, type GfxModel } from '@blocksuite/std/gfx';
|
||||
|
||||
import { EdgelessClipboardController } from '../../clipboard/clipboard';
|
||||
import { duplicate } from '../../utils/clipboard-utils';
|
||||
import { getSortedCloneElements } from '../../utils/clone-utils';
|
||||
import { moveConnectors } from '../../utils/connector';
|
||||
import { deleteElements } from '../../utils/crud';
|
||||
import {
|
||||
createLinkedDocFromEdgelessElements,
|
||||
createLinkedDocFromNote,
|
||||
} from './render-linked-doc';
|
||||
import { getEdgelessWith } from './utils';
|
||||
|
||||
export const moreActions = [
|
||||
// Selection Group: frame & group
|
||||
{
|
||||
id: 'Z.a.selection',
|
||||
actions: [
|
||||
{
|
||||
id: 'a.create-frame',
|
||||
label: 'Frame section',
|
||||
icon: FrameIcon(),
|
||||
run(ctx) {
|
||||
const frame = ctx.std
|
||||
.get(EdgelessFrameManagerIdentifier)
|
||||
.createFrameOnSelected();
|
||||
if (!frame) return;
|
||||
|
||||
const surface = getSurfaceComponent(ctx.std);
|
||||
if (!surface) return;
|
||||
|
||||
surface.fitToViewport(Bound.deserialize(frame.xywh));
|
||||
|
||||
ctx.track('CanvasElementAdded', {
|
||||
control: 'context-menu',
|
||||
type: 'frame',
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'b.create-group',
|
||||
label: 'Group section',
|
||||
icon: GroupIcon(),
|
||||
when(ctx) {
|
||||
const models = ctx.getSurfaceModels();
|
||||
if (models.length === 0) return false;
|
||||
return !models.some(model => ctx.matchModel(model, FrameBlockModel));
|
||||
},
|
||||
run(ctx) {
|
||||
ctx.command.exec(createGroupFromSelectedCommand);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Reordering Group
|
||||
{
|
||||
id: 'Z.b.reordering',
|
||||
actions: [
|
||||
{
|
||||
id: 'a.bring-to-front',
|
||||
label: 'Bring to Front',
|
||||
icon: ArrowUpBigTopIcon(),
|
||||
run(ctx) {
|
||||
const models = ctx.getSurfaceModels();
|
||||
reorderElements(ctx, models, 'front');
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'b.bring-forward',
|
||||
label: 'Bring Forward',
|
||||
icon: ArrowUpBigIcon(),
|
||||
run(ctx) {
|
||||
const models = ctx.getSurfaceModels();
|
||||
reorderElements(ctx, models, 'forward');
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'c.send-backward',
|
||||
label: 'Send Backward',
|
||||
icon: ArrowDownBigIcon(),
|
||||
run(ctx) {
|
||||
const models = ctx.getSurfaceModels();
|
||||
reorderElements(ctx, models, 'backward');
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'c.send-to-back',
|
||||
label: 'Send to Back',
|
||||
icon: ArrowDownBigBottomIcon(),
|
||||
run(ctx) {
|
||||
const models = ctx.getSurfaceModels();
|
||||
reorderElements(ctx, models, 'back');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Clipboard Group
|
||||
// Uses the same `ID` for both page and edgeless modes.
|
||||
{
|
||||
id: 'a.clipboard',
|
||||
actions: [
|
||||
{
|
||||
id: 'copy',
|
||||
label: 'Copy',
|
||||
icon: CopyIcon(),
|
||||
run(ctx) {
|
||||
const models = ctx.getSurfaceModels();
|
||||
if (!models.length) return;
|
||||
|
||||
const edgelessClipboard = ctx.std.getOptional(
|
||||
EdgelessClipboardController
|
||||
);
|
||||
if (!edgelessClipboard) return;
|
||||
|
||||
edgelessClipboard.copy();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'duplicate',
|
||||
label: 'Duplicate',
|
||||
icon: DuplicateIcon(),
|
||||
run(ctx) {
|
||||
const models = ctx.getSurfaceModels();
|
||||
if (!models.length) return;
|
||||
|
||||
const edgeless = getEdgelessWith(ctx);
|
||||
if (!edgeless) return;
|
||||
|
||||
duplicate(edgeless, models).catch(console.error);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'reload',
|
||||
label: 'Reload',
|
||||
icon: ResetIcon(),
|
||||
when(ctx) {
|
||||
const models = ctx.getSurfaceModels();
|
||||
if (models.length === 0) return false;
|
||||
return models.every(isRefreshableModel);
|
||||
},
|
||||
run(ctx) {
|
||||
const blocks = ctx
|
||||
.getSurfaceModels()
|
||||
.map(model => ctx.view.getBlock(model.id))
|
||||
.filter(isRefreshableBlock);
|
||||
|
||||
if (!blocks.length) return;
|
||||
|
||||
for (const block of blocks) {
|
||||
block.refreshData();
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Conversions Group
|
||||
{
|
||||
id: 'd.conversions',
|
||||
actions: [
|
||||
{
|
||||
id: 'a.turn-into-linked-doc',
|
||||
label: 'Turn into linked doc',
|
||||
icon: LinkedPageIcon(),
|
||||
when(ctx) {
|
||||
const models = ctx.getSurfaceModels();
|
||||
if (models.length !== 1) return false;
|
||||
return ctx.matchModel(models[0], NoteBlockModel);
|
||||
},
|
||||
run(ctx) {
|
||||
const model = ctx.getCurrentModelByType(NoteBlockModel);
|
||||
if (!model) return;
|
||||
|
||||
const create = async () => {
|
||||
const title = await promptDocTitle(ctx.std);
|
||||
if (title === null) return;
|
||||
|
||||
const edgeless = getEdgelessWith(ctx);
|
||||
if (!edgeless) return;
|
||||
|
||||
const surfaceId = edgeless.surfaceBlockModel.id;
|
||||
if (!surfaceId) return;
|
||||
|
||||
const linkedDoc = createLinkedDocFromNote(ctx.store, model, title);
|
||||
if (!linkedDoc) return;
|
||||
|
||||
// Inserts linked doc card
|
||||
const cardId = ctx.std.get(EdgelessCRUDIdentifier).addBlock(
|
||||
EmbedSyncedDocBlockSchema.model.flavour,
|
||||
{
|
||||
xywh: model.xywh,
|
||||
style: 'syncedDoc',
|
||||
pageId: linkedDoc.id,
|
||||
index: model.index,
|
||||
},
|
||||
surfaceId
|
||||
);
|
||||
|
||||
ctx.track('CanvasElementAdded', {
|
||||
control: 'context-menu',
|
||||
type: 'embed-synced-doc',
|
||||
});
|
||||
ctx.track('DocCreated', {
|
||||
control: 'turn into linked doc',
|
||||
type: 'embed-linked-doc',
|
||||
});
|
||||
ctx.track('LinkedDocCreated', {
|
||||
control: 'turn into linked doc',
|
||||
type: 'embed-linked-doc',
|
||||
other: 'new doc',
|
||||
});
|
||||
|
||||
moveConnectors(model.id, cardId, edgeless.service);
|
||||
|
||||
// Deletes selected note
|
||||
ctx.store.transact(() => {
|
||||
ctx.store.deleteBlock(model);
|
||||
});
|
||||
ctx.gfx.selection.set({
|
||||
elements: [cardId],
|
||||
editing: false,
|
||||
});
|
||||
};
|
||||
|
||||
create().catch(console.error);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'b.create-linked-doc',
|
||||
label: 'Create linked doc',
|
||||
icon: LinkedPageIcon(),
|
||||
when(ctx) {
|
||||
const models = ctx.getSurfaceModels();
|
||||
if (models.length === 0) return false;
|
||||
if (models.length === 1) {
|
||||
return ![
|
||||
NoteBlockModel,
|
||||
EmbedLinkedDocModel,
|
||||
EmbedSyncedDocModel,
|
||||
].some(k => ctx.matchModel(models[0], k));
|
||||
}
|
||||
return true;
|
||||
},
|
||||
run(ctx) {
|
||||
const models = ctx.getSurfaceModels();
|
||||
if (!models.length) return;
|
||||
|
||||
const create = async () => {
|
||||
const edgeless = getEdgelessWith(ctx);
|
||||
if (!edgeless) return;
|
||||
|
||||
const surfaceId = edgeless.surfaceBlockModel.id;
|
||||
if (!surfaceId) return;
|
||||
|
||||
const title = await promptDocTitle(ctx.std);
|
||||
if (title === null) return;
|
||||
|
||||
const clonedModels = getSortedCloneElements(models);
|
||||
const linkedDoc = createLinkedDocFromEdgelessElements(
|
||||
ctx.host,
|
||||
clonedModels,
|
||||
title
|
||||
);
|
||||
|
||||
ctx.store.transact(() => {
|
||||
deleteElements(edgeless, clonedModels);
|
||||
});
|
||||
|
||||
// Inserts linked doc card
|
||||
const width = 364;
|
||||
const height = 390;
|
||||
const bound = getCommonBoundWithRotation(clonedModels);
|
||||
const cardId = ctx.std.get(EdgelessCRUDIdentifier).addBlock(
|
||||
EmbedLinkedDocBlockSchema.model.flavour,
|
||||
{
|
||||
xywh: `[${bound.center[0] - width / 2}, ${bound.center[1] - height / 2}, ${width}, ${height}]`,
|
||||
style: 'vertical',
|
||||
pageId: linkedDoc.id,
|
||||
},
|
||||
surfaceId
|
||||
);
|
||||
|
||||
ctx.gfx.selection.set({
|
||||
elements: [cardId],
|
||||
editing: false,
|
||||
});
|
||||
|
||||
ctx.track('CanvasElementAdded', {
|
||||
control: 'context-menu',
|
||||
type: 'embed-linked-doc',
|
||||
});
|
||||
ctx.track('DocCreated', {
|
||||
control: 'create linked doc',
|
||||
type: 'embed-linked-doc',
|
||||
});
|
||||
ctx.track('LinkedDocCreated', {
|
||||
control: 'create linked doc',
|
||||
type: 'embed-linked-doc',
|
||||
other: 'new doc',
|
||||
});
|
||||
|
||||
notifyDocCreated(ctx.std, ctx.store);
|
||||
};
|
||||
|
||||
create().catch(console.error);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Deleting Group
|
||||
{
|
||||
id: 'e.delete',
|
||||
label: 'Delete',
|
||||
icon: DeleteIcon(),
|
||||
variant: 'destructive',
|
||||
run(ctx) {
|
||||
const models = ctx.getSurfaceModels();
|
||||
if (!models.length) return;
|
||||
|
||||
const edgeless = getEdgelessWith(ctx);
|
||||
if (!edgeless) return;
|
||||
|
||||
ctx.store.captureSync();
|
||||
|
||||
deleteElements(edgeless, models);
|
||||
|
||||
// Clears
|
||||
ctx.select('surface');
|
||||
ctx.reset();
|
||||
},
|
||||
},
|
||||
] as const satisfies ToolbarActions;
|
||||
|
||||
function reorderElements(
|
||||
ctx: ToolbarContext,
|
||||
models: GfxModel[],
|
||||
type: ReorderingType
|
||||
) {
|
||||
if (!models.length) return;
|
||||
|
||||
for (const model of models) {
|
||||
const index = ctx.gfx.layer.getReorderedIndex(model, type);
|
||||
|
||||
// block should be updated in transaction
|
||||
if (model instanceof GfxBlockElementModel) {
|
||||
ctx.store.transact(() => {
|
||||
model.index = index;
|
||||
});
|
||||
} else {
|
||||
model.index = index;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isRefreshableModel(model: GfxModel) {
|
||||
return (
|
||||
model instanceof AttachmentBlockModel ||
|
||||
model instanceof BookmarkBlockModel ||
|
||||
model instanceof ImageBlockModel ||
|
||||
isExternalEmbedModel(model)
|
||||
);
|
||||
}
|
||||
|
||||
function isRefreshableBlock(block: BlockComponent | null) {
|
||||
return (
|
||||
!!block &&
|
||||
(block instanceof AttachmentBlockComponent ||
|
||||
block instanceof BookmarkBlockComponent ||
|
||||
block instanceof ImageBlockComponent ||
|
||||
isExternalEmbedBlockComponent(block))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { isFrameBlock } from '@blocksuite/affine-block-frame';
|
||||
import { getSurfaceBlock, isNoteBlock } from '@blocksuite/affine-block-surface';
|
||||
import type { FrameBlockModel, NoteBlockModel } from '@blocksuite/affine-model';
|
||||
import { replaceIdMiddleware } from '@blocksuite/affine-shared/adapters';
|
||||
import { DocModeProvider } from '@blocksuite/affine-shared/services';
|
||||
import { getBlockProps } from '@blocksuite/affine-shared/utils';
|
||||
import type { EditorHost } from '@blocksuite/std';
|
||||
import { GfxBlockElementModel, type GfxModel } from '@blocksuite/std/gfx';
|
||||
import { type Store, Text } from '@blocksuite/store';
|
||||
|
||||
import {
|
||||
getElementProps,
|
||||
mapFrameIds,
|
||||
sortEdgelessElements,
|
||||
} from '../../../edgeless/utils/clone-utils.js';
|
||||
|
||||
export function createLinkedDocFromNote(
|
||||
doc: Store,
|
||||
note: NoteBlockModel,
|
||||
docTitle?: string
|
||||
) {
|
||||
const _doc = doc.workspace.createDoc();
|
||||
const transformer = doc.getTransformer([
|
||||
replaceIdMiddleware(doc.workspace.idGenerator),
|
||||
]);
|
||||
const blockSnapshot = transformer.blockToSnapshot(note);
|
||||
if (!blockSnapshot) {
|
||||
console.error('Failed to create linked doc from note');
|
||||
return;
|
||||
}
|
||||
const linkedDoc = _doc.getStore({ id: doc.id });
|
||||
linkedDoc.load(() => {
|
||||
const rootId = linkedDoc.addBlock('affine:page', {
|
||||
title: new Text(docTitle),
|
||||
});
|
||||
linkedDoc.addBlock('affine:surface', {}, rootId);
|
||||
transformer
|
||||
.snapshotToBlock(blockSnapshot, linkedDoc, rootId)
|
||||
.catch(console.error);
|
||||
});
|
||||
|
||||
return linkedDoc;
|
||||
}
|
||||
|
||||
export function createLinkedDocFromEdgelessElements(
|
||||
host: EditorHost,
|
||||
elements: GfxModel[],
|
||||
docTitle?: string
|
||||
) {
|
||||
const _doc = host.doc.workspace.createDoc();
|
||||
const transformer = host.doc.getTransformer();
|
||||
const linkedDoc = _doc.getStore();
|
||||
linkedDoc.load(() => {
|
||||
const rootId = linkedDoc.addBlock('affine:page', {
|
||||
title: new Text(docTitle),
|
||||
});
|
||||
const surfaceId = linkedDoc.addBlock('affine:surface', {}, rootId);
|
||||
const surface = getSurfaceBlock(linkedDoc);
|
||||
if (!surface) return;
|
||||
|
||||
const sortedElements = sortEdgelessElements(elements);
|
||||
const ids = new Map<string, string>();
|
||||
sortedElements.forEach(model => {
|
||||
let newId = model.id;
|
||||
if (model instanceof GfxBlockElementModel) {
|
||||
const blockProps = getBlockProps(model);
|
||||
if (isNoteBlock(model)) {
|
||||
const blockSnapshot = transformer.blockToSnapshot(model);
|
||||
if (blockSnapshot) {
|
||||
transformer
|
||||
.snapshotToBlock(blockSnapshot, linkedDoc, rootId)
|
||||
.catch(console.error);
|
||||
}
|
||||
} else {
|
||||
if (isFrameBlock(model)) {
|
||||
mapFrameIds(blockProps as FrameBlockModel['props'], ids);
|
||||
}
|
||||
|
||||
newId = linkedDoc.addBlock(model.flavour, blockProps, surfaceId);
|
||||
}
|
||||
} else {
|
||||
const props = getElementProps(model, ids);
|
||||
newId = surface.addElement(props);
|
||||
}
|
||||
ids.set(model.id, newId);
|
||||
});
|
||||
});
|
||||
|
||||
host.std.get(DocModeProvider).setPrimaryMode('edgeless', linkedDoc.id);
|
||||
return linkedDoc;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ToolbarContext } from '@blocksuite/affine-shared/services';
|
||||
|
||||
import { EdgelessRootBlockComponent } from '../..';
|
||||
|
||||
// TODO(@fundon): it should be simple
|
||||
export function getEdgelessWith(ctx: ToolbarContext) {
|
||||
const rootModel = ctx.store.root;
|
||||
if (!rootModel) return;
|
||||
|
||||
const edgeless = ctx.view.getBlock(rootModel.id);
|
||||
if (!ctx.matchBlock(edgeless, EdgelessRootBlockComponent)) {
|
||||
console.error('edgeless view is not found.');
|
||||
return;
|
||||
}
|
||||
|
||||
return edgeless;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
FrameHighlightManager,
|
||||
FrameTool,
|
||||
PresentTool,
|
||||
} from '@blocksuite/affine-block-frame';
|
||||
import { ConnectionOverlay } from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
BrushTool,
|
||||
EraserTool,
|
||||
HighlighterTool,
|
||||
} from '@blocksuite/affine-gfx-brush';
|
||||
import {
|
||||
ConnectorFilter,
|
||||
ConnectorTool,
|
||||
} from '@blocksuite/affine-gfx-connector';
|
||||
import {
|
||||
MindMapDragExtension,
|
||||
MindMapIndicatorOverlay,
|
||||
} from '@blocksuite/affine-gfx-mindmap';
|
||||
import { NoteTool } from '@blocksuite/affine-gfx-note';
|
||||
import { ShapeTool } from '@blocksuite/affine-gfx-shape';
|
||||
import { TextTool } from '@blocksuite/affine-gfx-text';
|
||||
import { ElementTransformManager } from '@blocksuite/std/gfx';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
|
||||
import { EdgelessElementToolbarExtension } from './configs/toolbar';
|
||||
import { EdgelessRootBlockSpec } from './edgeless-root-spec.js';
|
||||
import { DblClickAddEdgelessText } from './element-transform/dblclick-add-edgeless-text.js';
|
||||
import { SnapExtension } from './element-transform/snap-manager.js';
|
||||
import { DefaultTool } from './gfx-tool/default-tool.js';
|
||||
import { EmptyTool } from './gfx-tool/empty-tool.js';
|
||||
import { PanTool } from './gfx-tool/pan-tool.js';
|
||||
import { TemplateTool } from './gfx-tool/template-tool.js';
|
||||
import { EditPropsMiddlewareBuilder } from './middlewares/base.js';
|
||||
import { SnapOverlay } from './utils/snap-manager.js';
|
||||
|
||||
export const EdgelessToolExtension: ExtensionType[] = [
|
||||
DefaultTool,
|
||||
PanTool,
|
||||
EraserTool,
|
||||
TextTool,
|
||||
ShapeTool,
|
||||
NoteTool,
|
||||
BrushTool,
|
||||
ConnectorTool,
|
||||
TemplateTool,
|
||||
EmptyTool,
|
||||
FrameTool,
|
||||
PresentTool,
|
||||
HighlighterTool,
|
||||
];
|
||||
|
||||
export const EdgelessEditExtensions: ExtensionType[] = [
|
||||
ElementTransformManager,
|
||||
ConnectorFilter,
|
||||
SnapExtension,
|
||||
MindMapDragExtension,
|
||||
FrameHighlightManager,
|
||||
DblClickAddEdgelessText,
|
||||
];
|
||||
|
||||
export const EdgelessBuiltInManager: ExtensionType[] = [
|
||||
ConnectionOverlay,
|
||||
MindMapIndicatorOverlay,
|
||||
SnapOverlay,
|
||||
EditPropsMiddlewareBuilder,
|
||||
EdgelessElementToolbarExtension,
|
||||
].flat();
|
||||
|
||||
export const EdgelessBuiltInSpecs: ExtensionType[] = [
|
||||
EdgelessRootBlockSpec,
|
||||
EdgelessToolExtension,
|
||||
EdgelessBuiltInManager,
|
||||
EdgelessEditExtensions,
|
||||
].flat();
|
||||
703
blocksuite/affine/blocks/root/src/edgeless/edgeless-keyboard.ts
Normal file
703
blocksuite/affine/blocks/root/src/edgeless/edgeless-keyboard.ts
Normal file
@@ -0,0 +1,703 @@
|
||||
import { insertLinkByQuickSearchCommand } from '@blocksuite/affine-block-bookmark';
|
||||
import { EdgelessTextBlockComponent } from '@blocksuite/affine-block-edgeless-text';
|
||||
import { isNoteBlock } from '@blocksuite/affine-block-surface';
|
||||
import { toast } from '@blocksuite/affine-components/toast';
|
||||
import { mountConnectorLabelEditor } from '@blocksuite/affine-gfx-connector';
|
||||
import {
|
||||
createGroupFromSelectedCommand,
|
||||
ungroupCommand,
|
||||
} from '@blocksuite/affine-gfx-group';
|
||||
import {
|
||||
getNearestTranslation,
|
||||
isElementOutsideViewport,
|
||||
isSingleMindMapNode,
|
||||
} from '@blocksuite/affine-gfx-mindmap';
|
||||
import { mountShapeTextEditor, ShapeTool } from '@blocksuite/affine-gfx-shape';
|
||||
import {
|
||||
ConnectorElementModel,
|
||||
ConnectorMode,
|
||||
EdgelessTextBlockModel,
|
||||
GroupElementModel,
|
||||
LayoutType,
|
||||
MindmapElementModel,
|
||||
NoteBlockModel,
|
||||
NoteDisplayMode,
|
||||
type ShapeElementModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
EditPropsStore,
|
||||
TelemetryProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { matchModels } from '@blocksuite/affine-shared/utils';
|
||||
import { IS_MAC } from '@blocksuite/global/env';
|
||||
import { Bound, getCommonBound } from '@blocksuite/global/gfx';
|
||||
import { SurfaceSelection, TextSelection } from '@blocksuite/std';
|
||||
import {
|
||||
GfxBlockElementModel,
|
||||
type GfxPrimitiveElementModel,
|
||||
type GfxToolsMap,
|
||||
type GfxToolsOption,
|
||||
isGfxGroupCompatibleModel,
|
||||
} from '@blocksuite/std/gfx';
|
||||
|
||||
import { PageKeyboardManager } from '../keyboard/keyboard-manager.js';
|
||||
import type { EdgelessRootBlockComponent } from './edgeless-root-block.js';
|
||||
import {
|
||||
DEFAULT_NOTE_CHILD_FLAVOUR,
|
||||
DEFAULT_NOTE_CHILD_TYPE,
|
||||
DEFAULT_NOTE_TIP,
|
||||
} from './utils/consts.js';
|
||||
import { deleteElements } from './utils/crud.js';
|
||||
import { getNextShapeType } from './utils/hotkey-utils.js';
|
||||
import { isCanvasElement } from './utils/query.js';
|
||||
|
||||
export class EdgelessPageKeyboardManager extends PageKeyboardManager {
|
||||
get gfx() {
|
||||
return this.rootComponent.gfx;
|
||||
}
|
||||
|
||||
constructor(override rootComponent: EdgelessRootBlockComponent) {
|
||||
super(rootComponent);
|
||||
this.rootComponent.bindHotKey(
|
||||
{
|
||||
v: () => {
|
||||
this._setEdgelessTool('default');
|
||||
},
|
||||
t: () => {
|
||||
this._setEdgelessTool('text');
|
||||
},
|
||||
c: () => {
|
||||
const mode = ConnectorMode.Curve;
|
||||
rootComponent.std.get(EditPropsStore).recordLastProps('connector', {
|
||||
mode,
|
||||
});
|
||||
this._setEdgelessTool('connector', { mode });
|
||||
},
|
||||
h: () => {
|
||||
this._setEdgelessTool('pan', {
|
||||
panning: false,
|
||||
});
|
||||
},
|
||||
n: () => {
|
||||
this._setEdgelessTool('affine:note', {
|
||||
childFlavour: DEFAULT_NOTE_CHILD_FLAVOUR,
|
||||
childType: DEFAULT_NOTE_CHILD_TYPE,
|
||||
tip: DEFAULT_NOTE_TIP,
|
||||
});
|
||||
},
|
||||
p: () => {
|
||||
this._setEdgelessTool('brush');
|
||||
},
|
||||
e: () => {
|
||||
this._setEdgelessTool('eraser');
|
||||
},
|
||||
k: () => {
|
||||
if (this.rootComponent.service.locked) return;
|
||||
const { selection } = rootComponent.service;
|
||||
|
||||
if (
|
||||
selection.selectedElements.length === 1 &&
|
||||
selection.firstElement instanceof GfxBlockElementModel &&
|
||||
matchModels(selection.firstElement as GfxBlockElementModel, [
|
||||
NoteBlockModel,
|
||||
])
|
||||
) {
|
||||
rootComponent.slots.toggleNoteSlicer.next();
|
||||
}
|
||||
},
|
||||
f: () => {
|
||||
if (this.rootComponent.service.locked) return;
|
||||
if (
|
||||
this.rootComponent.service.selection.selectedElements.length !==
|
||||
0 &&
|
||||
!this.rootComponent.service.selection.editing
|
||||
) {
|
||||
const frame = rootComponent.service.frame.createFrameOnSelected();
|
||||
if (!frame) return;
|
||||
this.rootComponent.std
|
||||
.getOptional(TelemetryProvider)
|
||||
?.track('CanvasElementAdded', {
|
||||
control: 'shortcut',
|
||||
page: 'whiteboard editor',
|
||||
module: 'toolbar',
|
||||
segment: 'toolbar',
|
||||
type: 'frame',
|
||||
});
|
||||
rootComponent.surface.fitToViewport(Bound.deserialize(frame.xywh));
|
||||
} else if (!this.rootComponent.service.selection.editing) {
|
||||
this._setEdgelessTool('frame');
|
||||
}
|
||||
},
|
||||
'-': () => {
|
||||
if (this.rootComponent.service.locked) return;
|
||||
const { selectedElements: elements } =
|
||||
rootComponent.service.selection;
|
||||
if (
|
||||
!rootComponent.service.selection.editing &&
|
||||
elements.length === 1 &&
|
||||
isNoteBlock(elements[0])
|
||||
) {
|
||||
rootComponent.slots.toggleNoteSlicer.next();
|
||||
}
|
||||
},
|
||||
'@': () => {
|
||||
const std = this.rootComponent.std;
|
||||
if (
|
||||
std.selection.getGroup('note').length > 0 ||
|
||||
std.selection.find(TextSelection) ||
|
||||
Boolean(std.selection.find(SurfaceSelection)?.editing)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const [_, { insertedLinkType }] = std.command.exec(
|
||||
insertLinkByQuickSearchCommand
|
||||
);
|
||||
|
||||
insertedLinkType
|
||||
?.then(type => {
|
||||
const flavour = type?.flavour;
|
||||
if (!flavour) return;
|
||||
|
||||
rootComponent.std
|
||||
.getOptional(TelemetryProvider)
|
||||
?.track('CanvasElementAdded', {
|
||||
control: 'shortcut',
|
||||
page: 'whiteboard editor',
|
||||
module: 'toolbar',
|
||||
segment: 'toolbar',
|
||||
type: flavour.split(':')[1],
|
||||
});
|
||||
})
|
||||
.catch(console.error);
|
||||
},
|
||||
'Shift-s': () => {
|
||||
if (this.rootComponent.service.locked) return;
|
||||
const controller = rootComponent.gfx.tool.currentTool$.peek();
|
||||
if (
|
||||
this.rootComponent.service.selection.editing ||
|
||||
!(controller instanceof ShapeTool)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const { shapeName } = controller.activatedOption;
|
||||
const nextShapeName = getNextShapeType(shapeName);
|
||||
this._setEdgelessTool('shape', {
|
||||
shapeName: nextShapeName,
|
||||
});
|
||||
|
||||
controller.createOverlay();
|
||||
},
|
||||
'Mod-g': ctx => {
|
||||
if (this.rootComponent.service.locked) return;
|
||||
if (
|
||||
this.rootComponent.service.selection.selectedElements.length > 1 &&
|
||||
!this.rootComponent.service.selection.editing
|
||||
) {
|
||||
ctx.get('keyboardState').event.preventDefault();
|
||||
rootComponent.std.command.exec(createGroupFromSelectedCommand);
|
||||
}
|
||||
},
|
||||
'Shift-Mod-g': ctx => {
|
||||
if (this.rootComponent.service.locked) return;
|
||||
const { selection } = this.rootComponent.service;
|
||||
if (
|
||||
selection.selectedElements.length === 1 &&
|
||||
selection.firstElement instanceof GroupElementModel &&
|
||||
!selection.firstElement.isLocked()
|
||||
) {
|
||||
ctx.get('keyboardState').event.preventDefault();
|
||||
rootComponent.std.command.exec(ungroupCommand, {
|
||||
group: selection.firstElement,
|
||||
});
|
||||
}
|
||||
},
|
||||
'Mod-a': ctx => {
|
||||
if (this.rootComponent.service.locked) return;
|
||||
if (this.rootComponent.service.selection.editing) {
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.get('defaultState').event.preventDefault();
|
||||
const { service } = this.rootComponent;
|
||||
this.rootComponent.service.selection.set({
|
||||
elements: [
|
||||
...service.blocks
|
||||
.filter(
|
||||
block =>
|
||||
block.group === null &&
|
||||
!(
|
||||
matchModels(block, [NoteBlockModel]) &&
|
||||
block.props.displayMode === NoteDisplayMode.DocOnly
|
||||
)
|
||||
)
|
||||
.map(block => block.id),
|
||||
...service.elements
|
||||
.filter(el => el.group === null)
|
||||
.map(el => el.id),
|
||||
],
|
||||
editing: false,
|
||||
});
|
||||
},
|
||||
'Mod--': ctx => {
|
||||
ctx.get('defaultState').event.preventDefault();
|
||||
this.rootComponent.service.setZoomByAction('out');
|
||||
},
|
||||
'Alt-0': ctx => {
|
||||
ctx.get('defaultState').event.preventDefault();
|
||||
this.rootComponent.service.setZoomByAction('reset');
|
||||
},
|
||||
'Alt-1': ctx => {
|
||||
ctx.get('defaultState').event.preventDefault();
|
||||
this.rootComponent.service.setZoomByAction('fit');
|
||||
},
|
||||
'Alt-2': ctx => {
|
||||
ctx.get('defaultState').event.preventDefault();
|
||||
|
||||
const selectedElements = this.gfx.selection.selectedElements;
|
||||
|
||||
if (selectedElements.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bound = getCommonBound(selectedElements);
|
||||
if (bound === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast(this.rootComponent.host, 'Zoom to selection');
|
||||
|
||||
this.gfx.viewport.setViewportByBound(
|
||||
bound,
|
||||
[0.12, 0.12, 0.12, 0.12],
|
||||
true
|
||||
);
|
||||
},
|
||||
'Mod-=': ctx => {
|
||||
ctx.get('defaultState').event.preventDefault();
|
||||
this.rootComponent.service.setZoomByAction('in');
|
||||
},
|
||||
Backspace: () => {
|
||||
this._delete();
|
||||
},
|
||||
Delete: () => {
|
||||
this._delete();
|
||||
},
|
||||
'Control-d': () => {
|
||||
if (!IS_MAC) return;
|
||||
this._delete();
|
||||
},
|
||||
Escape: () => {
|
||||
if (!this.rootComponent.service.selection.empty) {
|
||||
rootComponent.selection.clear();
|
||||
}
|
||||
},
|
||||
|
||||
ArrowUp: () => {
|
||||
this._move('ArrowUp');
|
||||
},
|
||||
|
||||
ArrowDown: () => {
|
||||
this._move('ArrowDown');
|
||||
},
|
||||
|
||||
ArrowLeft: () => {
|
||||
this._move('ArrowLeft');
|
||||
},
|
||||
|
||||
ArrowRight: () => {
|
||||
this._move('ArrowRight');
|
||||
},
|
||||
|
||||
'Shift-ArrowUp': () => {
|
||||
this._move('ArrowUp', true);
|
||||
},
|
||||
|
||||
'Shift-ArrowDown': () => {
|
||||
this._move('ArrowDown', true);
|
||||
},
|
||||
|
||||
'Shift-ArrowLeft': () => {
|
||||
this._move('ArrowLeft', true);
|
||||
},
|
||||
|
||||
'Shift-ArrowRight': () => {
|
||||
this._move('ArrowRight', true);
|
||||
},
|
||||
|
||||
Enter: () => {
|
||||
const { service } = rootComponent;
|
||||
const selection = service.selection;
|
||||
const elements = selection.selectedElements;
|
||||
const onlyOne = elements.length === 1;
|
||||
|
||||
if (onlyOne) {
|
||||
const element = elements[0];
|
||||
const id = element.id;
|
||||
|
||||
if (element.isLocked()) return;
|
||||
|
||||
if (element instanceof ConnectorElementModel) {
|
||||
selection.set({
|
||||
elements: [id],
|
||||
editing: true,
|
||||
});
|
||||
requestAnimationFrame(() => {
|
||||
mountConnectorLabelEditor(element, rootComponent);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (element instanceof EdgelessTextBlockModel) {
|
||||
selection.set({
|
||||
elements: [id],
|
||||
editing: true,
|
||||
});
|
||||
const textBlock = rootComponent.host.view.getBlock(id);
|
||||
if (textBlock instanceof EdgelessTextBlockComponent) {
|
||||
textBlock.tryFocusEnd();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isSingleMindMapNode(elements)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mindmap = elements[0].group as MindmapElementModel;
|
||||
const currentNode = mindmap.getNode(elements[0].id)!;
|
||||
const node = mindmap.getNode(elements[0].id)!;
|
||||
const parent = mindmap.getParentNode(node.id) ?? node;
|
||||
const id = mindmap.addNode(parent.id, currentNode.id, 'after');
|
||||
const target = service.crud.getElementById(id) as ShapeElementModel;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
mountShapeTextEditor(target, rootComponent);
|
||||
|
||||
if (isElementOutsideViewport(service.viewport, target, [20, 20])) {
|
||||
const { elementBound } = target;
|
||||
|
||||
service.viewport.smoothTranslate(
|
||||
elementBound.x + elementBound.w / 2,
|
||||
elementBound.y + elementBound.h / 2
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
Tab: ctx => {
|
||||
ctx.get('defaultState').event.preventDefault();
|
||||
|
||||
const { service } = rootComponent;
|
||||
const selection = service.selection;
|
||||
const elements = selection.selectedElements;
|
||||
|
||||
if (!isSingleMindMapNode(elements)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mindmap = elements[0].group as MindmapElementModel;
|
||||
if (mindmap.isLocked()) return;
|
||||
|
||||
const node = mindmap.getNode(elements[0].id)!;
|
||||
const id = mindmap.addNode(node.id);
|
||||
const target = service.crud.getElementById(id) as ShapeElementModel;
|
||||
|
||||
if (node.detail.collapsed) {
|
||||
mindmap.toggleCollapse(node, { layout: true });
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
mountShapeTextEditor(target, rootComponent);
|
||||
|
||||
if (isElementOutsideViewport(service.viewport, target, [20, 20])) {
|
||||
const { elementBound } = target;
|
||||
|
||||
service.viewport.smoothTranslate(
|
||||
elementBound.x + elementBound.w / 2,
|
||||
elementBound.y + elementBound.h / 2
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
global: true,
|
||||
}
|
||||
);
|
||||
|
||||
this._bindToggleHand();
|
||||
}
|
||||
|
||||
private _bindToggleHand() {
|
||||
this.rootComponent.handleEvent(
|
||||
'keyDown',
|
||||
ctx => {
|
||||
const event = ctx.get('keyboardState').raw;
|
||||
const gfx = this.rootComponent.gfx;
|
||||
const selection = gfx.selection;
|
||||
|
||||
if (event.code === 'Space' && !event.repeat) {
|
||||
this._space(event);
|
||||
} else if (
|
||||
!selection.editing &&
|
||||
// the key might be `Unidentified` according to mdn
|
||||
event.key?.length === 1 &&
|
||||
!event.shiftKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.altKey &&
|
||||
!event.metaKey
|
||||
) {
|
||||
const elements = selection.selectedElements;
|
||||
const doc = this.rootComponent.doc;
|
||||
|
||||
if (isSingleMindMapNode(elements)) {
|
||||
const target = gfx.getElementById(
|
||||
elements[0].id
|
||||
) as ShapeElementModel;
|
||||
if (target.text) {
|
||||
doc.transact(() => {
|
||||
target.text!.delete(0, target.text!.length);
|
||||
target.text!.insert(0, event.key);
|
||||
});
|
||||
}
|
||||
mountShapeTextEditor(target, this.rootComponent);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
{ global: true }
|
||||
);
|
||||
this.rootComponent.handleEvent(
|
||||
'keyUp',
|
||||
ctx => {
|
||||
const event = ctx.get('keyboardState').raw;
|
||||
if (event.code === 'Space' && !event.repeat) {
|
||||
this._space(event);
|
||||
}
|
||||
},
|
||||
{ global: true }
|
||||
);
|
||||
}
|
||||
|
||||
private _delete() {
|
||||
const edgeless = this.rootComponent;
|
||||
|
||||
if (edgeless.service.locked) return;
|
||||
if (edgeless.service.selection.editing) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedElements = edgeless.service.selection.selectedElements;
|
||||
if (selectedElements.some(e => e.isLocked())) return;
|
||||
|
||||
if (isSingleMindMapNode(selectedElements)) {
|
||||
const node = selectedElements[0];
|
||||
const mindmap = node.group as MindmapElementModel;
|
||||
const focusNode =
|
||||
mindmap.getSiblingNode(node.id, 'prev') ??
|
||||
mindmap.getSiblingNode(node.id, 'next') ??
|
||||
mindmap.getParentNode(node.id);
|
||||
|
||||
if (focusNode) {
|
||||
edgeless.service.selection.set({
|
||||
elements: [focusNode.element.id],
|
||||
editing: false,
|
||||
});
|
||||
}
|
||||
|
||||
deleteElements(edgeless, selectedElements);
|
||||
} else {
|
||||
deleteElements(edgeless, selectedElements);
|
||||
edgeless.service.selection.clear();
|
||||
}
|
||||
}
|
||||
|
||||
private _move(key: string, shift = false) {
|
||||
const edgeless = this.rootComponent;
|
||||
|
||||
if (edgeless.service.locked) return;
|
||||
if (edgeless.service.selection.editing) return;
|
||||
|
||||
const { selectedElements } = edgeless.service.selection;
|
||||
const inc = shift ? 10 : 1;
|
||||
const mindmapNodes = selectedElements.filter(
|
||||
el => el.group instanceof MindmapElementModel
|
||||
);
|
||||
|
||||
if (mindmapNodes.length > 0) {
|
||||
const node = mindmapNodes[0];
|
||||
const mindmap = node.group as MindmapElementModel;
|
||||
const nodeDirection = mindmap.getLayoutDir(node.id);
|
||||
let targetNode: GfxPrimitiveElementModel | null = null;
|
||||
|
||||
switch (key) {
|
||||
case 'ArrowUp':
|
||||
case 'ArrowDown':
|
||||
targetNode =
|
||||
mindmap.getSiblingNode(
|
||||
node.id,
|
||||
key === 'ArrowDown' ? 'next' : 'prev',
|
||||
nodeDirection === LayoutType.RIGHT
|
||||
? 'right'
|
||||
: nodeDirection === LayoutType.LEFT
|
||||
? 'left'
|
||||
: undefined
|
||||
)?.element ?? null;
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
targetNode =
|
||||
nodeDirection === LayoutType.RIGHT
|
||||
? (mindmap.getParentNode(node.id)?.element ?? null)
|
||||
: (mindmap.getChildNodes(node.id, 'left')[0]?.element ?? null);
|
||||
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
targetNode =
|
||||
nodeDirection === LayoutType.RIGHT ||
|
||||
nodeDirection === LayoutType.BALANCE
|
||||
? (mindmap.getChildNodes(node.id, 'right')[0]?.element ?? null)
|
||||
: (mindmap.getParentNode(node.id)?.element ?? null);
|
||||
break;
|
||||
}
|
||||
|
||||
if (targetNode) {
|
||||
edgeless.service.selection.set({
|
||||
elements: [targetNode.id],
|
||||
editing: false,
|
||||
});
|
||||
|
||||
if (
|
||||
isElementOutsideViewport(
|
||||
edgeless.service.viewport,
|
||||
targetNode,
|
||||
[90, 20]
|
||||
)
|
||||
) {
|
||||
const [dx, dy] = getNearestTranslation(
|
||||
edgeless.service.viewport,
|
||||
targetNode,
|
||||
[100, 20]
|
||||
);
|
||||
|
||||
edgeless.service.viewport.smoothTranslate(
|
||||
edgeless.service.viewport.centerX - dx,
|
||||
edgeless.service.viewport.centerY + dy
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedElements.some(e => e.isLocked())) return;
|
||||
|
||||
const movedElements = new Set([
|
||||
...selectedElements,
|
||||
...selectedElements
|
||||
.map(el => (isGfxGroupCompatibleModel(el) ? el.descendantElements : []))
|
||||
.flat(),
|
||||
]);
|
||||
|
||||
movedElements.forEach(element => {
|
||||
const bound = Bound.deserialize(element.xywh).clone();
|
||||
|
||||
switch (key) {
|
||||
case 'ArrowUp':
|
||||
bound.y -= inc;
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
bound.x -= inc;
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
bound.x += inc;
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
bound.y += inc;
|
||||
break;
|
||||
}
|
||||
|
||||
if (isCanvasElement(element)) {
|
||||
if (element instanceof ConnectorElementModel) {
|
||||
element.moveTo(bound);
|
||||
}
|
||||
element['xywh'] = bound.serialize();
|
||||
} else {
|
||||
element['xywh'] = bound.serialize();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _setEdgelessTool<K extends keyof GfxToolsMap>(
|
||||
toolName: K,
|
||||
...options: K extends keyof GfxToolsOption
|
||||
? [option: GfxToolsOption[K], ignoreActiveState?: boolean]
|
||||
: [option: void, ignoreActiveState?: boolean]
|
||||
) {
|
||||
const ignoreActiveState =
|
||||
typeof options === 'boolean'
|
||||
? options[0]
|
||||
: options[1] === undefined
|
||||
? false
|
||||
: options[1];
|
||||
|
||||
// when editing, should not update mouse mode by shortcut
|
||||
if (!ignoreActiveState && this.rootComponent.gfx.selection.editing) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.rootComponent.gfx.tool.setTool<K>(
|
||||
toolName,
|
||||
// @ts-expect-error FIXME: ts error
|
||||
options[0] !== undefined && typeof options[0] !== 'boolean'
|
||||
? options[0]
|
||||
: undefined
|
||||
);
|
||||
}
|
||||
|
||||
private _space(event: KeyboardEvent) {
|
||||
/*
|
||||
Call this function with a check for !event.repeat to consider only the first keydown (not repeat). This way, you can use onPressSpaceBar in a tool to determine if the space bar is pressed or not.
|
||||
*/
|
||||
|
||||
const edgeless = this.rootComponent;
|
||||
const selection = edgeless.service.selection;
|
||||
const currentTool = edgeless.gfx.tool.currentTool$.peek()!;
|
||||
const currentSel = selection.surfaceSelections;
|
||||
const isKeyDown = event.type === 'keydown';
|
||||
|
||||
if (edgeless.gfx.tool.dragging$.peek()) {
|
||||
return; // Don't do anything if currently dragging
|
||||
}
|
||||
|
||||
const revertToPrevTool = (ev: KeyboardEvent) => {
|
||||
if (ev.code === 'Space') {
|
||||
this._setEdgelessTool(
|
||||
// @ts-expect-error FIXME: ts error
|
||||
currentTool.toolName,
|
||||
currentTool?.activatedOption
|
||||
);
|
||||
selection.set(currentSel);
|
||||
document.removeEventListener('keyup', revertToPrevTool, false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isKeyDown) {
|
||||
if (
|
||||
currentTool.toolName === 'pan' ||
|
||||
(currentTool.toolName === 'default' && selection.editing)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this._setEdgelessTool('pan', { panning: false });
|
||||
|
||||
edgeless.dispatcher.disposables.addFromEvent(
|
||||
document,
|
||||
'keyup',
|
||||
revertToPrevTool
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
import { NoteConfigExtension } from '@blocksuite/affine-block-note';
|
||||
import type {
|
||||
SurfaceBlockComponent,
|
||||
SurfaceBlockModel,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
EdgelessLegacySlotIdentifier,
|
||||
getBgGridGap,
|
||||
normalizeWheelDeltaY,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import { isSingleMindMapNode } from '@blocksuite/affine-gfx-mindmap';
|
||||
import { mountShapeTextEditor } from '@blocksuite/affine-gfx-shape';
|
||||
import {
|
||||
NoteBlockModel,
|
||||
type RootBlockModel,
|
||||
type ShapeElementModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
EditorSettingProvider,
|
||||
EditPropsStore,
|
||||
FontLoaderService,
|
||||
ThemeProvider,
|
||||
ViewportElementProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
isTouchPadPinchEvent,
|
||||
matchModels,
|
||||
requestConnectedFrame,
|
||||
requestThrottledConnectedFrame,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import { IS_WINDOWS } from '@blocksuite/global/env';
|
||||
import { Bound, Point, Vec } from '@blocksuite/global/gfx';
|
||||
import {
|
||||
BlockComponent,
|
||||
type GfxBlockComponent,
|
||||
SurfaceSelection,
|
||||
type UIEventHandler,
|
||||
} from '@blocksuite/std';
|
||||
import {
|
||||
GfxControllerIdentifier,
|
||||
type GfxViewportElement,
|
||||
} from '@blocksuite/std/gfx';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import { css, html } from 'lit';
|
||||
import { query } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
|
||||
import type { EdgelessRootBlockWidgetName } from '../types.js';
|
||||
import type { EdgelessSelectedRectWidget } from './components/rects/edgeless-selected-rect.js';
|
||||
import { EdgelessPageKeyboardManager } from './edgeless-keyboard.js';
|
||||
import type { EdgelessRootService } from './edgeless-root-service.js';
|
||||
import { isCanvasElement } from './utils/query.js';
|
||||
|
||||
export class EdgelessRootBlockComponent extends BlockComponent<
|
||||
RootBlockModel,
|
||||
EdgelessRootService,
|
||||
EdgelessRootBlockWidgetName
|
||||
> {
|
||||
static override styles = css`
|
||||
affine-edgeless-root {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
display: block;
|
||||
height: 100%;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.widgets-container {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
pointer-events: none;
|
||||
contain: size layout;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.widgets-container > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.edgeless-background {
|
||||
height: 100%;
|
||||
background-color: var(--affine-background-primary-color);
|
||||
background-image: radial-gradient(
|
||||
var(--affine-edgeless-grid-color) 1px,
|
||||
var(--affine-background-primary-color) 1px
|
||||
);
|
||||
}
|
||||
|
||||
.edgeless-container {
|
||||
color: var(--affine-text-primary-color);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.selected {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
private readonly _refreshLayerViewport = requestThrottledConnectedFrame(
|
||||
() => {
|
||||
const { zoom, translateX, translateY } = this.gfx.viewport;
|
||||
const gap = getBgGridGap(zoom);
|
||||
|
||||
if (this.backgroundElm) {
|
||||
this.backgroundElm.style.setProperty(
|
||||
'background-position',
|
||||
`${translateX}px ${translateY}px`
|
||||
);
|
||||
this.backgroundElm.style.setProperty(
|
||||
'background-size',
|
||||
`${gap}px ${gap}px`
|
||||
);
|
||||
}
|
||||
},
|
||||
this
|
||||
);
|
||||
|
||||
private _resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
keyboardManager: EdgelessPageKeyboardManager | null = null;
|
||||
|
||||
get dispatcher() {
|
||||
return this.std.event;
|
||||
}
|
||||
|
||||
get fontLoader() {
|
||||
return this.std.get(FontLoaderService);
|
||||
}
|
||||
|
||||
get gfx() {
|
||||
return this.std.get(GfxControllerIdentifier);
|
||||
}
|
||||
|
||||
get selectedRectWidget() {
|
||||
return this.host.view.getWidget(
|
||||
'edgeless-selected-rect',
|
||||
this.host.id
|
||||
) as EdgelessSelectedRectWidget;
|
||||
}
|
||||
|
||||
get slots() {
|
||||
return this.std.get(EdgelessLegacySlotIdentifier);
|
||||
}
|
||||
|
||||
get surfaceBlockModel() {
|
||||
return this.model.children.find(
|
||||
child => child.flavour === 'affine:surface'
|
||||
) as SurfaceBlockModel;
|
||||
}
|
||||
|
||||
get viewportElement(): HTMLElement {
|
||||
return this.std.get(ViewportElementProvider).viewportElement;
|
||||
}
|
||||
|
||||
private _initFontLoader() {
|
||||
this.std
|
||||
.get(FontLoaderService)
|
||||
.ready.then(() => {
|
||||
this.surface.refresh();
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
private _initLayerUpdateEffect() {
|
||||
const updateLayers = requestThrottledConnectedFrame(() => {
|
||||
const blocks = Array.from(
|
||||
this.gfxViewportElm.children as HTMLCollectionOf<GfxBlockComponent>
|
||||
);
|
||||
|
||||
blocks.forEach((block: GfxBlockComponent) => {
|
||||
block.updateZIndex?.();
|
||||
});
|
||||
});
|
||||
|
||||
this._disposables.add(
|
||||
this.gfx.layer.slots.layerUpdated.subscribe(() => updateLayers())
|
||||
);
|
||||
}
|
||||
|
||||
private _initPanEvent() {
|
||||
this.disposables.add(
|
||||
this.dispatcher.add('pan', ctx => {
|
||||
const { viewport } = this.gfx;
|
||||
if (viewport.locked) return;
|
||||
|
||||
const multiPointersState = ctx.get('multiPointerState');
|
||||
const [p1, p2] = multiPointersState.pointers;
|
||||
|
||||
const dx =
|
||||
(0.25 * (p1.delta.x + p2.delta.x)) /
|
||||
viewport.zoom /
|
||||
viewport.viewScale;
|
||||
const dy =
|
||||
(0.25 * (p1.delta.y + p2.delta.y)) /
|
||||
viewport.zoom /
|
||||
viewport.viewScale;
|
||||
|
||||
// direction is opposite
|
||||
viewport.applyDeltaCenter(-dx, -dy);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private _initPinchEvent() {
|
||||
this.disposables.add(
|
||||
this.dispatcher.add('pinch', ctx => {
|
||||
const { viewport } = this.gfx;
|
||||
if (viewport.locked) return;
|
||||
|
||||
const multiPointersState = ctx.get('multiPointerState');
|
||||
const [p1, p2] = multiPointersState.pointers;
|
||||
|
||||
const currentCenter = new Point(
|
||||
0.5 * (p1.x + p2.x),
|
||||
0.5 * (p1.y + p2.y)
|
||||
);
|
||||
|
||||
const lastDistance = Vec.dist(
|
||||
[p1.x - p1.delta.x, p1.y - p1.delta.y],
|
||||
[p2.x - p2.delta.x, p2.y - p2.delta.y]
|
||||
);
|
||||
const currentDistance = Vec.dist([p1.x, p1.y], [p2.x, p2.y]);
|
||||
|
||||
const zoom = (currentDistance / lastDistance) * viewport.zoom;
|
||||
|
||||
const [baseX, baseY] = viewport.toModelCoord(
|
||||
currentCenter.x,
|
||||
currentCenter.y
|
||||
);
|
||||
|
||||
viewport.setZoom(zoom, new Point(baseX, baseY));
|
||||
|
||||
return false;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private _initPixelRatioChangeEffect() {
|
||||
let media: MediaQueryList;
|
||||
|
||||
const onPixelRatioChange = () => {
|
||||
if (media) {
|
||||
this.gfx.viewport.onResize();
|
||||
media.removeEventListener('change', onPixelRatioChange);
|
||||
}
|
||||
|
||||
media = matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
|
||||
media.addEventListener('change', onPixelRatioChange);
|
||||
};
|
||||
|
||||
onPixelRatioChange();
|
||||
|
||||
this._disposables.add(() => {
|
||||
media?.removeEventListener('change', onPixelRatioChange);
|
||||
});
|
||||
}
|
||||
|
||||
private _initRemoteCursor() {
|
||||
let rafId: number | null = null;
|
||||
|
||||
const setRemoteCursor = (pos: { x: number; y: number }) => {
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
rafId = requestConnectedFrame(() => {
|
||||
if (!this.gfx.viewport) return;
|
||||
const cursorPosition = this.gfx.viewport.toModelCoord(pos.x, pos.y);
|
||||
this.gfx.selection.setCursor({
|
||||
x: cursorPosition[0],
|
||||
y: cursorPosition[1],
|
||||
});
|
||||
rafId = null;
|
||||
}, this);
|
||||
};
|
||||
|
||||
this.handleEvent('pointerMove', e => {
|
||||
const pointerEvent = e.get('pointerState');
|
||||
setRemoteCursor(pointerEvent);
|
||||
});
|
||||
}
|
||||
|
||||
private _initResizeEffect() {
|
||||
const resizeObserver = new ResizeObserver((_: ResizeObserverEntry[]) => {
|
||||
this.gfx.selection.set(this.gfx.selection.surfaceSelections);
|
||||
this.gfx.viewport.onResize();
|
||||
});
|
||||
|
||||
resizeObserver.observe(this.viewportElement);
|
||||
this._resizeObserver = resizeObserver;
|
||||
}
|
||||
|
||||
private _initSlotEffects() {
|
||||
const { disposables } = this;
|
||||
|
||||
this.disposables.add(
|
||||
this.std.get(ThemeProvider).theme$.subscribe(() => this.surface.refresh())
|
||||
);
|
||||
|
||||
disposables.add(
|
||||
effect(() => {
|
||||
this.style.cursor = this.gfx.cursor$.value;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private _initViewport() {
|
||||
const { std, gfx } = this;
|
||||
|
||||
const run = () => {
|
||||
const animationFn = std.getOptional(
|
||||
NoteConfigExtension.identifier
|
||||
)?.pageBlockViewportFitAnimation;
|
||||
if (animationFn) {
|
||||
const pageBlock = this.model.children.find(
|
||||
(child): child is NoteBlockModel =>
|
||||
matchModels(child, [NoteBlockModel]) && child.isPageBlock()
|
||||
);
|
||||
if (pageBlock && animationFn({ std: this.std, note: pageBlock })) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const storedViewport = std.get(EditPropsStore).getStorage('viewport');
|
||||
if (storedViewport) {
|
||||
if ('xywh' in storedViewport) {
|
||||
const bound = Bound.deserialize(storedViewport.xywh);
|
||||
gfx.viewport.setViewportByBound(bound, storedViewport.padding);
|
||||
} else {
|
||||
const { zoom, centerX, centerY } = storedViewport;
|
||||
gfx.viewport.setViewport(zoom, [centerX, centerY]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.gfx.fitToScreen();
|
||||
};
|
||||
|
||||
run();
|
||||
|
||||
this._disposables.add(() => {
|
||||
std.get(EditPropsStore).setStorage('viewport', {
|
||||
centerX: gfx.viewport.centerX,
|
||||
centerY: gfx.viewport.centerY,
|
||||
zoom: gfx.viewport.zoom,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private _initWheelEvent() {
|
||||
this._disposables.add(
|
||||
this.dispatcher.add('wheel', ctx => {
|
||||
const config = this.std.getOptional(EditorSettingProvider);
|
||||
const state = ctx.get('defaultState');
|
||||
const e = state.event as WheelEvent;
|
||||
const edgelessScrollZoom = config?.peek().edgelessScrollZoom ?? false;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const { viewport } = this.gfx;
|
||||
if (viewport.locked) return;
|
||||
|
||||
// zoom
|
||||
if (isTouchPadPinchEvent(e) || edgelessScrollZoom) {
|
||||
const rect = this.getBoundingClientRect();
|
||||
// Perform zooming relative to the mouse position
|
||||
const [baseX, baseY] = this.gfx.viewport.toModelCoord(
|
||||
e.clientX - rect.x,
|
||||
e.clientY - rect.y
|
||||
);
|
||||
|
||||
const zoom = normalizeWheelDeltaY(e.deltaY, viewport.zoom);
|
||||
viewport.setZoom(zoom, new Point(baseX, baseY), true);
|
||||
e.stopPropagation();
|
||||
}
|
||||
// pan
|
||||
else {
|
||||
const simulateHorizontalScroll = IS_WINDOWS && e.shiftKey;
|
||||
const dx = simulateHorizontalScroll
|
||||
? e.deltaY / viewport.zoom
|
||||
: e.deltaX / viewport.zoom;
|
||||
const dy = simulateHorizontalScroll ? 0 : e.deltaY / viewport.zoom;
|
||||
|
||||
viewport.applyDeltaCenter(dx, dy);
|
||||
viewport.viewportMoved.next([dx, dy]);
|
||||
e.stopPropagation();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override bindHotKey(
|
||||
keymap: Record<string, UIEventHandler>,
|
||||
options?: { global?: boolean; flavour?: boolean }
|
||||
): () => void {
|
||||
const { gfx } = this;
|
||||
const selection = gfx.selection;
|
||||
|
||||
Object.keys(keymap).forEach(key => {
|
||||
if (key.length === 1 && key >= 'A' && key <= 'z') {
|
||||
const handler = keymap[key];
|
||||
|
||||
keymap[key] = ctx => {
|
||||
const elements = selection.selectedElements;
|
||||
|
||||
if (isSingleMindMapNode(elements) && !selection.editing) {
|
||||
const target = gfx.getElementById(
|
||||
elements[0].id
|
||||
) as ShapeElementModel;
|
||||
if (target.text) {
|
||||
this.doc.transact(() => {
|
||||
target.text!.delete(0, target.text!.length);
|
||||
target.text!.insert(0, key);
|
||||
});
|
||||
}
|
||||
mountShapeTextEditor(target, this);
|
||||
} else {
|
||||
handler(ctx);
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return super.bindHotKey(keymap, options);
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
this._initViewport();
|
||||
|
||||
this.keyboardManager = new EdgelessPageKeyboardManager(this);
|
||||
|
||||
this.handleEvent('selectionChange', () => {
|
||||
const surface = this.host.selection.value.find(
|
||||
(sel): sel is SurfaceSelection => sel.is(SurfaceSelection)
|
||||
);
|
||||
if (!surface) return;
|
||||
|
||||
const el = this.gfx.getElementById(surface.elements[0]);
|
||||
if (isCanvasElement(el)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return;
|
||||
});
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (this._resizeObserver) {
|
||||
this._resizeObserver.disconnect();
|
||||
this._resizeObserver = null;
|
||||
}
|
||||
|
||||
this.keyboardManager = null;
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
this._initSlotEffects();
|
||||
this._initResizeEffect();
|
||||
this._initPixelRatioChangeEffect();
|
||||
this._initFontLoader();
|
||||
this._initRemoteCursor();
|
||||
this._initLayerUpdateEffect();
|
||||
|
||||
this._initWheelEvent();
|
||||
this._initPanEvent();
|
||||
this._initPinchEvent();
|
||||
|
||||
if (this.doc.readonly) {
|
||||
this.gfx.tool.setTool('pan', { panning: true });
|
||||
} else {
|
||||
this.gfx.tool.setTool('default');
|
||||
}
|
||||
|
||||
this.gfx.viewport.elementReady.next(this.gfxViewportElm);
|
||||
|
||||
requestConnectedFrame(() => {
|
||||
this.requestUpdate();
|
||||
}, this);
|
||||
|
||||
this._disposables.add(
|
||||
this.gfx.viewport.viewportUpdated.subscribe(() => {
|
||||
this._refreshLayerViewport();
|
||||
})
|
||||
);
|
||||
|
||||
this._refreshLayerViewport();
|
||||
}
|
||||
|
||||
override renderBlock() {
|
||||
const widgets = repeat(
|
||||
Object.entries(this.widgets),
|
||||
([id]) => id,
|
||||
([_, widget]) => widget
|
||||
);
|
||||
|
||||
return html`
|
||||
<div class="edgeless-background edgeless-container">
|
||||
<gfx-viewport
|
||||
.maxConcurrentRenders=${6}
|
||||
.viewport=${this.gfx.viewport}
|
||||
.getModelsInViewport=${() => {
|
||||
const blocks = this.gfx.grid.search(
|
||||
this.gfx.viewport.viewportBounds,
|
||||
{
|
||||
useSet: true,
|
||||
filter: ['block'],
|
||||
}
|
||||
);
|
||||
|
||||
return blocks;
|
||||
}}
|
||||
.host=${this.host}
|
||||
>
|
||||
${this.renderChildren(this.model)}
|
||||
${this.renderChildren(this.surfaceBlockModel)}
|
||||
</gfx-viewport>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
Used to mount component before widgets
|
||||
Eg., canvas text editor
|
||||
-->
|
||||
<div class="edgeless-mount-point"></div>
|
||||
|
||||
<div class="widgets-container">${widgets}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@query('.edgeless-background')
|
||||
accessor backgroundElm: HTMLDivElement | null = null;
|
||||
|
||||
@query('gfx-viewport')
|
||||
accessor gfxViewportElm!: GfxViewportElement;
|
||||
|
||||
@query('.edgeless-mount-point')
|
||||
accessor mountElm: HTMLDivElement | null = null;
|
||||
|
||||
@query('affine-surface')
|
||||
accessor surface!: SurfaceBlockComponent;
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import {
|
||||
EdgelessCRUDIdentifier,
|
||||
getBgGridGap,
|
||||
type SurfaceBlockComponent,
|
||||
type SurfaceBlockModel,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import type { RootBlockModel } from '@blocksuite/affine-model';
|
||||
import {
|
||||
EditorSettingProvider,
|
||||
FontLoaderService,
|
||||
ThemeProvider,
|
||||
ViewportElementProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { requestThrottledConnectedFrame } from '@blocksuite/affine-shared/utils';
|
||||
import {
|
||||
BlockComponent,
|
||||
type GfxBlockComponent,
|
||||
SurfaceSelection,
|
||||
} from '@blocksuite/std';
|
||||
import {
|
||||
GfxControllerIdentifier,
|
||||
type GfxViewportElement,
|
||||
} from '@blocksuite/std/gfx';
|
||||
import { css, html } from 'lit';
|
||||
import { query, state } from 'lit/decorators.js';
|
||||
import { type StyleInfo, styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import type { EdgelessRootBlockWidgetName } from '../types.js';
|
||||
import type { EdgelessRootService } from './edgeless-root-service.js';
|
||||
import { isCanvasElement } from './utils/query.js';
|
||||
|
||||
export class EdgelessRootPreviewBlockComponent extends BlockComponent<
|
||||
RootBlockModel,
|
||||
EdgelessRootService,
|
||||
EdgelessRootBlockWidgetName
|
||||
> {
|
||||
static override styles = css`
|
||||
affine-edgeless-root-preview {
|
||||
pointer-events: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
affine-edgeless-root-preview .widgets-container {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
contain: size layout;
|
||||
z-index: 1;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
affine-edgeless-root-preview .edgeless-background {
|
||||
height: 100%;
|
||||
background-color: var(--affine-background-primary-color);
|
||||
background-image: radial-gradient(
|
||||
var(--affine-edgeless-grid-color) 1px,
|
||||
var(--affine-background-primary-color) 1px
|
||||
);
|
||||
}
|
||||
|
||||
@media print {
|
||||
.selected {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
private readonly _refreshLayerViewport = requestThrottledConnectedFrame(
|
||||
() => {
|
||||
const { zoom, translateX, translateY } = this.service.viewport;
|
||||
const gap = getBgGridGap(zoom);
|
||||
|
||||
this.backgroundStyle = {
|
||||
backgroundPosition: `${translateX}px ${translateY}px`,
|
||||
backgroundSize: `${gap}px ${gap}px`,
|
||||
};
|
||||
},
|
||||
this
|
||||
);
|
||||
|
||||
private _resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
get dispatcher() {
|
||||
return this.service?.uiEventDispatcher;
|
||||
}
|
||||
|
||||
private get _gfx() {
|
||||
return this.std.get(GfxControllerIdentifier);
|
||||
}
|
||||
|
||||
get surfaceBlockModel() {
|
||||
return this.model.children.find(
|
||||
child => child.flavour === 'affine:surface'
|
||||
) as SurfaceBlockModel;
|
||||
}
|
||||
|
||||
get viewportElement(): HTMLElement {
|
||||
return this.std.get(ViewportElementProvider).viewportElement;
|
||||
}
|
||||
|
||||
private _initFontLoader() {
|
||||
this.std
|
||||
.get(FontLoaderService)
|
||||
.ready.then(() => {
|
||||
this.surface?.refresh();
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
private _initLayerUpdateEffect() {
|
||||
const updateLayers = requestThrottledConnectedFrame(() => {
|
||||
const blocks = Array.from(
|
||||
this.gfxViewportElm.children as HTMLCollectionOf<GfxBlockComponent>
|
||||
);
|
||||
|
||||
blocks.forEach((block: GfxBlockComponent) => {
|
||||
block.updateZIndex?.();
|
||||
});
|
||||
});
|
||||
|
||||
this._disposables.add(
|
||||
this._gfx.layer.slots.layerUpdated.subscribe(() => updateLayers())
|
||||
);
|
||||
}
|
||||
|
||||
private _initPixelRatioChangeEffect() {
|
||||
let media: MediaQueryList;
|
||||
|
||||
const onPixelRatioChange = () => {
|
||||
if (media) {
|
||||
this._gfx.viewport.onResize();
|
||||
media.removeEventListener('change', onPixelRatioChange);
|
||||
}
|
||||
|
||||
media = matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
|
||||
media.addEventListener('change', onPixelRatioChange);
|
||||
};
|
||||
|
||||
onPixelRatioChange();
|
||||
|
||||
this._disposables.add(() => {
|
||||
media?.removeEventListener('change', onPixelRatioChange);
|
||||
});
|
||||
}
|
||||
|
||||
private _initResizeEffect() {
|
||||
const resizeObserver = new ResizeObserver((_: ResizeObserverEntry[]) => {
|
||||
this._gfx.selection.set(this._gfx.selection.surfaceSelections);
|
||||
this._gfx.viewport.onResize();
|
||||
});
|
||||
|
||||
try {
|
||||
resizeObserver.observe(this.viewportElement);
|
||||
this._resizeObserver?.disconnect();
|
||||
this._resizeObserver = resizeObserver;
|
||||
} catch {
|
||||
// viewport is not ready
|
||||
console.error('Viewport is not ready');
|
||||
}
|
||||
}
|
||||
|
||||
private _initSlotEffects() {
|
||||
this.disposables.add(
|
||||
this.std
|
||||
.get(ThemeProvider)
|
||||
.theme$.subscribe(() => this.surface?.refresh())
|
||||
);
|
||||
}
|
||||
|
||||
private get _disableScheduleUpdate() {
|
||||
const editorSetting = this.std.getOptional(EditorSettingProvider);
|
||||
|
||||
return editorSetting?.peek().edgelessDisableScheduleUpdate ?? false;
|
||||
}
|
||||
|
||||
private get _crud() {
|
||||
return this.std.get(EdgelessCRUDIdentifier);
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
this.handleEvent('selectionChange', () => {
|
||||
const surface = this.host.selection.value.find(
|
||||
(sel): sel is SurfaceSelection => sel.is(SurfaceSelection)
|
||||
);
|
||||
if (!surface) return;
|
||||
|
||||
const el = this._crud.getElementById(surface.elements[0]);
|
||||
if (isCanvasElement(el)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return;
|
||||
});
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
|
||||
if (this._resizeObserver) {
|
||||
this._resizeObserver.disconnect();
|
||||
this._resizeObserver = null;
|
||||
}
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
this._initSlotEffects();
|
||||
this._initResizeEffect();
|
||||
this._initPixelRatioChangeEffect();
|
||||
this._initFontLoader();
|
||||
this._initLayerUpdateEffect();
|
||||
|
||||
this._disposables.add(
|
||||
this._gfx.viewport.viewportUpdated.subscribe(() => {
|
||||
this._refreshLayerViewport();
|
||||
})
|
||||
);
|
||||
|
||||
this._refreshLayerViewport();
|
||||
}
|
||||
|
||||
override renderBlock() {
|
||||
const background = styleMap({
|
||||
...this.backgroundStyle,
|
||||
background: this.overrideBackground,
|
||||
});
|
||||
|
||||
return html`
|
||||
<div class="edgeless-background edgeless-container" style=${background}>
|
||||
<gfx-viewport
|
||||
.enableChildrenSchedule=${!this._disableScheduleUpdate}
|
||||
.viewport=${this._gfx.viewport}
|
||||
.getModelsInViewport=${() => {
|
||||
const blocks = this._gfx.grid.search(
|
||||
this._gfx.viewport.viewportBounds,
|
||||
{
|
||||
useSet: true,
|
||||
filter: ['block'],
|
||||
}
|
||||
);
|
||||
return blocks;
|
||||
}}
|
||||
.host=${this.host}
|
||||
>
|
||||
${this.renderChildren(this.model)}${this.renderChildren(
|
||||
this.surfaceBlockModel
|
||||
)}
|
||||
</gfx-viewport>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@state()
|
||||
accessor overrideBackground: string | undefined = undefined;
|
||||
|
||||
@state()
|
||||
accessor backgroundStyle: Readonly<StyleInfo> | null = null;
|
||||
|
||||
@query('gfx-viewport')
|
||||
accessor gfxViewportElm!: GfxViewportElement;
|
||||
|
||||
@query('affine-surface')
|
||||
accessor surface!: SurfaceBlockComponent;
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import { EdgelessFrameManagerIdentifier } from '@blocksuite/affine-block-frame';
|
||||
import {
|
||||
EdgelessCRUDIdentifier,
|
||||
EdgelessLegacySlotIdentifier,
|
||||
getSurfaceBlock,
|
||||
type SurfaceBlockModel,
|
||||
type SurfaceContext,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import { TemplateJob } from '@blocksuite/affine-gfx-template';
|
||||
import {
|
||||
type ConnectorElementModel,
|
||||
RootBlockSchema,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import type { BlockStdScope } from '@blocksuite/std';
|
||||
import type {
|
||||
GfxController,
|
||||
GfxModel,
|
||||
LayerManager,
|
||||
PointTestOptions,
|
||||
ReorderingDirection,
|
||||
} from '@blocksuite/std/gfx';
|
||||
import {
|
||||
GfxBlockElementModel,
|
||||
GfxControllerIdentifier,
|
||||
isGfxGroupCompatibleModel,
|
||||
ZOOM_MAX,
|
||||
ZOOM_MIN,
|
||||
ZOOM_STEP,
|
||||
} from '@blocksuite/std/gfx';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import clamp from 'lodash-es/clamp';
|
||||
|
||||
import { RootService } from '../root-service.js';
|
||||
import { getCursorMode } from './utils/query.js';
|
||||
|
||||
export class EdgelessRootService extends RootService implements SurfaceContext {
|
||||
static override readonly flavour = RootBlockSchema.model.flavour;
|
||||
|
||||
private readonly _surface: SurfaceBlockModel;
|
||||
|
||||
TemplateJob = TemplateJob;
|
||||
|
||||
get blocks(): GfxBlockElementModel[] {
|
||||
return this.layer.blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* sorted edgeless elements
|
||||
*/
|
||||
get edgelessElements(): GfxModel[] {
|
||||
return [...this.layer.canvasElements, ...this.layer.blocks].sort(
|
||||
this.layer.compare
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* sorted canvas elements
|
||||
*/
|
||||
get elements() {
|
||||
return this.layer.canvasElements;
|
||||
}
|
||||
|
||||
get frame() {
|
||||
return this.std.get(EdgelessFrameManagerIdentifier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all sorted frames by presentation orderer,
|
||||
* the legacy frame that uses `index` as presentation order
|
||||
* will be put at the beginning of the array.
|
||||
*/
|
||||
get frames() {
|
||||
return this.frame.frames;
|
||||
}
|
||||
|
||||
get gfx(): GfxController {
|
||||
return this.std.get(GfxControllerIdentifier);
|
||||
}
|
||||
|
||||
override get host() {
|
||||
return this.std.host;
|
||||
}
|
||||
|
||||
get layer(): LayerManager {
|
||||
return this.gfx.layer;
|
||||
}
|
||||
|
||||
get locked() {
|
||||
return this.viewport.locked;
|
||||
}
|
||||
|
||||
set locked(locked: boolean) {
|
||||
this.viewport.locked = locked;
|
||||
}
|
||||
|
||||
get selection() {
|
||||
return this.gfx.selection;
|
||||
}
|
||||
|
||||
get surface() {
|
||||
return this._surface;
|
||||
}
|
||||
|
||||
get viewport() {
|
||||
return this.std.get(GfxControllerIdentifier).viewport;
|
||||
}
|
||||
|
||||
get zoom() {
|
||||
return this.viewport.zoom;
|
||||
}
|
||||
|
||||
get crud() {
|
||||
return this.std.get(EdgelessCRUDIdentifier);
|
||||
}
|
||||
|
||||
constructor(std: BlockStdScope, flavourProvider: { flavour: string }) {
|
||||
super(std, flavourProvider);
|
||||
const surface = getSurfaceBlock(this.doc);
|
||||
if (!surface) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.NoSurfaceModelError,
|
||||
'This doc is missing surface block in edgeless.'
|
||||
);
|
||||
}
|
||||
this._surface = surface;
|
||||
}
|
||||
|
||||
private _initReadonlyListener() {
|
||||
const doc = this.doc;
|
||||
|
||||
const slots = this.std.get(EdgelessLegacySlotIdentifier);
|
||||
|
||||
let readonly = doc.readonly;
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
if (readonly !== doc.readonly) {
|
||||
readonly = doc.readonly;
|
||||
slots.readonlyUpdated.next(readonly);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private _initSlotEffects() {
|
||||
const { disposables } = this;
|
||||
|
||||
disposables.add(
|
||||
effect(() => {
|
||||
const value = this.gfx.tool.currentToolOption$.value;
|
||||
this.gfx.cursor$.value = getCursorMode(value);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
generateIndex() {
|
||||
return this.layer.generateIndex();
|
||||
}
|
||||
|
||||
getConnectors(element: GfxModel | string) {
|
||||
const id = typeof element === 'string' ? element : element.id;
|
||||
|
||||
return this.surface.getConnectors(id) as ConnectorElementModel[];
|
||||
}
|
||||
|
||||
override mounted() {
|
||||
super.mounted();
|
||||
this._initSlotEffects();
|
||||
this._initReadonlyListener();
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is used to pick element in group, if the picked element is in a
|
||||
* group, we will pick the group instead. If that picked group is currently selected, then
|
||||
* we will pick the element itself.
|
||||
*/
|
||||
pickElementInGroup(
|
||||
x: number,
|
||||
y: number,
|
||||
options?: PointTestOptions
|
||||
): GfxModel | null {
|
||||
return this.gfx.getElementInGroup(x, y, options);
|
||||
}
|
||||
|
||||
removeElement(id: string | GfxModel) {
|
||||
id = typeof id === 'string' ? id : id.id;
|
||||
|
||||
const el = this.crud.getElementById(id);
|
||||
if (isGfxGroupCompatibleModel(el)) {
|
||||
el.childIds.forEach(childId => {
|
||||
this.removeElement(childId);
|
||||
});
|
||||
}
|
||||
|
||||
if (el instanceof GfxBlockElementModel) {
|
||||
this.doc.deleteBlock(el);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._surface.hasElementById(id)) {
|
||||
this._surface.deleteElement(id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
reorderElement(element: GfxModel, direction: ReorderingDirection) {
|
||||
const index = this.layer.getReorderedIndex(element, direction);
|
||||
|
||||
// block should be updated in transaction
|
||||
if (element instanceof GfxBlockElementModel) {
|
||||
this.doc.transact(() => {
|
||||
element.index = index;
|
||||
});
|
||||
} else {
|
||||
element.index = index;
|
||||
}
|
||||
}
|
||||
|
||||
setZoomByAction(action: 'fit' | 'out' | 'reset' | 'in') {
|
||||
if (this.locked) return;
|
||||
|
||||
switch (action) {
|
||||
case 'fit':
|
||||
this.gfx.fitToScreen();
|
||||
break;
|
||||
case 'reset':
|
||||
this.viewport.smoothZoom(1.0);
|
||||
break;
|
||||
case 'in':
|
||||
case 'out':
|
||||
this.setZoomByStep(ZOOM_STEP * (action === 'in' ? 1 : -1));
|
||||
}
|
||||
}
|
||||
|
||||
setZoomByStep(step: number) {
|
||||
this.viewport.smoothZoom(clamp(this.zoom + step, ZOOM_MIN, ZOOM_MAX));
|
||||
}
|
||||
|
||||
override unmounted() {
|
||||
super.unmounted();
|
||||
|
||||
this.viewport?.dispose();
|
||||
this.selectionManager.set([]);
|
||||
this.disposables.dispose();
|
||||
}
|
||||
}
|
||||
117
blocksuite/affine/blocks/root/src/edgeless/edgeless-root-spec.ts
Normal file
117
blocksuite/affine/blocks/root/src/edgeless/edgeless-root-spec.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { EdgelessClipboardAttachmentConfig } from '@blocksuite/affine-block-attachment';
|
||||
import { EdgelessClipboardBookmarkConfig } from '@blocksuite/affine-block-bookmark';
|
||||
import { EdgelessClipboardEdgelessTextConfig } from '@blocksuite/affine-block-edgeless-text';
|
||||
import {
|
||||
EdgelessClipboardEmbedFigmaConfig,
|
||||
EdgelessClipboardEmbedGithubConfig,
|
||||
EdgelessClipboardEmbedHtmlConfig,
|
||||
EdgelessClipboardEmbedIframeConfig,
|
||||
EdgelessClipboardEmbedLinkedDocConfig,
|
||||
EdgelessClipboardEmbedLoomConfig,
|
||||
EdgelessClipboardEmbedSyncedDocConfig,
|
||||
EdgelessClipboardEmbedYoutubeConfig,
|
||||
} from '@blocksuite/affine-block-embed';
|
||||
import { EdgelessClipboardFrameConfig } from '@blocksuite/affine-block-frame';
|
||||
import { EdgelessClipboardImageConfig } from '@blocksuite/affine-block-image';
|
||||
import { EdgelessClipboardNoteConfig } from '@blocksuite/affine-block-note';
|
||||
import { ViewportElementExtension } from '@blocksuite/affine-shared/services';
|
||||
import { autoConnectWidget } from '@blocksuite/affine-widget-edgeless-auto-connect';
|
||||
import { edgelessToolbarWidget } from '@blocksuite/affine-widget-edgeless-toolbar';
|
||||
import { frameTitleWidget } from '@blocksuite/affine-widget-frame-title';
|
||||
import { edgelessRemoteSelectionWidget } from '@blocksuite/affine-widget-remote-selection';
|
||||
import {
|
||||
BlockViewExtension,
|
||||
LifeCycleWatcher,
|
||||
WidgetViewExtension,
|
||||
} from '@blocksuite/std';
|
||||
import { GfxControllerIdentifier, ToolController } from '@blocksuite/std/gfx';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
import { literal, unsafeStatic } from 'lit/static-html.js';
|
||||
|
||||
import { CommonSpecs } from '../common-specs/index.js';
|
||||
import { edgelessNavigatorBgWidget } from '../widgets/edgeless-navigator-bg/index.js';
|
||||
import { AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET } from '../widgets/edgeless-zoom-toolbar/index.js';
|
||||
import { EdgelessClipboardController } from './clipboard/clipboard.js';
|
||||
import { NOTE_SLICER_WIDGET } from './components/note-slicer/index.js';
|
||||
import { EDGELESS_DRAGGING_AREA_WIDGET } from './components/rects/edgeless-dragging-area-rect.js';
|
||||
import { EDGELESS_SELECTED_RECT_WIDGET } from './components/rects/edgeless-selected-rect.js';
|
||||
import { quickTools, seniorTools } from './components/toolbar/tools.js';
|
||||
import { EdgelessRootService } from './edgeless-root-service.js';
|
||||
|
||||
export const edgelessZoomToolbarWidget = WidgetViewExtension(
|
||||
'affine:page',
|
||||
AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET,
|
||||
literal`${unsafeStatic(AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET)}`
|
||||
);
|
||||
export const edgelessDraggingAreaWidget = WidgetViewExtension(
|
||||
'affine:page',
|
||||
EDGELESS_DRAGGING_AREA_WIDGET,
|
||||
literal`${unsafeStatic(EDGELESS_DRAGGING_AREA_WIDGET)}`
|
||||
);
|
||||
export const noteSlicerWidget = WidgetViewExtension(
|
||||
'affine:page',
|
||||
NOTE_SLICER_WIDGET,
|
||||
literal`${unsafeStatic(NOTE_SLICER_WIDGET)}`
|
||||
);
|
||||
export const edgelessSelectedRectWidget = WidgetViewExtension(
|
||||
'affine:page',
|
||||
EDGELESS_SELECTED_RECT_WIDGET,
|
||||
literal`${unsafeStatic(EDGELESS_SELECTED_RECT_WIDGET)}`
|
||||
);
|
||||
|
||||
class EdgelessLocker extends LifeCycleWatcher {
|
||||
static override key = 'edgeless-locker';
|
||||
|
||||
override mounted() {
|
||||
const { viewport } = this.std.get(GfxControllerIdentifier);
|
||||
viewport.locked = true;
|
||||
}
|
||||
}
|
||||
|
||||
const EdgelessClipboardConfigs: ExtensionType[] = [
|
||||
EdgelessClipboardNoteConfig,
|
||||
EdgelessClipboardEdgelessTextConfig,
|
||||
EdgelessClipboardImageConfig,
|
||||
EdgelessClipboardFrameConfig,
|
||||
EdgelessClipboardAttachmentConfig,
|
||||
EdgelessClipboardBookmarkConfig,
|
||||
EdgelessClipboardEmbedFigmaConfig,
|
||||
EdgelessClipboardEmbedGithubConfig,
|
||||
EdgelessClipboardEmbedHtmlConfig,
|
||||
EdgelessClipboardEmbedLoomConfig,
|
||||
EdgelessClipboardEmbedYoutubeConfig,
|
||||
EdgelessClipboardEmbedIframeConfig,
|
||||
EdgelessClipboardEmbedLinkedDocConfig,
|
||||
EdgelessClipboardEmbedSyncedDocConfig,
|
||||
];
|
||||
|
||||
const EdgelessCommonExtension: ExtensionType[] = [
|
||||
CommonSpecs,
|
||||
ToolController,
|
||||
EdgelessRootService,
|
||||
ViewportElementExtension('.affine-edgeless-viewport'),
|
||||
...quickTools,
|
||||
...seniorTools,
|
||||
...EdgelessClipboardConfigs,
|
||||
].flat();
|
||||
|
||||
export const EdgelessRootBlockSpec: ExtensionType[] = [
|
||||
...EdgelessCommonExtension,
|
||||
BlockViewExtension('affine:page', literal`affine-edgeless-root`),
|
||||
edgelessRemoteSelectionWidget,
|
||||
edgelessZoomToolbarWidget,
|
||||
frameTitleWidget,
|
||||
autoConnectWidget,
|
||||
edgelessDraggingAreaWidget,
|
||||
noteSlicerWidget,
|
||||
edgelessNavigatorBgWidget,
|
||||
edgelessSelectedRectWidget,
|
||||
edgelessToolbarWidget,
|
||||
EdgelessClipboardController,
|
||||
];
|
||||
|
||||
export const PreviewEdgelessRootBlockSpec: ExtensionType[] = [
|
||||
...EdgelessCommonExtension,
|
||||
BlockViewExtension('affine:page', literal`affine-edgeless-root-preview`),
|
||||
EdgelessLocker,
|
||||
];
|
||||
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
addText,
|
||||
insertEdgelessTextCommand,
|
||||
} from '@blocksuite/affine-gfx-text';
|
||||
import {
|
||||
FeatureFlagService,
|
||||
TelemetryProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import type { PointerEventState } from '@blocksuite/std';
|
||||
import { TransformExtension } from '@blocksuite/std/gfx';
|
||||
|
||||
export class DblClickAddEdgelessText extends TransformExtension {
|
||||
static override key = 'dbl-click-add-edgeless-text';
|
||||
|
||||
override dblClick(e: PointerEventState): void {
|
||||
const textFlag = this.std.store
|
||||
.get(FeatureFlagService)
|
||||
.getFlag('enable_edgeless_text');
|
||||
const picked = this.gfx.getElementByPoint(
|
||||
...this.gfx.viewport.toModelCoord(e.x, e.y)
|
||||
);
|
||||
|
||||
if (picked) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (textFlag) {
|
||||
const [x, y] = this.gfx.viewport.toModelCoord(e.x, e.y);
|
||||
this.std.command.exec(insertEdgelessTextCommand, { x, y });
|
||||
} else {
|
||||
const edgelessView = this.std.view.getBlock(
|
||||
this.std.store.root?.id || ''
|
||||
);
|
||||
|
||||
if (edgelessView) {
|
||||
addText(edgelessView, e);
|
||||
}
|
||||
}
|
||||
|
||||
this.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', {
|
||||
control: 'canvas:dbclick',
|
||||
page: 'whiteboard editor',
|
||||
module: 'toolbar',
|
||||
segment: 'toolbar',
|
||||
type: 'text',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { OverlayIdentifier } from '@blocksuite/affine-block-surface';
|
||||
import { MindmapElementModel } from '@blocksuite/affine-model';
|
||||
import type { Bound } from '@blocksuite/global/gfx';
|
||||
import {
|
||||
type DragExtensionInitializeContext,
|
||||
type ExtensionDragMoveContext,
|
||||
type GfxModel,
|
||||
TransformExtension,
|
||||
} from '@blocksuite/std/gfx';
|
||||
|
||||
import type { SnapOverlay } from '../utils/snap-manager';
|
||||
|
||||
export class SnapExtension extends TransformExtension {
|
||||
static override key = 'snap-manager';
|
||||
|
||||
get snapOverlay() {
|
||||
return this.std.getOptional(
|
||||
OverlayIdentifier('snap-manager')
|
||||
) as SnapOverlay;
|
||||
}
|
||||
|
||||
override onDragInitialize(initContext: DragExtensionInitializeContext) {
|
||||
const snapOverlay = this.snapOverlay;
|
||||
|
||||
if (!snapOverlay) {
|
||||
return {};
|
||||
}
|
||||
|
||||
let alignBound: Bound;
|
||||
|
||||
return {
|
||||
onDragStart() {
|
||||
alignBound = snapOverlay.setMovingElements(
|
||||
initContext.elements,
|
||||
initContext.elements.reduce((pre, elem) => {
|
||||
if (elem.group instanceof MindmapElementModel) {
|
||||
pre.push(elem.group);
|
||||
}
|
||||
|
||||
return pre;
|
||||
}, [] as GfxModel[])
|
||||
);
|
||||
},
|
||||
onDragMove(context: ExtensionDragMoveContext) {
|
||||
if (
|
||||
context.elements.length === 0 ||
|
||||
alignBound.w === 0 ||
|
||||
alignBound.h === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentBound = alignBound.moveDelta(context.dx, context.dy);
|
||||
const alignRst = snapOverlay.align(currentBound);
|
||||
|
||||
context.dx = alignRst.dx + context.dx;
|
||||
context.dy = alignRst.dy + context.dy;
|
||||
},
|
||||
onDragEnd() {
|
||||
snapOverlay.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export enum DefaultModeDragType {
|
||||
/** Moving connector label */
|
||||
ConnectorLabelMoving = 'connector-label-moving',
|
||||
/** Moving selected contents */
|
||||
ContentMoving = 'content-moving',
|
||||
/** Native range dragging inside active note block */
|
||||
NativeEditing = 'native-editing',
|
||||
/** Default void state */
|
||||
None = 'none',
|
||||
/** Dragging preview */
|
||||
PreviewDragging = 'preview-dragging',
|
||||
/** Expanding the dragging area, select the content covered inside */
|
||||
Selecting = 'selecting',
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
import {
|
||||
type FrameOverlay,
|
||||
isFrameBlock,
|
||||
} from '@blocksuite/affine-block-frame';
|
||||
import {
|
||||
ConnectorUtils,
|
||||
OverlayIdentifier,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
type ConnectorElementModel,
|
||||
GroupElementModel,
|
||||
MindmapElementModel,
|
||||
NoteBlockModel,
|
||||
NoteDisplayMode,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { resetNativeSelection } from '@blocksuite/affine-shared/utils';
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import type { IVec } from '@blocksuite/global/gfx';
|
||||
import { Bound, getCommonBoundWithRotation, Vec } from '@blocksuite/global/gfx';
|
||||
import type { BlockComponent, PointerEventState } from '@blocksuite/std';
|
||||
import {
|
||||
BaseTool,
|
||||
getTopElements,
|
||||
type GfxModel,
|
||||
isGfxGroupCompatibleModel,
|
||||
type PointTestOptions,
|
||||
TransformManagerIdentifier,
|
||||
} from '@blocksuite/std/gfx';
|
||||
import { effect } from '@preact/signals-core';
|
||||
|
||||
import { createElementsFromClipboardDataCommand } from '../clipboard/command.js';
|
||||
import { prepareCloneData } from '../utils/clone-utils.js';
|
||||
import { calPanDelta } from '../utils/panning-utils.js';
|
||||
import { isCanvasElement } from '../utils/query.js';
|
||||
import { DefaultModeDragType } from './default-tool-ext/ext.js';
|
||||
|
||||
export class DefaultTool extends BaseTool {
|
||||
static override toolName: string = 'default';
|
||||
|
||||
private _accumulateDelta: IVec = [0, 0];
|
||||
|
||||
private _autoPanTimer: number | null = null;
|
||||
|
||||
private readonly _clearDisposable = () => {
|
||||
if (this._disposables) {
|
||||
this._disposables.dispose();
|
||||
this._disposables = null;
|
||||
}
|
||||
};
|
||||
|
||||
private readonly _clearSelectingState = () => {
|
||||
this._stopAutoPanning();
|
||||
this._clearDisposable();
|
||||
};
|
||||
|
||||
private _disposables: DisposableGroup | null = null;
|
||||
|
||||
private readonly _panViewport = (delta: IVec) => {
|
||||
this._accumulateDelta[0] += delta[0];
|
||||
this._accumulateDelta[1] += delta[1];
|
||||
this.gfx.viewport.applyDeltaCenter(delta[0], delta[1]);
|
||||
};
|
||||
|
||||
// For moving the connector label
|
||||
private _selectedConnector: ConnectorElementModel | null = null;
|
||||
|
||||
private _selectedConnectorLabelBounds: Bound | null = null;
|
||||
|
||||
private _selectionRectTransition: null | {
|
||||
w: number;
|
||||
h: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
endX: number;
|
||||
endY: number;
|
||||
} = null;
|
||||
|
||||
private readonly _startAutoPanning = (delta: IVec) => {
|
||||
this._panViewport(delta);
|
||||
this._updateSelectingState(delta);
|
||||
this._stopAutoPanning();
|
||||
|
||||
this._autoPanTimer = window.setInterval(() => {
|
||||
this._panViewport(delta);
|
||||
this._updateSelectingState(delta);
|
||||
}, 30);
|
||||
};
|
||||
|
||||
private readonly _stopAutoPanning = () => {
|
||||
if (this._autoPanTimer) {
|
||||
clearTimeout(this._autoPanTimer);
|
||||
this._autoPanTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
private _toBeMoved: GfxModel[] = [];
|
||||
|
||||
private readonly _updateSelectingState = (delta: IVec = [0, 0]) => {
|
||||
const { gfx } = this;
|
||||
|
||||
if (gfx.keyboard.spaceKey$.peek() && this._selectionRectTransition) {
|
||||
/* Move the selection if space is pressed */
|
||||
const curDraggingViewArea = this.controller.draggingViewArea$.peek();
|
||||
const { w, h, startX, startY, endX, endY } =
|
||||
this._selectionRectTransition;
|
||||
const { endX: lastX, endY: lastY } = curDraggingViewArea;
|
||||
|
||||
const dx = lastX + delta[0] - endX + this._accumulateDelta[0];
|
||||
const dy = lastY + delta[1] - endY + this._accumulateDelta[1];
|
||||
|
||||
this.controller.draggingViewArea$.value = {
|
||||
...curDraggingViewArea,
|
||||
x: Math.min(startX + dx, lastX),
|
||||
y: Math.min(startY + dy, lastY),
|
||||
w,
|
||||
h,
|
||||
startX: startX + dx,
|
||||
startY: startY + dy,
|
||||
};
|
||||
} else {
|
||||
const curDraggingArea = this.controller.draggingViewArea$.peek();
|
||||
const newStartX = curDraggingArea.startX - delta[0];
|
||||
const newStartY = curDraggingArea.startY - delta[1];
|
||||
|
||||
this.controller.draggingViewArea$.value = {
|
||||
...curDraggingArea,
|
||||
startX: newStartX,
|
||||
startY: newStartY,
|
||||
x: Math.min(newStartX, curDraggingArea.endX),
|
||||
y: Math.min(newStartY, curDraggingArea.endY),
|
||||
w: Math.abs(curDraggingArea.endX - newStartX),
|
||||
h: Math.abs(curDraggingArea.endY - newStartY),
|
||||
};
|
||||
}
|
||||
|
||||
const { x, y, w, h } = this.controller.draggingArea$.peek();
|
||||
const bound = new Bound(x, y, w, h);
|
||||
|
||||
let elements = gfx.getElementsByBound(bound).filter(el => {
|
||||
if (isFrameBlock(el)) {
|
||||
return el.childElements.length === 0 || bound.contains(el.elementBound);
|
||||
}
|
||||
if (el instanceof MindmapElementModel) {
|
||||
return bound.contains(el.elementBound);
|
||||
}
|
||||
if (
|
||||
el instanceof NoteBlockModel &&
|
||||
el.props.displayMode === NoteDisplayMode.DocOnly
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
elements = getTopElements(elements).filter(el => !el.isLocked());
|
||||
|
||||
const set = new Set(
|
||||
gfx.keyboard.shiftKey$.peek()
|
||||
? [...elements, ...gfx.selection.selectedElements]
|
||||
: elements
|
||||
);
|
||||
|
||||
this.edgelessSelectionManager.set({
|
||||
elements: Array.from(set).map(element => element.id),
|
||||
editing: false,
|
||||
});
|
||||
};
|
||||
|
||||
dragType = DefaultModeDragType.None;
|
||||
|
||||
enableHover = true;
|
||||
|
||||
private get _edgeless(): BlockComponent | null {
|
||||
return this.std.view.getBlock(this.doc.root!.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the end position of the dragging area in the model coordinate
|
||||
*/
|
||||
get dragLastPos() {
|
||||
const { endX, endY } = this.controller.draggingArea$.peek();
|
||||
|
||||
return [endX, endY] as IVec;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the start position of the dragging area in the model coordinate
|
||||
*/
|
||||
get dragStartPos() {
|
||||
const { startX, startY } = this.controller.draggingArea$.peek();
|
||||
|
||||
return [startX, startY] as IVec;
|
||||
}
|
||||
|
||||
get edgelessSelectionManager() {
|
||||
return this.gfx.selection;
|
||||
}
|
||||
|
||||
get elementTransformMgr() {
|
||||
return this.std.getOptional(TransformManagerIdentifier);
|
||||
}
|
||||
|
||||
private get frameOverlay() {
|
||||
return this.std.get(OverlayIdentifier('frame')) as FrameOverlay;
|
||||
}
|
||||
|
||||
private async _cloneContent() {
|
||||
if (!this._edgeless) return;
|
||||
|
||||
const snapshot = prepareCloneData(this._toBeMoved, this.std);
|
||||
|
||||
const bound = getCommonBoundWithRotation(this._toBeMoved);
|
||||
const [_, { createdElementsPromise }] = this.std.command.exec(
|
||||
createElementsFromClipboardDataCommand,
|
||||
{
|
||||
elementsRawData: snapshot,
|
||||
pasteCenter: bound.center,
|
||||
}
|
||||
);
|
||||
if (!createdElementsPromise) return;
|
||||
const { canvasElements, blockModels } = await createdElementsPromise;
|
||||
|
||||
this._toBeMoved = [...canvasElements, ...blockModels];
|
||||
this.edgelessSelectionManager.set({
|
||||
elements: this._toBeMoved.map(e => e.id),
|
||||
editing: false,
|
||||
});
|
||||
}
|
||||
|
||||
private _determineDragType(e: PointerEventState): DefaultModeDragType {
|
||||
const { x, y } = e;
|
||||
// Is dragging started from current selected rect
|
||||
if (this.edgelessSelectionManager.isInSelectedRect(x, y)) {
|
||||
if (this.edgelessSelectionManager.selectedElements.length === 1) {
|
||||
let selected = this.edgelessSelectionManager.selectedElements[0];
|
||||
// double check
|
||||
const currentSelected = this._pick(x, y);
|
||||
if (
|
||||
!isFrameBlock(selected) &&
|
||||
!(selected instanceof GroupElementModel) &&
|
||||
currentSelected &&
|
||||
currentSelected !== selected
|
||||
) {
|
||||
selected = currentSelected;
|
||||
this.edgelessSelectionManager.set({
|
||||
elements: [selected.id],
|
||||
editing: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
isCanvasElement(selected) &&
|
||||
ConnectorUtils.isConnectorWithLabel(selected) &&
|
||||
(selected as ConnectorElementModel).labelIncludesPoint(
|
||||
this.gfx.viewport.toModelCoord(x, y)
|
||||
)
|
||||
) {
|
||||
this._selectedConnector = selected as ConnectorElementModel;
|
||||
this._selectedConnectorLabelBounds = Bound.fromXYWH(
|
||||
this._selectedConnector.labelXYWH!
|
||||
);
|
||||
return DefaultModeDragType.ConnectorLabelMoving;
|
||||
}
|
||||
}
|
||||
|
||||
return this.edgelessSelectionManager.editing
|
||||
? DefaultModeDragType.NativeEditing
|
||||
: DefaultModeDragType.ContentMoving;
|
||||
} else {
|
||||
const selected = this._pick(x, y);
|
||||
if (selected) {
|
||||
this.edgelessSelectionManager.set({
|
||||
elements: [selected.id],
|
||||
editing: false,
|
||||
});
|
||||
|
||||
if (
|
||||
isCanvasElement(selected) &&
|
||||
ConnectorUtils.isConnectorWithLabel(selected) &&
|
||||
(selected as ConnectorElementModel).labelIncludesPoint(
|
||||
this.gfx.viewport.toModelCoord(x, y)
|
||||
)
|
||||
) {
|
||||
this._selectedConnector = selected as ConnectorElementModel;
|
||||
this._selectedConnectorLabelBounds = Bound.fromXYWH(
|
||||
this._selectedConnector.labelXYWH!
|
||||
);
|
||||
return DefaultModeDragType.ConnectorLabelMoving;
|
||||
}
|
||||
|
||||
return DefaultModeDragType.ContentMoving;
|
||||
} else {
|
||||
return DefaultModeDragType.Selecting;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _moveLabel(delta: IVec) {
|
||||
const connector = this._selectedConnector;
|
||||
let bounds = this._selectedConnectorLabelBounds;
|
||||
if (!connector || !bounds) return;
|
||||
bounds = bounds.clone();
|
||||
const center = connector.getNearestPoint(
|
||||
Vec.add(bounds.center, delta) as IVec
|
||||
);
|
||||
const distance = connector.getOffsetDistanceByPoint(center as IVec);
|
||||
bounds.center = center;
|
||||
this.gfx.updateElement(connector, {
|
||||
labelXYWH: bounds.toXYWH(),
|
||||
labelOffset: {
|
||||
distance,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _pick(x: number, y: number, options?: PointTestOptions) {
|
||||
const modelPos = this.gfx.viewport.toModelCoord(x, y);
|
||||
|
||||
const tryGetLockedAncestor = (e: GfxModel | null) => {
|
||||
if (e?.isLockedByAncestor()) {
|
||||
return e.groups.findLast(group => group.isLocked());
|
||||
}
|
||||
return e;
|
||||
};
|
||||
|
||||
const result = this.gfx.getElementInGroup(
|
||||
modelPos[0],
|
||||
modelPos[1],
|
||||
options
|
||||
);
|
||||
|
||||
if (result instanceof MindmapElementModel) {
|
||||
const picked = this.gfx.getElementByPoint(modelPos[0], modelPos[1], {
|
||||
...((options ?? {}) as PointTestOptions),
|
||||
all: true,
|
||||
});
|
||||
|
||||
let pickedIdx = picked.length - 1;
|
||||
|
||||
while (pickedIdx >= 0) {
|
||||
const element = picked[pickedIdx];
|
||||
if (element === result) {
|
||||
pickedIdx -= 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return tryGetLockedAncestor(picked[pickedIdx]) ?? null;
|
||||
}
|
||||
|
||||
return tryGetLockedAncestor(result);
|
||||
}
|
||||
|
||||
private initializeDragState(
|
||||
dragType: DefaultModeDragType,
|
||||
event: PointerEventState
|
||||
) {
|
||||
this.dragType = dragType;
|
||||
|
||||
this._clearDisposable();
|
||||
this._disposables = new DisposableGroup();
|
||||
|
||||
// If the drag type is selecting, set up the dragging area disposable group
|
||||
// If the viewport updates when dragging, should update the dragging area and selection
|
||||
if (this.dragType === DefaultModeDragType.Selecting) {
|
||||
this._disposables.add(
|
||||
this.gfx.viewport.viewportUpdated.subscribe(() => {
|
||||
if (
|
||||
this.dragType === DefaultModeDragType.Selecting &&
|
||||
this.controller.dragging$.peek() &&
|
||||
!this._autoPanTimer
|
||||
) {
|
||||
this._updateSelectingState();
|
||||
}
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.dragType === DefaultModeDragType.ContentMoving) {
|
||||
if (this.elementTransformMgr) {
|
||||
this.doc.captureSync();
|
||||
this.elementTransformMgr.initializeDrag({
|
||||
movingElements: this._toBeMoved,
|
||||
event: event.raw,
|
||||
onDragEnd: () => {
|
||||
this.doc.captureSync();
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
override click(e: PointerEventState) {
|
||||
if (this.doc.readonly) return;
|
||||
|
||||
if (!this.elementTransformMgr?.dispatchOnSelected(e)) {
|
||||
this.edgelessSelectionManager.clear();
|
||||
resetNativeSelection(null);
|
||||
}
|
||||
|
||||
this.elementTransformMgr?.dispatch('click', e);
|
||||
}
|
||||
|
||||
override deactivate() {
|
||||
this._stopAutoPanning();
|
||||
this._clearDisposable();
|
||||
this._accumulateDelta = [0, 0];
|
||||
}
|
||||
|
||||
override doubleClick(e: PointerEventState) {
|
||||
if (this.doc.readonly) {
|
||||
const viewport = this.gfx.viewport;
|
||||
if (viewport.zoom === 1) {
|
||||
this.gfx.fitToScreen();
|
||||
} else {
|
||||
// Zoom to 100% and Center
|
||||
const [x, y] = viewport.toModelCoord(e.x, e.y);
|
||||
viewport.setViewport(1, [x, y], true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.elementTransformMgr?.dispatch('dblclick', e);
|
||||
}
|
||||
|
||||
override dragEnd() {
|
||||
if (this.edgelessSelectionManager.editing) return;
|
||||
|
||||
this.frameOverlay.clear();
|
||||
this._toBeMoved = [];
|
||||
this._selectedConnector = null;
|
||||
this._selectedConnectorLabelBounds = null;
|
||||
this._clearSelectingState();
|
||||
this.dragType = DefaultModeDragType.None;
|
||||
}
|
||||
|
||||
override dragMove(e: PointerEventState) {
|
||||
const { viewport } = this.gfx;
|
||||
switch (this.dragType) {
|
||||
case DefaultModeDragType.Selecting: {
|
||||
// Record the last drag pointer position for auto panning and view port updating
|
||||
|
||||
this._updateSelectingState();
|
||||
const moveDelta = calPanDelta(viewport, e);
|
||||
if (moveDelta) {
|
||||
this._startAutoPanning(moveDelta);
|
||||
} else {
|
||||
this._stopAutoPanning();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case DefaultModeDragType.ContentMoving: {
|
||||
break;
|
||||
}
|
||||
case DefaultModeDragType.ConnectorLabelMoving: {
|
||||
const dx = this.dragLastPos[0] - this.dragStartPos[0];
|
||||
const dy = this.dragLastPos[1] - this.dragStartPos[1];
|
||||
this._moveLabel([dx, dy]);
|
||||
break;
|
||||
}
|
||||
case DefaultModeDragType.NativeEditing: {
|
||||
// TODO reset if drag out of note
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
override async dragStart(e: PointerEventState) {
|
||||
if (this.edgelessSelectionManager.editing) return;
|
||||
// Determine the drag type based on the current state and event
|
||||
let dragType = this._determineDragType(e);
|
||||
|
||||
const elements = this.edgelessSelectionManager.selectedElements;
|
||||
if (elements.some(e => e.isLocked())) return;
|
||||
|
||||
const toBeMoved = new Set(elements);
|
||||
|
||||
elements.forEach(element => {
|
||||
if (isGfxGroupCompatibleModel(element)) {
|
||||
element.descendantElements.forEach(ele => {
|
||||
toBeMoved.add(ele);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this._toBeMoved = Array.from(toBeMoved);
|
||||
|
||||
// If alt key is pressed and content is moving, clone the content
|
||||
if (dragType === DefaultModeDragType.ContentMoving && e.keys.alt) {
|
||||
await this._cloneContent();
|
||||
}
|
||||
|
||||
// Set up drag state
|
||||
this.initializeDragState(dragType, e);
|
||||
}
|
||||
|
||||
override mounted() {
|
||||
this.disposable.add(
|
||||
effect(() => {
|
||||
const pressed = this.gfx.keyboard.spaceKey$.value;
|
||||
|
||||
if (pressed) {
|
||||
const currentDraggingArea = this.controller.draggingViewArea$.peek();
|
||||
|
||||
this._selectionRectTransition = {
|
||||
w: currentDraggingArea.w,
|
||||
h: currentDraggingArea.h,
|
||||
startX: currentDraggingArea.startX,
|
||||
startY: currentDraggingArea.startY,
|
||||
endX: currentDraggingArea.endX,
|
||||
endY: currentDraggingArea.endY,
|
||||
};
|
||||
} else {
|
||||
this._selectionRectTransition = null;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override pointerDown(e: PointerEventState): void {
|
||||
this.elementTransformMgr?.dispatch('pointerdown', e);
|
||||
}
|
||||
|
||||
override pointerMove(e: PointerEventState) {
|
||||
const hovered = this._pick(e.x, e.y, {
|
||||
hitThreshold: 10,
|
||||
});
|
||||
|
||||
if (
|
||||
isFrameBlock(hovered) &&
|
||||
hovered.externalBound?.isPointInBound(
|
||||
this.gfx.viewport.toModelCoord(e.x, e.y)
|
||||
)
|
||||
) {
|
||||
this.frameOverlay.highlight(hovered);
|
||||
} else {
|
||||
this.frameOverlay.clear();
|
||||
}
|
||||
|
||||
this.elementTransformMgr?.dispatch('pointermove', e);
|
||||
}
|
||||
|
||||
override pointerUp(e: PointerEventState) {
|
||||
this.elementTransformMgr?.dispatch('pointerup', e);
|
||||
}
|
||||
|
||||
override tripleClick() {}
|
||||
|
||||
override unmounted(): void {}
|
||||
}
|
||||
|
||||
declare module '@blocksuite/std/gfx' {
|
||||
interface GfxToolsMap {
|
||||
default: DefaultTool;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { BaseTool } from '@blocksuite/std/gfx';
|
||||
|
||||
/**
|
||||
* Empty tool that does nothing.
|
||||
*/
|
||||
export class EmptyTool extends BaseTool {
|
||||
static override toolName: string = 'empty';
|
||||
}
|
||||
|
||||
declare module '@blocksuite/std/gfx' {
|
||||
interface GfxToolsMap {
|
||||
empty: EmptyTool;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { DefaultTool } from './default-tool.js';
|
||||
export { EmptyTool } from './empty-tool.js';
|
||||
export { PanTool, type PanToolOption } from './pan-tool.js';
|
||||
export { TemplateTool } from './template-tool.js';
|
||||
@@ -0,0 +1,87 @@
|
||||
import { on } from '@blocksuite/affine-shared/utils';
|
||||
import type { PointerEventState } from '@blocksuite/std';
|
||||
import { BaseTool, MouseButton } from '@blocksuite/std/gfx';
|
||||
import { Signal } from '@preact/signals-core';
|
||||
|
||||
export type PanToolOption = {
|
||||
panning: boolean;
|
||||
};
|
||||
|
||||
export class PanTool extends BaseTool<PanToolOption> {
|
||||
static override toolName = 'pan';
|
||||
|
||||
private _lastPoint: [number, number] | null = null;
|
||||
|
||||
readonly panning$ = new Signal<boolean>(false);
|
||||
|
||||
override get allowDragWithRightButton(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
override dragEnd(_: PointerEventState): void {
|
||||
this._lastPoint = null;
|
||||
this.panning$.value = false;
|
||||
}
|
||||
|
||||
override dragMove(e: PointerEventState): void {
|
||||
if (!this._lastPoint) return;
|
||||
|
||||
const { viewport } = this.gfx;
|
||||
const { zoom } = viewport;
|
||||
|
||||
const [lastX, lastY] = this._lastPoint;
|
||||
const deltaX = lastX - e.x;
|
||||
const deltaY = lastY - e.y;
|
||||
|
||||
this._lastPoint = [e.x, e.y];
|
||||
|
||||
viewport.applyDeltaCenter(deltaX / zoom, deltaY / zoom);
|
||||
}
|
||||
|
||||
override dragStart(e: PointerEventState): void {
|
||||
this._lastPoint = [e.x, e.y];
|
||||
this.panning$.value = true;
|
||||
}
|
||||
|
||||
override mounted(): void {
|
||||
this.addHook('pointerDown', evt => {
|
||||
const shouldPanWithMiddle = evt.raw.button === MouseButton.MIDDLE;
|
||||
|
||||
if (!shouldPanWithMiddle) {
|
||||
return;
|
||||
}
|
||||
|
||||
evt.raw.preventDefault();
|
||||
|
||||
const selection = this.gfx.selection.surfaceSelections;
|
||||
const currentTool = this.controller.currentToolOption$.peek();
|
||||
const restoreToPrevious = () => {
|
||||
this.controller.setTool(currentTool);
|
||||
this.gfx.selection.set(selection);
|
||||
};
|
||||
|
||||
this.controller.setTool('pan', {
|
||||
panning: true,
|
||||
});
|
||||
|
||||
const dispose = on(document, 'pointerup', evt => {
|
||||
if (evt.button === MouseButton.MIDDLE) {
|
||||
restoreToPrevious();
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@blocksuite/std/gfx' {
|
||||
interface GfxToolsMap {
|
||||
pan: PanTool;
|
||||
}
|
||||
|
||||
interface GfxToolsOption {
|
||||
pan: PanToolOption;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { BaseTool } from '@blocksuite/std/gfx';
|
||||
|
||||
export class TemplateTool extends BaseTool {
|
||||
static override toolName: string = 'template';
|
||||
}
|
||||
|
||||
declare module '@blocksuite/std/gfx' {
|
||||
interface GfxToolsMap {
|
||||
template: TemplateTool;
|
||||
}
|
||||
}
|
||||
10
blocksuite/affine/blocks/root/src/edgeless/index.ts
Normal file
10
blocksuite/affine/blocks/root/src/edgeless/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export * from './clipboard/clipboard';
|
||||
export * from './clipboard/command';
|
||||
export * from './edgeless-root-block.js';
|
||||
export { EdgelessRootPreviewBlockComponent } from './edgeless-root-preview-block.js';
|
||||
export { EdgelessRootService } from './edgeless-root-service.js';
|
||||
export * from './gfx-tool';
|
||||
export * from './utils/clipboard-utils.js';
|
||||
export { sortEdgelessElements } from './utils/clone-utils.js';
|
||||
export { isCanvasElement } from './utils/query.js';
|
||||
export { EDGELESS_BLOCK_CHILD_PADDING } from '@blocksuite/affine-shared/consts';
|
||||
@@ -0,0 +1,25 @@
|
||||
import { getLastPropsKey } from '@blocksuite/affine-block-surface';
|
||||
import { EditPropsStore } from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
type SurfaceMiddleware,
|
||||
SurfaceMiddlewareBuilder,
|
||||
} from '@blocksuite/std/gfx';
|
||||
|
||||
export class EditPropsMiddlewareBuilder extends SurfaceMiddlewareBuilder {
|
||||
static override key = 'editProps';
|
||||
|
||||
middleware: SurfaceMiddleware = ctx => {
|
||||
if (ctx.type === 'beforeAdd') {
|
||||
const { type, props } = ctx.payload;
|
||||
const key = getLastPropsKey(type, props);
|
||||
const nProps = key
|
||||
? this.std.get(EditPropsStore).applyLastProps(key, ctx.payload.props)
|
||||
: null;
|
||||
|
||||
ctx.payload.props = {
|
||||
...(nProps ?? props),
|
||||
index: props.index ?? this.gfx.layer.generateIndex(),
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { isFrameBlock } from '@blocksuite/affine-block-frame';
|
||||
import {
|
||||
getSurfaceComponent,
|
||||
isNoteBlock,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import type {
|
||||
EdgelessTextBlockModel,
|
||||
EmbedSyncedDocModel,
|
||||
FrameBlockModel,
|
||||
ImageBlockModel,
|
||||
NoteBlockModel,
|
||||
ShapeElementModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { getElementsWithoutGroup } from '@blocksuite/affine-shared/utils';
|
||||
import { getCommonBoundWithRotation } from '@blocksuite/global/gfx';
|
||||
import type { BlockComponent } from '@blocksuite/std';
|
||||
import { GfxControllerIdentifier, type GfxModel } from '@blocksuite/std/gfx';
|
||||
import groupBy from 'lodash-es/groupBy';
|
||||
|
||||
import { createElementsFromClipboardDataCommand } from '../clipboard/command.js';
|
||||
import { getSortedCloneElements, prepareCloneData } from './clone-utils.js';
|
||||
import {
|
||||
isEdgelessTextBlock,
|
||||
isEmbedSyncedDocBlock,
|
||||
isImageBlock,
|
||||
} from './query.js';
|
||||
|
||||
const offset = 10;
|
||||
export async function duplicate(
|
||||
edgeless: BlockComponent,
|
||||
elements: GfxModel[],
|
||||
select = true
|
||||
) {
|
||||
const gfx = edgeless.std.get(GfxControllerIdentifier);
|
||||
|
||||
const surface = getSurfaceComponent(edgeless.std);
|
||||
if (!surface) return;
|
||||
|
||||
const copyElements = getSortedCloneElements(elements);
|
||||
const totalBound = getCommonBoundWithRotation(copyElements);
|
||||
totalBound.x += totalBound.w + offset;
|
||||
|
||||
const snapshot = prepareCloneData(copyElements, edgeless.std);
|
||||
const [_, { createdElementsPromise }] = edgeless.std.command.exec(
|
||||
createElementsFromClipboardDataCommand,
|
||||
{
|
||||
elementsRawData: snapshot,
|
||||
pasteCenter: totalBound.center,
|
||||
}
|
||||
);
|
||||
if (!createdElementsPromise) return;
|
||||
const { canvasElements, blockModels } = await createdElementsPromise;
|
||||
|
||||
const newElements = [...canvasElements, ...blockModels];
|
||||
|
||||
surface.fitToViewport(totalBound);
|
||||
|
||||
if (select) {
|
||||
gfx.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 ?? [],
|
||||
};
|
||||
};
|
||||
237
blocksuite/affine/blocks/root/src/edgeless/utils/clone-utils.ts
Normal file
237
blocksuite/affine/blocks/root/src/edgeless/utils/clone-utils.ts
Normal file
@@ -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/std';
|
||||
import {
|
||||
getTopElements,
|
||||
GfxBlockElementModel,
|
||||
type GfxModel,
|
||||
type GfxPrimitiveElementModel,
|
||||
isGfxGroupCompatibleModel,
|
||||
type SerializedElement,
|
||||
} from '@blocksuite/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 },
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
21
blocksuite/affine/blocks/root/src/edgeless/utils/consts.ts
Normal file
21
blocksuite/affine/blocks/root/src/edgeless/utils/consts.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
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 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;
|
||||
|
||||
export const EMBED_IFRAME_BLOCK_MIN_WIDTH = 218;
|
||||
export const EMBED_IFRAME_BLOCK_MIN_HEIGHT = 44;
|
||||
export const EMBED_IFRAME_BLOCK_MAX_WIDTH = 3400;
|
||||
export const EMBED_IFRAME_BLOCK_MAX_HEIGHT = 2200;
|
||||
37
blocksuite/affine/blocks/root/src/edgeless/utils/crud.ts
Normal file
37
blocksuite/affine/blocks/root/src/edgeless/utils/crud.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { isNoteBlock } from '@blocksuite/affine-block-surface';
|
||||
import type { Connectable } from '@blocksuite/affine-model';
|
||||
import type { GfxModel } from '@blocksuite/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,2 @@
|
||||
// TODO(@fundon): move to pen module
|
||||
export const drawingCursor = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none'%3E%3Cg filter='url(%23filter0_d_5033_225305)'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M16.138 6.9046C16.6785 6.36513 17.5553 6.36513 18.0958 6.9046C18.6358 7.44353 18.6358 8.31689 18.0958 8.85582L17.3186 9.63134L15.3621 7.67873L16.138 6.9046ZM14.6542 8.38506L16.6107 10.3377L8.96075 17.9707L6.61523 18.384L6.94908 16.461C7.00206 16.1558 7.14823 15.8745 7.36749 15.6557L14.6542 8.38506Z' fill='black'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M18.095 6.9046C17.5545 6.36513 16.6777 6.36513 16.1372 6.9046L15.3613 7.67873L17.3178 9.63134L18.095 8.85582C18.635 8.31689 18.635 7.44353 18.095 6.9046ZM18.8014 9.56366C19.7328 8.63405 19.7329 7.12641 18.8014 6.1968C17.8705 5.26773 16.3616 5.26773 15.4307 6.1968L6.66035 14.9478C6.29491 15.3124 6.05131 15.7813 5.96301 16.2899L5.50738 18.9145C5.47951 19.075 5.53158 19.239 5.6469 19.354C5.76223 19.469 5.92636 19.5207 6.08678 19.4924L9.28847 18.9282C9.38935 18.9104 9.48233 18.8621 9.55485 18.7898L17.671 10.6918L18.8014 9.56366ZM16.6099 10.3377L14.6534 8.38506L7.36668 15.6557C7.14741 15.8745 7.00125 16.1558 6.94827 16.461L6.61442 18.384L8.95993 17.9707L16.6099 10.3377Z' fill='white'/%3E%3C/g%3E%3Cdefs%3E%3Cfilter id='filter0_d_5033_225305' x='-1.8' y='-0.8' width='27.6' height='27.6' filterUnits='userSpaceOnUse' color-interpolation-filters='sRGB'%3E%3CfeFlood flood-opacity='0' result='BackgroundImageFix'/%3E%3CfeColorMatrix in='SourceAlpha' type='matrix' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0' result='hardAlpha'/%3E%3CfeOffset dy='1'/%3E%3CfeGaussianBlur stdDeviation='0.9'/%3E%3CfeColorMatrix type='matrix' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.65 0'/%3E%3CfeBlend mode='normal' in2='BackgroundImageFix' result='effect1_dropShadow_5033_225305'/%3E%3CfeBlend mode='normal' in='SourceGraphic' in2='effect1_dropShadow_5033_225305' result='shape'/%3E%3C/filter%3E%3C/defs%3E%3C/svg%3E") 4 20, crosshair`;
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ShapeToolOption } from '@blocksuite/affine-gfx-shape';
|
||||
import { ShapeType } from '@blocksuite/affine-model';
|
||||
|
||||
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,46 @@
|
||||
import type { IVec } from '@blocksuite/global/gfx';
|
||||
import type { PointerEventState } from '@blocksuite/std';
|
||||
import type { Viewport } from '@blocksuite/std/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];
|
||||
}
|
||||
258
blocksuite/affine/blocks/root/src/edgeless/utils/query.ts
Normal file
258
blocksuite/affine/blocks/root/src/edgeless/utils/query.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
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,
|
||||
ShapeElementModel,
|
||||
TextElementModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
getElementsWithoutGroup,
|
||||
isTopLevelBlock,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import type { PointLocation } from '@blocksuite/global/gfx';
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
import type {
|
||||
GfxModel,
|
||||
GfxPrimitiveElementModel,
|
||||
GfxToolsFullOptionValue,
|
||||
Viewport,
|
||||
} from '@blocksuite/std/gfx';
|
||||
import type { BlockModel } from '@blocksuite/store';
|
||||
|
||||
import { drawingCursor } from './cursors';
|
||||
|
||||
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'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: Remove this function after the edgeless refactor completed
|
||||
* This function is used to check if the block is an EmbedIframeBlock for edgeless selected rect
|
||||
* Should not be used in the future
|
||||
* Related issue: https://linear.app/affine-design/issue/BS-2841/
|
||||
* @deprecated
|
||||
*/
|
||||
export function isEmbedIframeBlock(element: BlockModel | GfxModel | null) {
|
||||
return (
|
||||
!!element &&
|
||||
'flavour' in element &&
|
||||
element.flavour === 'affine:embed-iframe'
|
||||
);
|
||||
}
|
||||
|
||||
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 'highlighter':
|
||||
return drawingCursor;
|
||||
case 'eraser':
|
||||
case 'shape':
|
||||
case 'connector':
|
||||
case 'frame':
|
||||
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;
|
||||
}
|
||||
762
blocksuite/affine/blocks/root/src/edgeless/utils/snap-manager.ts
Normal file
762
blocksuite/affine/blocks/root/src/edgeless/utils/snap-manager.ts
Normal file
@@ -0,0 +1,762 @@
|
||||
import { Overlay } from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
ConnectorElementModel,
|
||||
MindmapElementModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { almostEqual, Bound, Point } from '@blocksuite/global/gfx';
|
||||
import type { GfxModel } from '@blocksuite/std/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 SnapOverlay 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: {
|
||||
horizontal: [Point, Point][];
|
||||
vertical: [Point, Point][];
|
||||
} = {
|
||||
horizontal: [],
|
||||
vertical: [],
|
||||
};
|
||||
|
||||
override clear() {
|
||||
this._referenceBounds = {
|
||||
vertical: [],
|
||||
horizontal: [],
|
||||
all: [],
|
||||
};
|
||||
this._intraGraphicAlignLines = {
|
||||
horizontal: [],
|
||||
vertical: [],
|
||||
};
|
||||
this._distributedAlignLines = [];
|
||||
this._skippedElements.clear();
|
||||
|
||||
super.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.horizontal = 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.vertical = 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 = {
|
||||
horizontal: [],
|
||||
vertical: [],
|
||||
};
|
||||
this._distributedAlignLines = [];
|
||||
this._updateAlignCandidates(bound);
|
||||
|
||||
for (const other of this._referenceBounds.all) {
|
||||
const closestDistances = this._calculateClosestDistances(bound, other);
|
||||
|
||||
if (
|
||||
closestDistances.horiz &&
|
||||
(!this._intraGraphicAlignLines.horizontal.length ||
|
||||
Math.abs(closestDistances.horiz.distance) < Math.abs(rst.dx))
|
||||
) {
|
||||
this._updateXAlignPoint(rst, bound, other, closestDistances);
|
||||
}
|
||||
|
||||
if (
|
||||
closestDistances.vert &&
|
||||
(!this._intraGraphicAlignLines.vertical.length ||
|
||||
Math.abs(closestDistances.vert.distance) < Math.abs(rst.dy))
|
||||
) {
|
||||
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.vertical.length === 0 &&
|
||||
this._intraGraphicAlignLines.horizontal.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.horizontal,
|
||||
...this._intraGraphicAlignLines.vertical,
|
||||
].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 _isSkippedElement(element: GfxModel) {
|
||||
return (
|
||||
element instanceof ConnectorElementModel ||
|
||||
element.group instanceof MindmapElementModel
|
||||
);
|
||||
}
|
||||
|
||||
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) || this._isSkippedElement(candidate)) return;
|
||||
verticalBounds.push(candidate.elementBound);
|
||||
allBounds.push(candidate.elementBound);
|
||||
});
|
||||
|
||||
horizCandidates.forEach(candidate => {
|
||||
if (skipped.has(candidate) || this._isSkippedElement(candidate)) 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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user