mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-03 02:20:19 +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:
+680
@@ -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;
|
||||
}
|
||||
+762
@@ -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;
|
||||
}
|
||||
+61
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
+1619
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;
|
||||
}
|
||||
+17
@@ -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>`;
|
||||
}
|
||||
}
|
||||
+103
@@ -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);
|
||||
},
|
||||
});
|
||||
+65
@@ -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,
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user