mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-10 05:29:08 +08:00
refactor(editor): move shape toolbar config and components to its package (#11082)
This commit is contained in:
@@ -1,2 +1,4 @@
|
||||
export * from './shape-draggable';
|
||||
export * from './shape-menu';
|
||||
export * from './shape-tool-button';
|
||||
export * from './shape-tool-element';
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
import {
|
||||
CanvasElementType,
|
||||
EdgelessCRUDIdentifier,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
getShapeRadius,
|
||||
getShapeType,
|
||||
ShapeType,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
EditPropsStore,
|
||||
TelemetryProvider,
|
||||
ThemeProvider,
|
||||
ViewportElementProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
EdgelessDraggableElementController,
|
||||
EdgelessToolbarToolMixin,
|
||||
} from '@blocksuite/affine-widget-edgeless-toolbar';
|
||||
import { SignalWatcher } from '@blocksuite/global/lit';
|
||||
import { css, html, LitElement, nothing } from 'lit';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { ShapeTool } from '../shape-tool.js';
|
||||
import { ellipseSvg, roundedSvg, triangleSvg } from '../toolbar/icons.js';
|
||||
import type { DraggableShape } from './utils.js';
|
||||
import { buildVariablesObject } from './utils.js';
|
||||
|
||||
const shapes: DraggableShape[] = [];
|
||||
// to move shapes together
|
||||
const oy = -2;
|
||||
const ox = 0;
|
||||
shapes.push({
|
||||
name: 'roundedRect',
|
||||
svg: roundedSvg,
|
||||
style: {
|
||||
default: { x: -9, y: 6 },
|
||||
hover: { y: -5, z: 1 },
|
||||
next: { y: 60 },
|
||||
},
|
||||
});
|
||||
shapes.push({
|
||||
name: ShapeType.Ellipse,
|
||||
svg: ellipseSvg,
|
||||
style: {
|
||||
default: { x: -20, y: 31 },
|
||||
hover: { y: 15, z: 1 },
|
||||
next: { y: 64 },
|
||||
},
|
||||
});
|
||||
shapes.push({
|
||||
name: ShapeType.Triangle,
|
||||
svg: triangleSvg,
|
||||
style: {
|
||||
default: { x: 18, y: 25 },
|
||||
hover: { y: 7, z: 1 },
|
||||
next: { y: 64 },
|
||||
},
|
||||
});
|
||||
shapes.forEach(s => {
|
||||
Object.values(s.style).forEach(style => {
|
||||
if (style.y) (style.y as number) += oy;
|
||||
if (style.x) (style.x as number) += ox;
|
||||
});
|
||||
});
|
||||
|
||||
export class EdgelessToolbarShapeDraggable extends EdgelessToolbarToolMixin(
|
||||
SignalWatcher(LitElement)
|
||||
) {
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.edgeless-shape-draggable {
|
||||
/* avoid shadow clipping */
|
||||
--shadow-safe-area: 10px;
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
width: calc(100% + 2 * var(--shadow-safe-area));
|
||||
height: calc(100% + var(--shadow-safe-area));
|
||||
padding-top: var(--shadow-safe-area);
|
||||
padding-left: var(--shadow-safe-area);
|
||||
padding-right: var(--shadow-safe-area);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.shape {
|
||||
width: fit-content;
|
||||
height: fit-content;
|
||||
position: absolute;
|
||||
transition:
|
||||
transform 0.3s,
|
||||
z-index 0.1s;
|
||||
transform: translateX(var(--default-x, 0)) translateY(var(--default-y, 0))
|
||||
scale(var(--default-s, 1));
|
||||
z-index: var(--default-z, 0);
|
||||
pointer-events: none;
|
||||
}
|
||||
.shape svg {
|
||||
display: block;
|
||||
}
|
||||
.shape svg path,
|
||||
.shape svg circle,
|
||||
.shape svg rect {
|
||||
pointer-events: auto;
|
||||
cursor: grab;
|
||||
}
|
||||
.shape:hover,
|
||||
.shape.cancel {
|
||||
transform: translateX(var(--hover-x, 0)) translateY(var(--hover-y, 0))
|
||||
scale(var(--hover-s, 1));
|
||||
z-index: var(--hover-z, 0);
|
||||
}
|
||||
.shape.next {
|
||||
transition: all 0.5s cubic-bezier(0.39, 0.28, 0.09, 0.95);
|
||||
pointer-events: none;
|
||||
transform: translateX(var(--next-x, 0)) translateY(var(--next-y, 0))
|
||||
scale(var(--next-s, 1));
|
||||
}
|
||||
.shape.next.coming {
|
||||
transform: translateX(var(--default-x, 0)) translateY(var(--default-y, 0))
|
||||
scale(var(--default-s, 1));
|
||||
}
|
||||
`;
|
||||
|
||||
draggableController!: EdgelessDraggableElementController<DraggableShape>;
|
||||
|
||||
draggingShape: DraggableShape['name'] = 'roundedRect';
|
||||
|
||||
override type = 'shape' as const;
|
||||
|
||||
get crud() {
|
||||
return this.edgeless.std.get(EdgelessCRUDIdentifier);
|
||||
}
|
||||
|
||||
get shapeShadow() {
|
||||
return this.theme === 'dark'
|
||||
? '0 0 7px rgba(0, 0, 0, .22)'
|
||||
: '0 0 5px rgba(0, 0, 0, .2)';
|
||||
}
|
||||
|
||||
private _setShapeOverlayLock(lock: boolean) {
|
||||
const controller = this.gfx.tool.currentTool$.peek();
|
||||
if (controller instanceof ShapeTool) {
|
||||
controller.setDisableOverlay(lock);
|
||||
}
|
||||
}
|
||||
|
||||
initDragController() {
|
||||
if (!this.edgeless || !this.toolbarContainer) return;
|
||||
if (this.draggableController) return;
|
||||
this.draggableController = new EdgelessDraggableElementController(this, {
|
||||
edgeless: this.edgeless,
|
||||
scopeElement: this.toolbarContainer,
|
||||
standardWidth: 100,
|
||||
clickToDrag: true,
|
||||
onOverlayCreated: (overlay, element) => {
|
||||
const shapeName =
|
||||
this.draggableController.states.draggingElement?.data.name;
|
||||
if (!shapeName) return;
|
||||
|
||||
this.setEdgelessTool({
|
||||
type: 'shape',
|
||||
shapeName,
|
||||
});
|
||||
const controller = this.gfx.tool.currentTool$.peek();
|
||||
if (controller instanceof ShapeTool) {
|
||||
controller.clearOverlay();
|
||||
}
|
||||
overlay.element.style.filter = `drop-shadow(${this.shapeShadow})`;
|
||||
this.readyToDrop = true;
|
||||
this.draggingShape = element.data.name;
|
||||
},
|
||||
onDrop: (el, bound) => {
|
||||
const xywh = bound.serialize();
|
||||
const shape = el.data;
|
||||
const id = this.crud.addElement(CanvasElementType.SHAPE, {
|
||||
shapeType: getShapeType(shape.name),
|
||||
xywh,
|
||||
radius: getShapeRadius(shape.name),
|
||||
});
|
||||
if (!id) return;
|
||||
this.edgeless.std
|
||||
.getOptional(TelemetryProvider)
|
||||
?.track('CanvasElementAdded', {
|
||||
control: 'toolbar:dnd',
|
||||
page: 'whiteboard editor',
|
||||
module: 'toolbar',
|
||||
segment: 'toolbar',
|
||||
type: 'shape',
|
||||
other: {
|
||||
shapeType: getShapeType(shape.name),
|
||||
},
|
||||
});
|
||||
|
||||
this._setShapeOverlayLock(false);
|
||||
this.readyToDrop = false;
|
||||
|
||||
// @ts-expect-error FIXME: resolve after gfx tool refactor
|
||||
this.gfx.tool.setTool('default');
|
||||
this.gfx.selection.set({
|
||||
elements: [id],
|
||||
editing: false,
|
||||
});
|
||||
},
|
||||
onCanceled: () => {
|
||||
this._setShapeOverlayLock(false);
|
||||
this.readyToDrop = false;
|
||||
},
|
||||
onElementClick: el => {
|
||||
this.onShapeClick?.(el.data);
|
||||
this._setShapeOverlayLock(true);
|
||||
},
|
||||
onEnterOrLeaveScope: (overlay, isOutside) => {
|
||||
overlay.element.style.filter = isOutside
|
||||
? 'none'
|
||||
: `drop-shadow(${this.shapeShadow})`;
|
||||
},
|
||||
});
|
||||
|
||||
this.edgeless.bindHotKey(
|
||||
{
|
||||
s: ctx => {
|
||||
// `page.keyboard.press('Shift+s')` in playwright will also trigger this 's' key event
|
||||
if (ctx.get('keyboardState').raw.shiftKey) return;
|
||||
|
||||
const locked = this.gfx.viewport.locked;
|
||||
const selection = this.gfx.selection;
|
||||
if (locked || selection.editing) return;
|
||||
|
||||
if (this.readyToDrop) {
|
||||
const activeIndex = shapes.findIndex(
|
||||
s => s.name === this.draggingShape
|
||||
);
|
||||
const nextIndex = (activeIndex + 1) % shapes.length;
|
||||
const next = shapes[nextIndex];
|
||||
this.draggingShape = next.name;
|
||||
|
||||
this.draggableController.cancelWithoutAnimation();
|
||||
}
|
||||
|
||||
const el = this.shapeContainer.querySelector(
|
||||
`.shape.${this.draggingShape}`
|
||||
) as HTMLElement;
|
||||
if (!el) {
|
||||
console.error('Edgeless toolbar Shape element not found');
|
||||
return;
|
||||
}
|
||||
const { x, y } = this.gfx.tool.lastMousePos$.peek();
|
||||
const { viewport } = this.edgeless.std.get(ViewportElementProvider);
|
||||
const { left, top } = viewport;
|
||||
const clientPos = { x: x + left, y: y + top };
|
||||
this.draggableController.clickToDrag(el, clientPos);
|
||||
},
|
||||
},
|
||||
{ global: true }
|
||||
);
|
||||
}
|
||||
|
||||
override render() {
|
||||
const { cancelled, dragOut, draggingElement } =
|
||||
this.draggableController?.states || {};
|
||||
const draggingShape = draggingElement?.data;
|
||||
return html`<div class="edgeless-shape-draggable">
|
||||
${repeat(
|
||||
shapes,
|
||||
s => s.name,
|
||||
shape => {
|
||||
const isBeingDragged = draggingShape?.name === shape.name;
|
||||
const { fillColor, strokeColor } =
|
||||
this.edgeless.std.get(EditPropsStore).lastProps$.value[
|
||||
`shape:${shape.name}`
|
||||
] || {};
|
||||
const color = this.edgeless.std
|
||||
.get(ThemeProvider)
|
||||
.generateColorProperty(fillColor);
|
||||
const stroke = this.edgeless.std
|
||||
.get(ThemeProvider)
|
||||
.generateColorProperty(strokeColor);
|
||||
const baseStyle = {
|
||||
...buildVariablesObject(shape.style),
|
||||
filter: `drop-shadow(${this.shapeShadow})`,
|
||||
color,
|
||||
stroke,
|
||||
};
|
||||
const currStyle = styleMap({
|
||||
...baseStyle,
|
||||
opacity: isBeingDragged ? 0 : 1,
|
||||
});
|
||||
const nextStyle = styleMap(baseStyle);
|
||||
return html`${isBeingDragged
|
||||
? html`<div
|
||||
style=${nextStyle}
|
||||
class=${classMap({
|
||||
shape: true,
|
||||
next: true,
|
||||
coming: !!dragOut && !cancelled,
|
||||
})}
|
||||
>
|
||||
${shape.svg}
|
||||
</div>`
|
||||
: nothing}
|
||||
<div
|
||||
style=${currStyle}
|
||||
class=${classMap({
|
||||
shape: true,
|
||||
[shape.name]: true,
|
||||
cancel: isBeingDragged && !dragOut,
|
||||
})}
|
||||
@mousedown=${(e: MouseEvent) =>
|
||||
this.draggableController.onMouseDown(e, {
|
||||
data: shape,
|
||||
preview: shape.svg,
|
||||
})}
|
||||
@touchstart=${(e: TouchEvent) =>
|
||||
this.draggableController.onTouchStart(e, {
|
||||
data: shape,
|
||||
preview: shape.svg,
|
||||
})}
|
||||
@click=${(e: MouseEvent) => e.stopPropagation()}
|
||||
>
|
||||
${shape.svg}
|
||||
</div>`;
|
||||
}
|
||||
)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
override updated(_changedProperties: Map<PropertyKey, unknown>) {
|
||||
const controllerRequiredProps = ['edgeless', 'toolbarContainer'] as const;
|
||||
if (
|
||||
controllerRequiredProps.some(p => _changedProperties.has(p)) &&
|
||||
!this.draggableController
|
||||
) {
|
||||
this.initDragController();
|
||||
}
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onShapeClick: (shape: DraggableShape) => void = () => {};
|
||||
|
||||
@state()
|
||||
accessor readyToDrop = false;
|
||||
|
||||
@query('.edgeless-shape-draggable')
|
||||
accessor shapeContainer!: HTMLDivElement;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { type ShapeName, ShapeType } from '@blocksuite/affine-model';
|
||||
import { EdgelessToolbarToolMixin } from '@blocksuite/affine-widget-edgeless-toolbar';
|
||||
import { SignalWatcher } from '@blocksuite/global/lit';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
|
||||
import { ShapeTool } from '../shape-tool.js';
|
||||
import type { DraggableShape } from './utils.js';
|
||||
|
||||
export class EdgelessShapeToolButton extends EdgelessToolbarToolMixin(
|
||||
SignalWatcher(LitElement)
|
||||
) {
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
edgeless-toolbar-button,
|
||||
.shapes {
|
||||
width: 100%;
|
||||
height: 64px;
|
||||
}
|
||||
`;
|
||||
|
||||
private readonly _handleShapeClick = (shape: DraggableShape) => {
|
||||
this.setEdgelessTool(this.type, {
|
||||
shapeName: shape.name,
|
||||
});
|
||||
if (!this.popper) this._toggleMenu();
|
||||
};
|
||||
|
||||
private readonly _handleWrapperClick = () => {
|
||||
if (this.tryDisposePopper()) return;
|
||||
|
||||
this.setEdgelessTool(this.type, {
|
||||
shapeName: ShapeType.Rect,
|
||||
});
|
||||
if (!this.popper) this._toggleMenu();
|
||||
};
|
||||
|
||||
override type = 'shape' as const;
|
||||
|
||||
private _toggleMenu() {
|
||||
this.createPopper('edgeless-shape-menu', this, {
|
||||
setProps: ele => {
|
||||
ele.edgeless = this.edgeless;
|
||||
ele.onChange = (shapeName: ShapeName) => {
|
||||
this.setEdgelessTool(this.type, {
|
||||
shapeName,
|
||||
});
|
||||
this._updateOverlay();
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _updateOverlay() {
|
||||
const controller = this.gfx.tool.currentTool$.peek();
|
||||
if (controller instanceof ShapeTool) {
|
||||
controller.createOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
override render() {
|
||||
const { active } = this;
|
||||
|
||||
return html`
|
||||
<edgeless-toolbar-button
|
||||
class="edgeless-shape-button"
|
||||
.tooltip=${this.popper
|
||||
? ''
|
||||
: html`<affine-tooltip-content-with-shortcut
|
||||
data-tip="${'Shape'}"
|
||||
data-shortcut="${'S'}"
|
||||
></affine-tooltip-content-with-shortcut>`}
|
||||
.tooltipOffset=${5}
|
||||
.active=${active}
|
||||
>
|
||||
<edgeless-toolbar-shape-draggable
|
||||
.edgeless=${this.edgeless}
|
||||
.toolbarContainer=${this.toolbarContainer}
|
||||
class="shapes"
|
||||
@click=${this._handleWrapperClick}
|
||||
.onShapeClick=${this._handleShapeClick}
|
||||
>
|
||||
</edgeless-toolbar-shape-draggable>
|
||||
</edgeless-toolbar-button>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { ShapeToolOption } from '@blocksuite/affine-gfx-shape';
|
||||
import { render, type TemplateResult } from 'lit';
|
||||
|
||||
type TransformState = {
|
||||
/** horizental offset base on center */
|
||||
x?: number | string;
|
||||
/** vertical offset base on center */
|
||||
y?: number | string;
|
||||
/** scale */
|
||||
s?: number;
|
||||
/** z-index */
|
||||
z?: number;
|
||||
};
|
||||
|
||||
export type DraggableShape = {
|
||||
name: ShapeToolOption['shapeName'];
|
||||
svg: TemplateResult;
|
||||
style: {
|
||||
default?: TransformState;
|
||||
hover?: TransformState;
|
||||
/**
|
||||
* The next shape when previous shape is dragged outside toolbar
|
||||
*/
|
||||
next?: TransformState;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to build the CSS variables object for the shape
|
||||
* @returns
|
||||
*/
|
||||
export const buildVariablesObject = (style: DraggableShape['style']) => {
|
||||
const states: Array<keyof DraggableShape['style']> = [
|
||||
'default',
|
||||
'hover',
|
||||
'next',
|
||||
];
|
||||
const variables: Array<keyof TransformState> = ['x', 'y', 's', 'z'];
|
||||
|
||||
const resolveValue = (
|
||||
variable: keyof TransformState,
|
||||
value: string | number
|
||||
) => {
|
||||
if (['x', 'y'].includes(variable)) {
|
||||
return typeof value === 'number' ? `${value}px` : value;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
return states.reduce((acc, state) => {
|
||||
return {
|
||||
...acc,
|
||||
...variables.reduce((acc, variable) => {
|
||||
const defaultValue = style.default?.[variable];
|
||||
const value = style[state]?.[variable] ?? defaultValue;
|
||||
if (value === undefined) return acc;
|
||||
return {
|
||||
...acc,
|
||||
[`--${state}-${variable}`]: resolveValue(variable, value),
|
||||
};
|
||||
}, {}),
|
||||
};
|
||||
}, {});
|
||||
};
|
||||
|
||||
// drag helper
|
||||
export type ShapeDragEvent = {
|
||||
inputType: 'mouse' | 'touch';
|
||||
x: number;
|
||||
y: number;
|
||||
el: HTMLElement;
|
||||
originalEvent: MouseEvent | TouchEvent;
|
||||
};
|
||||
|
||||
export const touchResolver = (event: TouchEvent) =>
|
||||
({
|
||||
inputType: 'touch',
|
||||
x: event.touches[0].clientX,
|
||||
y: event.touches[0].clientY,
|
||||
el: event.currentTarget as HTMLElement,
|
||||
originalEvent: event,
|
||||
}) satisfies ShapeDragEvent;
|
||||
|
||||
export const mouseResolver = (event: MouseEvent) =>
|
||||
({
|
||||
inputType: 'mouse',
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
el: event.currentTarget as HTMLElement,
|
||||
originalEvent: event,
|
||||
}) satisfies ShapeDragEvent;
|
||||
|
||||
// overlay helper
|
||||
export const defaultDraggingInfo = {
|
||||
startPos: { x: 0, y: 0 },
|
||||
toolbarRect: {} as DOMRect,
|
||||
edgelessRect: {} as DOMRect,
|
||||
shapeRectOriginal: {} as DOMRect,
|
||||
shapeEl: null as unknown as HTMLElement,
|
||||
parentToMount: null as unknown as HTMLElement,
|
||||
moved: false,
|
||||
shape: null as unknown as DraggableShape,
|
||||
style: {} as CSSStyleDeclaration,
|
||||
};
|
||||
export type DraggingInfo = typeof defaultDraggingInfo;
|
||||
|
||||
export const createShapeDraggingOverlay = (info: DraggingInfo) => {
|
||||
const { edgelessRect, parentToMount } = info;
|
||||
const overlay = document.createElement('div');
|
||||
Object.assign(overlay.style, {
|
||||
position: 'absolute',
|
||||
top: '0',
|
||||
left: '0',
|
||||
width: edgelessRect.width + 'px',
|
||||
// always clip
|
||||
// height: toolbarRect.bottom - edgelessRect.top + 'px',
|
||||
height: edgelessRect.height + 'px',
|
||||
overflow: 'hidden',
|
||||
zIndex: '9999',
|
||||
|
||||
// for debug purpose
|
||||
// background: 'rgba(255, 0, 0, 0.1)',
|
||||
});
|
||||
|
||||
const shape = document.createElement('div');
|
||||
const shapeScaleWrapper = document.createElement('div');
|
||||
Object.assign(shapeScaleWrapper.style, {
|
||||
transform: 'scale(var(--s, 1))',
|
||||
transition: 'transform 0.1s',
|
||||
transformOrigin: 'var(--o, center)',
|
||||
});
|
||||
render(info.shape.svg, shapeScaleWrapper);
|
||||
Object.assign(shape.style, {
|
||||
position: 'absolute',
|
||||
color: info.style.color,
|
||||
stroke: info.style.stroke,
|
||||
filter: `var(--shape-filter, ${info.style.filter})`,
|
||||
transform: 'translate(var(--x, 0), var(--y, 0))',
|
||||
left: 'var(--left, 0)',
|
||||
top: 'var(--top, 0)',
|
||||
cursor: 'grabbing',
|
||||
transition: 'inherit',
|
||||
});
|
||||
|
||||
shape.append(shapeScaleWrapper);
|
||||
overlay.append(shape);
|
||||
parentToMount.append(overlay);
|
||||
|
||||
return overlay;
|
||||
};
|
||||
Reference in New Issue
Block a user