mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-21 12:06:35 +08:00
297 lines
7.7 KiB
TypeScript
297 lines
7.7 KiB
TypeScript
import { CommonUtils } from '@blocksuite/affine-block-surface';
|
|
import type { IVec } from '@blocksuite/global/utils';
|
|
import {
|
|
assertEquals,
|
|
assertExists,
|
|
Slot,
|
|
Vec,
|
|
WithDisposable,
|
|
} from '@blocksuite/global/utils';
|
|
import { html, LitElement, nothing } from 'lit';
|
|
import { property } from 'lit/decorators.js';
|
|
import { styleMap } from 'lit/directives/style-map.js';
|
|
|
|
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
|
|
import type { PieMenuSchema, PieNodeModel } from './base.js';
|
|
import type { AffinePieMenuWidget } from './index.js';
|
|
import { PieNode } from './node.js';
|
|
import { PieManager } from './pie-manager.js';
|
|
import { pieMenuStyles } from './styles.js';
|
|
import {
|
|
getPosition,
|
|
isColorNode,
|
|
isCommandNode,
|
|
isNodeWithAction,
|
|
isNodeWithChildren,
|
|
isRootNode,
|
|
isSubmenuNode,
|
|
} from './utils.js';
|
|
|
|
const { toDegree, toRadian } = CommonUtils;
|
|
|
|
export class PieMenu extends WithDisposable(LitElement) {
|
|
static override styles = pieMenuStyles;
|
|
|
|
private readonly _handleKeyDown = (ev: KeyboardEvent) => {
|
|
const { key } = ev;
|
|
if (key === 'Escape') {
|
|
return this.abortController.abort();
|
|
}
|
|
|
|
if (ev.code === 'Backspace') {
|
|
if (this.selectionChain.length <= 1) return;
|
|
const { containerNode } = this.activeNode;
|
|
if (containerNode) this.popSelectionChainTo(containerNode);
|
|
}
|
|
|
|
if (key.match(/\d+/)) {
|
|
this.selectChildWithIndex(parseInt(key));
|
|
}
|
|
};
|
|
|
|
private readonly _handlePointerMove = (ev: PointerEvent) => {
|
|
const { clientX, clientY } = ev;
|
|
|
|
const { ACTIVATE_THRESHOLD_MIN } = PieManager.settings;
|
|
|
|
const lenSq = this.getActiveNodeToMouseLenSq([clientX, clientY]);
|
|
|
|
if (lenSq > ACTIVATE_THRESHOLD_MIN ** 2) {
|
|
const [nodeX, nodeY] = this.getActiveNodeRelPos();
|
|
const dx = clientX - nodeX;
|
|
const dy = clientY - nodeY;
|
|
|
|
const TAU = Math.PI * 2;
|
|
const angle = toDegree((Math.atan2(dy, dx) + TAU) % TAU); // convert from [-PI, PI] to [0 TAU]
|
|
this.slots.pointerAngleUpdated.emit(angle);
|
|
} else {
|
|
this.slots.pointerAngleUpdated.emit(null); // acts like a abort signal
|
|
}
|
|
};
|
|
|
|
private _hoveredNode: PieNode | null = null;
|
|
|
|
private _openSubmenuTimeout?: NodeJS.Timeout;
|
|
|
|
private readonly selectChildWithIndex = (index: number) => {
|
|
const activeNode = this.activeNode;
|
|
if (!activeNode || isNaN(index)) return;
|
|
|
|
const node = activeNode.querySelector(
|
|
`& > affine-pie-node[index='${index}']`
|
|
);
|
|
|
|
if (node instanceof PieNode && !isColorNode(node.model)) {
|
|
// colors are more than 9 may be another method ?
|
|
if (isSubmenuNode(node.model)) this.openSubmenu(node);
|
|
else node.select();
|
|
|
|
if (isCommandNode(node.model)) this.close();
|
|
}
|
|
};
|
|
|
|
abortController = new AbortController();
|
|
|
|
selectionChain: PieNode[] = [];
|
|
|
|
slots = {
|
|
pointerAngleUpdated: new Slot<number | null>(),
|
|
requestNodeUpdate: new Slot(),
|
|
};
|
|
|
|
get activeNode() {
|
|
const node = this.selectionChain[this.selectionChain.length - 1];
|
|
assertExists(node, 'Required atLeast 1 node active');
|
|
return node;
|
|
}
|
|
|
|
get hoveredNode() {
|
|
return this._hoveredNode;
|
|
}
|
|
|
|
get rootNode() {
|
|
const node = this.selectionChain[0];
|
|
assertExists(node, 'No root node');
|
|
return node;
|
|
}
|
|
|
|
private _createNodeTree(nodeSchema: PieNodeModel): PieNode {
|
|
const node = new PieNode();
|
|
const { angle, startAngle, endAngle, label } = nodeSchema;
|
|
|
|
node.id = label;
|
|
node.model = nodeSchema;
|
|
node.angle = angle ?? 0;
|
|
node.startAngle = startAngle ?? 0;
|
|
node.endAngle = endAngle ?? 0;
|
|
node.menu = this;
|
|
|
|
if (!isRootNode(nodeSchema)) {
|
|
node.slot = 'children-slot';
|
|
const { PIE_RADIUS } = PieManager.settings;
|
|
const isColorNode = nodeSchema.type === 'color';
|
|
const radius = isColorNode ? PIE_RADIUS * 0.6 : PIE_RADIUS;
|
|
|
|
node.position = getPosition(toRadian(node.angle), [radius, radius]);
|
|
} else {
|
|
node.position = [0, 0];
|
|
}
|
|
|
|
if (isNodeWithChildren(nodeSchema)) {
|
|
nodeSchema.children.forEach((childSchema, i) => {
|
|
const childNode = this._createNodeTree(childSchema);
|
|
childNode.containerNode = node;
|
|
childNode.index = i + 1;
|
|
childNode.setAttribute('index', childNode.index.toString());
|
|
|
|
node.append(childNode);
|
|
});
|
|
}
|
|
|
|
return node;
|
|
}
|
|
|
|
private _setupEvents() {
|
|
this._disposables.addFromEvent(
|
|
this.widgetComponent,
|
|
'pointermove',
|
|
this._handlePointerMove
|
|
);
|
|
|
|
this._disposables.addFromEvent(document, 'keydown', this._handleKeyDown);
|
|
}
|
|
|
|
close() {
|
|
this.abortController.abort();
|
|
}
|
|
|
|
override connectedCallback(): void {
|
|
super.connectedCallback();
|
|
this._setupEvents();
|
|
const root = this._createNodeTree(this.schema.root);
|
|
this.selectionChain.push(root);
|
|
}
|
|
|
|
/**
|
|
* Position of the active node relative to the view
|
|
*/
|
|
getActiveNodeRelPos(): IVec {
|
|
const position: IVec = [...this.position]; // use the menus position at start which will be the position of the root node
|
|
|
|
for (const node of this.selectionChain) {
|
|
position[0] += node.position[0];
|
|
position[1] += node.position[1];
|
|
}
|
|
return position;
|
|
}
|
|
|
|
getActiveNodeToMouseLenSq(mouse: IVec) {
|
|
const [x, y] = mouse;
|
|
const [nodeX, nodeY] = this.getActiveNodeRelPos();
|
|
|
|
const dx = x - nodeX;
|
|
const dy = y - nodeY;
|
|
|
|
return Vec.len2([dx, dy]);
|
|
}
|
|
|
|
getNodeRelPos(node: PieNode): IVec {
|
|
const position: IVec = [...this.position];
|
|
let cur: PieNode | null = node;
|
|
|
|
while (cur !== null) {
|
|
position[0] += cur.position[0];
|
|
position[1] += cur.position[1];
|
|
cur = cur.containerNode;
|
|
}
|
|
|
|
return position;
|
|
}
|
|
|
|
isActiveNode(node: PieNode) {
|
|
return this.activeNode === node;
|
|
}
|
|
|
|
isChildOfActiveNode(node: PieNode) {
|
|
return node.containerNode === this.activeNode;
|
|
}
|
|
|
|
openSubmenu(submenu: PieNode) {
|
|
assertEquals(submenu.model.type, 'submenu', 'Need node of type submenu');
|
|
|
|
if (isNodeWithAction(submenu.model)) submenu.select();
|
|
|
|
this.selectionChain.push(submenu);
|
|
this.setHovered(null);
|
|
this.slots.requestNodeUpdate.emit();
|
|
}
|
|
|
|
popSelectionChainTo(node: PieNode) {
|
|
assertEquals(
|
|
isNodeWithChildren(node.model),
|
|
true,
|
|
'Required a root node or a submenu node'
|
|
);
|
|
|
|
while (this.selectionChain.length > 1 && this.activeNode !== node) {
|
|
this.selectionChain.pop();
|
|
}
|
|
this.requestUpdate();
|
|
this.slots.requestNodeUpdate.emit();
|
|
}
|
|
|
|
override render() {
|
|
const [x, y] = this.position;
|
|
const menuStyles = {
|
|
transform: `translate(${x}px, ${y}px) translate(-50%, -50%)`,
|
|
};
|
|
|
|
return html` <div class="pie-menu-container">
|
|
<div class="overlay" @click="${() => this.abortController.abort()}"></div>
|
|
|
|
<div style="${styleMap(menuStyles)}" class="pie-menu">
|
|
${this.rootNode ?? nothing}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
selectHovered() {
|
|
const { hoveredNode } = this;
|
|
|
|
if (hoveredNode) {
|
|
hoveredNode.select();
|
|
}
|
|
}
|
|
|
|
setHovered(node: PieNode | null) {
|
|
clearTimeout(this._openSubmenuTimeout);
|
|
|
|
this._hoveredNode = node;
|
|
|
|
if (!node) return;
|
|
|
|
if (isSubmenuNode(node.model)) {
|
|
const { openOnHover, timeoutOverride } = node.model;
|
|
const { SUBMENU_OPEN_TIMEOUT } = PieManager.settings;
|
|
|
|
if (openOnHover !== undefined && !openOnHover) return;
|
|
|
|
this._openSubmenuTimeout = setTimeout(() => {
|
|
this.openSubmenu(node);
|
|
}, timeoutOverride ?? SUBMENU_OPEN_TIMEOUT);
|
|
}
|
|
}
|
|
|
|
@property({ attribute: false })
|
|
accessor position!: IVec;
|
|
|
|
@property({ attribute: false })
|
|
accessor rootComponent!: EdgelessRootBlockComponent;
|
|
|
|
@property({ attribute: false })
|
|
accessor schema!: PieMenuSchema;
|
|
|
|
@property({ attribute: false })
|
|
accessor widgetComponent!: AffinePieMenuWidget;
|
|
}
|