mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 13:25:12 +00:00
chore(editor): remove pie menu (#9394)
This commit is contained in:
@@ -33,7 +33,6 @@ import { AFFINE_FRAME_TITLE_WIDGET } from '../widgets/frame-title/index.js';
|
||||
import { AFFINE_INNER_MODAL_WIDGET } from '../widgets/inner-modal/inner-modal.js';
|
||||
import { AFFINE_LINKED_DOC_WIDGET } from '../widgets/linked-doc/index.js';
|
||||
import { AFFINE_MODAL_WIDGET } from '../widgets/modal/modal.js';
|
||||
import { AFFINE_PIE_MENU_WIDGET } from '../widgets/pie-menu/index.js';
|
||||
import { AFFINE_SLASH_MENU_WIDGET } from '../widgets/slash-menu/index.js';
|
||||
import { AFFINE_VIEWPORT_OVERLAY_WIDGET } from '../widgets/viewport-overlay/viewport-overlay.js';
|
||||
import { NOTE_SLICER_WIDGET } from './components/note-slicer/index.js';
|
||||
@@ -46,7 +45,6 @@ import { EdgelessRootService } from './edgeless-root-service.js';
|
||||
export const edgelessRootWidgetViewMap = {
|
||||
[AFFINE_MODAL_WIDGET]: literal`${unsafeStatic(AFFINE_MODAL_WIDGET)}`,
|
||||
[AFFINE_INNER_MODAL_WIDGET]: literal`${unsafeStatic(AFFINE_INNER_MODAL_WIDGET)}`,
|
||||
[AFFINE_PIE_MENU_WIDGET]: literal`${unsafeStatic(AFFINE_PIE_MENU_WIDGET)}`,
|
||||
[AFFINE_SLASH_MENU_WIDGET]: literal`${unsafeStatic(
|
||||
AFFINE_SLASH_MENU_WIDGET
|
||||
)}`,
|
||||
|
||||
@@ -13,8 +13,6 @@ import type { AFFINE_INNER_MODAL_WIDGET } from './widgets/inner-modal/inner-moda
|
||||
import type { AFFINE_LINKED_DOC_WIDGET } from './widgets/linked-doc/index.js';
|
||||
import type { AFFINE_MODAL_WIDGET } from './widgets/modal/modal.js';
|
||||
import type { AFFINE_PAGE_DRAGGING_AREA_WIDGET } from './widgets/page-dragging-area/page-dragging-area.js';
|
||||
import type { AFFINE_PIE_MENU_ID_EDGELESS_TOOLS } from './widgets/pie-menu/config.js';
|
||||
import type { AFFINE_PIE_MENU_WIDGET } from './widgets/pie-menu/index.js';
|
||||
import type { AFFINE_SLASH_MENU_WIDGET } from './widgets/slash-menu/index.js';
|
||||
import type { AFFINE_VIEWPORT_OVERLAY_WIDGET } from './widgets/viewport-overlay/viewport-overlay.js';
|
||||
|
||||
@@ -34,7 +32,6 @@ export type PageRootBlockWidgetName =
|
||||
export type EdgelessRootBlockWidgetName =
|
||||
| typeof AFFINE_MODAL_WIDGET
|
||||
| typeof AFFINE_INNER_MODAL_WIDGET
|
||||
| typeof AFFINE_PIE_MENU_WIDGET
|
||||
| typeof AFFINE_SLASH_MENU_WIDGET
|
||||
| typeof AFFINE_LINKED_DOC_WIDGET
|
||||
| typeof AFFINE_DRAG_HANDLE_WIDGET
|
||||
@@ -50,5 +47,3 @@ export type EdgelessRootBlockWidgetName =
|
||||
export type RootBlockComponent =
|
||||
| PageRootBlockComponent
|
||||
| EdgelessRootBlockComponent;
|
||||
|
||||
export type PieMenuId = typeof AFFINE_PIE_MENU_ID_EDGELESS_TOOLS;
|
||||
|
||||
@@ -46,7 +46,6 @@ export {
|
||||
export { AffineLinkedDocWidget } from './linked-doc/index.js';
|
||||
export { AffineModalWidget } from './modal/modal.js';
|
||||
export { AffinePageDraggingAreaWidget } from './page-dragging-area/page-dragging-area.js';
|
||||
export { AffinePieMenuWidget } from './pie-menu/index.js';
|
||||
export {
|
||||
type AffineSlashMenuActionItem,
|
||||
type AffineSlashMenuContext,
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import type { TemplateResult } from 'lit';
|
||||
|
||||
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
|
||||
import type { PieMenuId } from '../../types.js';
|
||||
import type { AffinePieMenuWidget } from './index.js';
|
||||
import type { PieMenu } from './menu.js';
|
||||
import type { PieNode } from './node.js';
|
||||
|
||||
export interface PieMenuSchema {
|
||||
id: PieMenuId;
|
||||
|
||||
label: string;
|
||||
|
||||
root: PieRootNodeModel;
|
||||
|
||||
trigger: (props: {
|
||||
keyEvent: KeyboardEvent;
|
||||
rootComponent: EdgelessRootBlockComponent;
|
||||
}) => boolean;
|
||||
}
|
||||
|
||||
export type IconGetter = (ctx: PieMenuContext) => TemplateResult;
|
||||
export type DisabledGetter = (ctx: PieMenuContext) => boolean;
|
||||
export interface PieBaseNodeModel {
|
||||
type: 'root' | 'command' | 'submenu' | 'toggle' | 'color';
|
||||
|
||||
label: string;
|
||||
|
||||
icon?: IconGetter | TemplateResult;
|
||||
|
||||
angle?: number;
|
||||
|
||||
startAngle?: number;
|
||||
|
||||
endAngle?: number;
|
||||
|
||||
disabled?: boolean | DisabledGetter;
|
||||
}
|
||||
|
||||
// A menu can only have one root node
|
||||
export interface PieRootNodeModel extends PieBaseNodeModel {
|
||||
type: 'root';
|
||||
children: Array<PieNonRootNode>;
|
||||
}
|
||||
|
||||
export type PieMenuContext = {
|
||||
rootComponent: EdgelessRootBlockComponent;
|
||||
menu: PieMenu;
|
||||
widgetComponent: AffinePieMenuWidget;
|
||||
node: PieNode;
|
||||
};
|
||||
export type ActionFunction = (ctx: PieMenuContext) => void;
|
||||
|
||||
// Nodes which can perform a given action
|
||||
export interface PieCommandNodeModel extends PieBaseNodeModel {
|
||||
type: 'command';
|
||||
action: ActionFunction;
|
||||
}
|
||||
|
||||
// Open a submenu
|
||||
export interface PieSubmenuNodeModel extends PieBaseNodeModel {
|
||||
type: 'submenu';
|
||||
role: 'default' | 'color-picker' | 'command';
|
||||
action?: ActionFunction;
|
||||
children: Array<PieNonRootNode>;
|
||||
openOnHover?: boolean;
|
||||
timeoutOverride?: number;
|
||||
}
|
||||
|
||||
export interface PieColorNodeModel extends PieBaseNodeModel {
|
||||
type: 'color';
|
||||
color: string;
|
||||
hollowCircle: boolean;
|
||||
text?: string;
|
||||
onChange: (color: string, ctx: PieMenuContext) => void;
|
||||
}
|
||||
|
||||
export type IPieNodeWithAction =
|
||||
| PieCommandNodeModel
|
||||
| (PieSubmenuNodeModel & { role: 'command'; action: ActionFunction });
|
||||
|
||||
export type PieNonRootNode =
|
||||
| PieCommandNodeModel
|
||||
| PieColorNodeModel
|
||||
| PieSubmenuNodeModel;
|
||||
|
||||
export type PieNodeModel = PieRootNodeModel | PieNonRootNode;
|
||||
@@ -1,87 +0,0 @@
|
||||
import { css, html, LitElement } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { PieNode } from '../node.js';
|
||||
|
||||
const styles = css`
|
||||
.pie-parent-node-container {
|
||||
position: absolute;
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
.pie-node.center {
|
||||
width: 6rem;
|
||||
height: 6rem;
|
||||
padding: 0.4rem;
|
||||
}
|
||||
|
||||
.pie-node.center[active='true'] .node-content > svg,
|
||||
.pie-node.center[active='true'] .node-content > .color-unit,
|
||||
.pie-node.center[active='true'] .node-content > .color-unit > svg {
|
||||
width: 2rem !important;
|
||||
height: 2rem !important;
|
||||
}
|
||||
|
||||
.pie-node.center[active='false'] {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
`;
|
||||
|
||||
export class PieNodeCenter extends LitElement {
|
||||
static override styles = [PieNode.styles, styles];
|
||||
|
||||
protected override render() {
|
||||
const [x, y] = this.node.position;
|
||||
|
||||
const styles = {
|
||||
transform: `translate(${x}px, ${y}px) translate(-50%, -50%)`,
|
||||
};
|
||||
|
||||
return html`
|
||||
<div style="${styleMap(styles)}" class="pie-parent-node-container">
|
||||
<div
|
||||
style="${styleMap({ transform: 'translate(-50%, -50%)' })}"
|
||||
active="${this.isActive.toString()}"
|
||||
@mouseenter="${this.onMouseEnter}"
|
||||
class="pie-node center"
|
||||
>
|
||||
<pie-node-content
|
||||
.node="${this.node}"
|
||||
.hoveredNode="${this.hoveredNode}"
|
||||
.isActive="${this.isActive}"
|
||||
></pie-node-content>
|
||||
|
||||
<pie-center-rotator
|
||||
.angle=${this.rotatorAngle}
|
||||
.isActive=${this.isActive}
|
||||
></pie-center-rotator>
|
||||
</div>
|
||||
<slot></slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor hoveredNode!: PieNode | null;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor isActive!: boolean;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor node!: PieNode;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onMouseEnter!: (ev: MouseEvent) => void;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor rotatorAngle: number | null = null;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'pie-node-center': PieNodeCenter;
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import { css, html, LitElement } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { PieNode } from '../node.js';
|
||||
|
||||
const styles = css`
|
||||
.pie-node.child {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
padding: 0.6rem;
|
||||
animation: my-anim 250ms cubic-bezier(0.775, 1.325, 0.535, 1);
|
||||
}
|
||||
|
||||
.pie-node.child.node-color {
|
||||
width: 0.7rem;
|
||||
height: 0.7rem;
|
||||
}
|
||||
|
||||
.pie-node.child:not(.node-color)::after {
|
||||
content: attr(index);
|
||||
color: var(--affine-text-secondary-color);
|
||||
position: absolute;
|
||||
font-size: 8px;
|
||||
bottom: 10%;
|
||||
right: 30%;
|
||||
}
|
||||
|
||||
.pie-node.child[hovering='true'] {
|
||||
border-color: var(--affine-primary-color);
|
||||
background-color: var(--affine-hover-color-filled);
|
||||
scale: 1.06;
|
||||
}
|
||||
|
||||
.pie-node.child.node-submenu::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 10%;
|
||||
transform: translateY(-50%);
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
background-color: var(--affine-primary-color);
|
||||
border-radius: 50%;
|
||||
}
|
||||
`;
|
||||
|
||||
export class PieNodeChild extends LitElement {
|
||||
static override styles = [PieNode.styles, styles];
|
||||
|
||||
protected override render() {
|
||||
const { model, position } = this.node;
|
||||
|
||||
const [x, y] = position;
|
||||
|
||||
const styles = {
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: `translate(${x}px, ${y}px) translate(-50%, -50%)`,
|
||||
visibility: this.visible ? 'visible' : 'hidden',
|
||||
};
|
||||
|
||||
return html`<li
|
||||
style="${styleMap(styles)}"
|
||||
hovering="${this.hovering.toString()}"
|
||||
@click="${this.onClick}"
|
||||
index="${this.node.index}"
|
||||
class=${`pie-node child node-${model.type}`}
|
||||
>
|
||||
<pie-node-content
|
||||
.node=${this.node}
|
||||
.isActive=${false}
|
||||
.hoveredNode=${null}
|
||||
>
|
||||
</pie-node-content>
|
||||
</li>`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor hovering!: boolean;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor node!: PieNode;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onClick!: (ev: MouseEvent) => void;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor visible!: boolean;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'pie-node-child': PieNodeChild;
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import { assertEquals } from '@blocksuite/global/utils';
|
||||
import { css, html, LitElement, type PropertyValues } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
|
||||
import { ColorUnit } from '../../../edgeless/components/panel/color-panel.js';
|
||||
import type { PieNode } from '../node.js';
|
||||
import { isSubmenuNode } from '../utils.js';
|
||||
|
||||
const styles = css`
|
||||
.node-content > svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.node-content.center[active='true'] > svg,
|
||||
.node-content.center[active='true'] > .color-unit,
|
||||
.node-content.center[active='true'] > .color-unit > svg {
|
||||
width: 2rem !important;
|
||||
height: 2rem !important;
|
||||
}
|
||||
`;
|
||||
|
||||
export class PieNodeContent extends LitElement {
|
||||
static override styles = styles;
|
||||
|
||||
private _renderCenterNodeContent() {
|
||||
if (isSubmenuNode(this.node.model) && !this.isActive) {
|
||||
return this._renderChildNodeContent();
|
||||
}
|
||||
|
||||
const { menu, model } = this.node;
|
||||
const isActiveNode = menu.isActiveNode(this.node);
|
||||
const hoveredNode = this.hoveredNode;
|
||||
|
||||
if (
|
||||
this.isActive &&
|
||||
isSubmenuNode(model) &&
|
||||
model.role === 'color-picker'
|
||||
) {
|
||||
if (!hoveredNode) return this.node.icon;
|
||||
|
||||
assertEquals(
|
||||
hoveredNode.model.type,
|
||||
'color',
|
||||
'IPieSubMenuNode.role with color-picker should have children of type color'
|
||||
);
|
||||
const { color, hollowCircle } = hoveredNode.model;
|
||||
return ColorUnit(color, { hollowCircle });
|
||||
}
|
||||
|
||||
const { label } = model;
|
||||
const centerLabelOrIcon = this.node.icon ?? label;
|
||||
|
||||
return isActiveNode
|
||||
? hoveredNode
|
||||
? hoveredNode.model.label
|
||||
: centerLabelOrIcon
|
||||
: centerLabelOrIcon;
|
||||
}
|
||||
|
||||
private _renderChildNodeContent() {
|
||||
return this.node.icon;
|
||||
}
|
||||
|
||||
protected override render() {
|
||||
const content = this.node.isCenterNode()
|
||||
? this._renderCenterNodeContent()
|
||||
: this._renderChildNodeContent();
|
||||
|
||||
return html`
|
||||
<div
|
||||
active="${this.isActive.toString()}"
|
||||
class="node-content ${this.node.isCenterNode() ? 'center' : 'child'}"
|
||||
>
|
||||
${content}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
protected override updated(changedProperties: PropertyValues): void {
|
||||
super.updated(changedProperties);
|
||||
|
||||
if (
|
||||
!changedProperties.has('hoveredNode') ||
|
||||
!this._nodeContentElement ||
|
||||
!this.isActive
|
||||
)
|
||||
return;
|
||||
const fadeIn = [
|
||||
{
|
||||
opacity: 0,
|
||||
},
|
||||
{ opacity: 1 },
|
||||
];
|
||||
|
||||
this._nodeContentElement.animate(fadeIn, {
|
||||
duration: 250,
|
||||
easing: 'cubic-bezier(0.775, 1.325, 0.535, 1)',
|
||||
fill: 'forwards' as const,
|
||||
});
|
||||
}
|
||||
|
||||
@query('.node-content')
|
||||
private accessor _nodeContentElement!: HTMLDivElement;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor hoveredNode!: PieNode | null;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor isActive!: boolean;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor node!: PieNode;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'pie-node-content': PieNodeContent;
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { CommonUtils } from '@blocksuite/affine-block-surface';
|
||||
import { css, html, LitElement, nothing } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { getPosition } from '../utils.js';
|
||||
|
||||
const styles = css`
|
||||
.rotator {
|
||||
position: absolute;
|
||||
background: var(--affine-background-overlay-panel-color);
|
||||
box-shadow: var(--affine-shadow-2);
|
||||
border: 2px solid var(--affine-primary-color);
|
||||
border-radius: 50%;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
}
|
||||
`;
|
||||
|
||||
export class PieCenterRotator extends LitElement {
|
||||
static override styles = styles;
|
||||
|
||||
protected override render() {
|
||||
if (!this.isActive || this.angle === null) return nothing;
|
||||
|
||||
const [x, y] = getPosition(CommonUtils.toRadian(this.angle), [45, 45]);
|
||||
|
||||
const styles = {
|
||||
transform: `translate(${x}px, ${y}px) translate(-50%, -50%)`,
|
||||
};
|
||||
|
||||
return html`<span style="${styleMap(styles)}" class="rotator"></span>`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor angle: number | null = null;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor isActive!: boolean;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'pie-center-rotator': PieCenterRotator;
|
||||
}
|
||||
}
|
||||
@@ -1,338 +0,0 @@
|
||||
import {
|
||||
ConnectorCWithArrowIcon,
|
||||
ConnectorIcon,
|
||||
ConnectorLWithArrowIcon,
|
||||
ConnectorXWithArrowIcon,
|
||||
DiamondIcon,
|
||||
EdgelessEraserLightIcon,
|
||||
EdgelessGeneralShapeIcon,
|
||||
EdgelessPenLightIcon,
|
||||
EllipseIcon,
|
||||
FrameIcon,
|
||||
FrameNavigatorIcon,
|
||||
GeneralStyleIcon,
|
||||
NoteIcon,
|
||||
ScribbledDiamondIcon,
|
||||
ScribbledEllipseIcon,
|
||||
ScribbledSquareIcon,
|
||||
ScribbledStyleIcon,
|
||||
ScribbledTriangleIcon,
|
||||
SelectIcon,
|
||||
SquareIcon,
|
||||
ToolsIcon,
|
||||
TriangleIcon,
|
||||
ViewBarIcon,
|
||||
} from '@blocksuite/affine-components/icons';
|
||||
import {
|
||||
ConnectorMode,
|
||||
LINE_COLORS,
|
||||
SHAPE_FILL_COLORS,
|
||||
SHAPE_STROKE_COLORS,
|
||||
ShapeStyle,
|
||||
ShapeType,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
EditPropsStore,
|
||||
type LastProps,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { isControlledKeyboardEvent } from '@blocksuite/affine-shared/utils';
|
||||
import { html } from 'lit';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import {
|
||||
DEFAULT_NOTE_CHILD_FLAVOUR,
|
||||
DEFAULT_NOTE_CHILD_TYPE,
|
||||
DEFAULT_NOTE_TIP,
|
||||
} from '../../edgeless/utils/consts.js';
|
||||
import type { PieMenuContext } from './base.js';
|
||||
import { PieMenuBuilder } from './pie-builder.js';
|
||||
import {
|
||||
getActiveConnectorStrokeColor,
|
||||
getActiveShapeColor,
|
||||
setEdgelessToolAction,
|
||||
updateShapeOverlay,
|
||||
} from './utils.js';
|
||||
|
||||
//----------------------------------------------------------
|
||||
// EDGELESS TOOLS PIE MENU SCHEMA
|
||||
//----------------------------------------------------------
|
||||
|
||||
export const AFFINE_PIE_MENU_ID_EDGELESS_TOOLS = 'affine:pie:edgeless:tools';
|
||||
|
||||
const pie = new PieMenuBuilder({
|
||||
id: AFFINE_PIE_MENU_ID_EDGELESS_TOOLS,
|
||||
label: 'Tools',
|
||||
icon: ToolsIcon,
|
||||
trigger: ({ keyEvent: ev, rootComponent }) => {
|
||||
if (isControlledKeyboardEvent(ev)) return false;
|
||||
const isEditing = rootComponent.service.selection.editing;
|
||||
|
||||
return ev.key === 'q' && !isEditing;
|
||||
},
|
||||
});
|
||||
|
||||
pie.expandableCommand({
|
||||
label: 'Pen',
|
||||
icon: EdgelessPenLightIcon,
|
||||
action: setEdgelessToolAction(tool => tool.setTool('brush')),
|
||||
submenus: pie => {
|
||||
pie.colorPicker({
|
||||
label: 'Pen Color',
|
||||
active: getActiveConnectorStrokeColor,
|
||||
onChange: (color: string, { rootComponent }: PieMenuContext) => {
|
||||
rootComponent.std.get(EditPropsStore).recordLastProps('brush', {
|
||||
color: color as LastProps['brush']['color'],
|
||||
});
|
||||
},
|
||||
colors: LINE_COLORS.map(color => ({ color })),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
pie.command({
|
||||
label: 'Eraser',
|
||||
icon: EdgelessEraserLightIcon,
|
||||
action: setEdgelessToolAction(tool => tool.setTool('eraser')),
|
||||
});
|
||||
|
||||
pie.command({
|
||||
label: 'Frame',
|
||||
icon: FrameIcon,
|
||||
action: setEdgelessToolAction(tool => tool.setTool('frame')),
|
||||
});
|
||||
|
||||
pie.command({
|
||||
label: 'Select',
|
||||
icon: SelectIcon,
|
||||
action: setEdgelessToolAction(tool => tool.setTool('default')),
|
||||
});
|
||||
|
||||
pie.command({
|
||||
label: 'Note',
|
||||
icon: NoteIcon,
|
||||
action: setEdgelessToolAction(tool =>
|
||||
tool.setTool('affine:note', {
|
||||
childFlavour: DEFAULT_NOTE_CHILD_FLAVOUR,
|
||||
childType: DEFAULT_NOTE_CHILD_TYPE,
|
||||
tip: DEFAULT_NOTE_TIP,
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
pie.command({
|
||||
label: 'Reset Zoom',
|
||||
icon: ViewBarIcon,
|
||||
action: ({ rootComponent }) => {
|
||||
rootComponent.service.zoomToFit();
|
||||
},
|
||||
});
|
||||
|
||||
pie.command({
|
||||
label: 'Present',
|
||||
icon: ({ rootComponent }) => {
|
||||
const { type } = rootComponent.gfx.tool.currentToolOption$.peek();
|
||||
if (type === 'frameNavigator') {
|
||||
return html`
|
||||
<span
|
||||
style="${styleMap({
|
||||
color: '#eb4335',
|
||||
fontWeight: 'bold',
|
||||
})}"
|
||||
>Stop</span
|
||||
>
|
||||
`;
|
||||
}
|
||||
|
||||
return FrameNavigatorIcon;
|
||||
},
|
||||
action: ({ rootComponent }) => {
|
||||
const toolName = rootComponent.gfx.tool.currentToolName$.peek();
|
||||
if (toolName === 'frameNavigator') {
|
||||
rootComponent.gfx.tool.setTool('default');
|
||||
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen().catch(console.error);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
rootComponent.gfx.tool.setTool('frameNavigator', {
|
||||
mode: 'fit',
|
||||
});
|
||||
},
|
||||
});
|
||||
// Connector submenu
|
||||
pie.beginSubmenu({
|
||||
label: 'Connector',
|
||||
icon: ({ rootComponent }) => {
|
||||
const tool = rootComponent.gfx.tool.currentToolOption$.peek();
|
||||
|
||||
if (tool.type === 'connector') {
|
||||
switch (tool.mode) {
|
||||
case ConnectorMode.Orthogonal:
|
||||
return ConnectorLWithArrowIcon;
|
||||
case ConnectorMode.Curve:
|
||||
return ConnectorCWithArrowIcon;
|
||||
case ConnectorMode.Straight:
|
||||
return ConnectorXWithArrowIcon;
|
||||
}
|
||||
}
|
||||
return ConnectorIcon;
|
||||
},
|
||||
});
|
||||
|
||||
pie.command({
|
||||
label: 'Curved',
|
||||
icon: ConnectorCWithArrowIcon,
|
||||
action: setEdgelessToolAction(tool =>
|
||||
tool.setTool('connector', {
|
||||
mode: ConnectorMode.Curve,
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
pie.command({
|
||||
label: 'Elbowed',
|
||||
icon: ConnectorXWithArrowIcon,
|
||||
action: setEdgelessToolAction(tool =>
|
||||
tool.setTool('connector', {
|
||||
mode: ConnectorMode.Orthogonal,
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
pie.command({
|
||||
label: 'Straight',
|
||||
icon: ConnectorLWithArrowIcon,
|
||||
action: setEdgelessToolAction(tool =>
|
||||
tool.setTool('connector', {
|
||||
mode: ConnectorMode.Straight,
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
pie.colorPicker({
|
||||
label: 'Line Color',
|
||||
active: getActiveConnectorStrokeColor,
|
||||
onChange: (color: string, { rootComponent }: PieMenuContext) => {
|
||||
rootComponent.std.get(EditPropsStore).recordLastProps('connector', {
|
||||
stroke: color as LastProps['connector']['stroke'],
|
||||
});
|
||||
},
|
||||
colors: LINE_COLORS.map(color => ({ color })),
|
||||
});
|
||||
|
||||
pie.endSubmenu();
|
||||
|
||||
// Shapes Submenu
|
||||
pie.beginSubmenu({
|
||||
label: 'Shapes',
|
||||
icon: EdgelessGeneralShapeIcon,
|
||||
});
|
||||
|
||||
const shapes = [
|
||||
{
|
||||
type: ShapeType.Rect,
|
||||
label: 'Rect',
|
||||
icon: (style: ShapeStyle) =>
|
||||
style === ShapeStyle.General ? SquareIcon : ScribbledSquareIcon,
|
||||
},
|
||||
{
|
||||
type: ShapeType.Ellipse,
|
||||
label: 'Ellipse',
|
||||
icon: (style: ShapeStyle) =>
|
||||
style === ShapeStyle.General ? EllipseIcon : ScribbledEllipseIcon,
|
||||
},
|
||||
{
|
||||
type: ShapeType.Triangle,
|
||||
label: 'Triangle',
|
||||
icon: (style: ShapeStyle) =>
|
||||
style === ShapeStyle.General ? TriangleIcon : ScribbledTriangleIcon,
|
||||
},
|
||||
{
|
||||
type: ShapeType.Diamond,
|
||||
label: 'Diamond',
|
||||
icon: (style: ShapeStyle) =>
|
||||
style === ShapeStyle.General ? DiamondIcon : ScribbledDiamondIcon,
|
||||
},
|
||||
];
|
||||
|
||||
shapes.forEach(shape => {
|
||||
pie.command({
|
||||
label: shape.label,
|
||||
icon: ({ rootComponent }) => {
|
||||
const attributes =
|
||||
rootComponent.std.get(EditPropsStore).lastProps$.value[
|
||||
`shape:${shape.type}`
|
||||
];
|
||||
return shape.icon(attributes.shapeStyle);
|
||||
},
|
||||
|
||||
action: ({ rootComponent }) => {
|
||||
rootComponent.gfx.tool.setTool('shape', {
|
||||
shapeName: shape.type,
|
||||
});
|
||||
updateShapeOverlay(rootComponent);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
pie.command({
|
||||
label: 'Toggle Style',
|
||||
icon: ({ rootComponent }) => {
|
||||
const { shapeStyle } =
|
||||
rootComponent.std.get(EditPropsStore).lastProps$.value[
|
||||
'shape:roundedRect'
|
||||
];
|
||||
return shapeStyle === ShapeStyle.General
|
||||
? ScribbledStyleIcon
|
||||
: GeneralStyleIcon;
|
||||
},
|
||||
|
||||
action: ({ rootComponent }) => {
|
||||
const { shapeStyle } =
|
||||
rootComponent.std.get(EditPropsStore).lastProps$.value[
|
||||
'shape:roundedRect'
|
||||
];
|
||||
const toggleType =
|
||||
shapeStyle === ShapeStyle.General
|
||||
? ShapeStyle.Scribbled
|
||||
: ShapeStyle.General;
|
||||
|
||||
rootComponent.std.get(EditPropsStore).recordLastProps('shape:roundedRect', {
|
||||
shapeStyle: toggleType,
|
||||
});
|
||||
|
||||
updateShapeOverlay(rootComponent);
|
||||
},
|
||||
});
|
||||
|
||||
pie.colorPicker({
|
||||
label: 'Fill',
|
||||
active: getActiveShapeColor('fill'),
|
||||
onChange: (color: string, { rootComponent }: PieMenuContext) => {
|
||||
rootComponent.std.get(EditPropsStore).recordLastProps('shape:roundedRect', {
|
||||
fillColor: color as LastProps['shape:roundedRect']['fillColor'],
|
||||
});
|
||||
updateShapeOverlay(rootComponent);
|
||||
},
|
||||
colors: SHAPE_FILL_COLORS.map(color => ({ color })),
|
||||
});
|
||||
|
||||
pie.colorPicker({
|
||||
label: 'Stroke',
|
||||
hollow: true,
|
||||
active: getActiveShapeColor('stroke'),
|
||||
onChange: (color: string, { rootComponent }: PieMenuContext) => {
|
||||
rootComponent.std.get(EditPropsStore).recordLastProps('shape:roundedRect', {
|
||||
strokeColor: color as LastProps['shape:roundedRect']['strokeColor'],
|
||||
});
|
||||
updateShapeOverlay(rootComponent);
|
||||
},
|
||||
colors: SHAPE_STROKE_COLORS.map(color => ({ color, name: 'Color' })),
|
||||
});
|
||||
|
||||
pie.endSubmenu();
|
||||
|
||||
export const edgelessToolsPieSchema = pie.build();
|
||||
@@ -1,165 +0,0 @@
|
||||
import type { UIEventStateContext } from '@blocksuite/block-std';
|
||||
import { WidgetComponent } from '@blocksuite/block-std';
|
||||
import type { IVec } from '@blocksuite/global/utils';
|
||||
import { noop } from '@blocksuite/global/utils';
|
||||
import { nothing } from 'lit';
|
||||
import { state } from 'lit/decorators.js';
|
||||
|
||||
import { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
|
||||
import type { PieMenuSchema } from './base.js';
|
||||
import { PieNodeCenter } from './components/pie-node-center.js';
|
||||
import { PieNodeChild } from './components/pie-node-child.js';
|
||||
import { PieNodeContent } from './components/pie-node-content.js';
|
||||
import { PieCenterRotator } from './components/rotator.js';
|
||||
import { edgelessToolsPieSchema } from './config.js';
|
||||
import { PieMenu } from './menu.js';
|
||||
import { PieManager } from './pie-manager.js';
|
||||
|
||||
noop(PieNodeContent);
|
||||
noop(PieNodeCenter);
|
||||
noop(PieCenterRotator);
|
||||
noop(PieNodeChild);
|
||||
|
||||
export const AFFINE_PIE_MENU_WIDGET = 'affine-pie-menu-widget';
|
||||
|
||||
export class AffinePieMenuWidget extends WidgetComponent {
|
||||
private readonly _handleCursorPos = (ctx: UIEventStateContext) => {
|
||||
const ev = ctx.get('pointerState');
|
||||
const { x, y } = ev.point;
|
||||
this.mouse = [x, y];
|
||||
};
|
||||
|
||||
private readonly _handleKeyUp = (ctx: UIEventStateContext) => {
|
||||
if (!this.currentMenu) return;
|
||||
const ev = ctx.get('keyboardState');
|
||||
const { trigger } = this.currentMenu.schema;
|
||||
|
||||
if (trigger({ keyEvent: ev.raw, rootComponent: this.rootComponent })) {
|
||||
clearTimeout(this.selectOnTrigRelease.timeout);
|
||||
if (this.selectOnTrigRelease.allow) {
|
||||
this.currentMenu.selectHovered();
|
||||
this.currentMenu.close();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
mouse: IVec = [innerWidth / 2, innerHeight / 2];
|
||||
|
||||
// No action if the currently hovered node is a submenu
|
||||
selectOnTrigRelease: { allow: boolean; timeout?: NodeJS.Timeout } = {
|
||||
allow: false,
|
||||
};
|
||||
|
||||
get isEnabled() {
|
||||
return this.doc.awarenessStore.getFlag('enable_pie_menu');
|
||||
}
|
||||
|
||||
// if key is released before 100ms then the menu is kept open, else
|
||||
get isOpen() {
|
||||
return !!this.currentMenu;
|
||||
}
|
||||
|
||||
get rootComponent(): EdgelessRootBlockComponent {
|
||||
const rootComponent = this.block;
|
||||
if (rootComponent instanceof EdgelessRootBlockComponent) {
|
||||
return rootComponent;
|
||||
}
|
||||
throw new Error('AffinePieMenuWidget is only supported in edgeless');
|
||||
}
|
||||
|
||||
private _attachMenu(schema: PieMenuSchema) {
|
||||
if (this.currentMenu && this.currentMenu.id === schema.id)
|
||||
return this.currentMenu.close();
|
||||
|
||||
const [x, y] = this.mouse;
|
||||
const menu = this._createMenu(schema, {
|
||||
x,
|
||||
y,
|
||||
widgetComponent: this,
|
||||
});
|
||||
this.currentMenu = menu;
|
||||
|
||||
this.selectOnTrigRelease.timeout = setTimeout(() => {
|
||||
this.selectOnTrigRelease.allow = true;
|
||||
}, PieManager.settings.SELECT_ON_RELEASE_TIMEOUT);
|
||||
}
|
||||
|
||||
private _initPie() {
|
||||
PieManager.setup({ rootComponent: this.rootComponent });
|
||||
|
||||
this._disposables.add(
|
||||
PieManager.slots.open.on(this._attachMenu.bind(this))
|
||||
);
|
||||
}
|
||||
|
||||
private _onMenuClose() {
|
||||
this.currentMenu = null;
|
||||
this.selectOnTrigRelease.allow = false;
|
||||
}
|
||||
|
||||
// on trigger key release it will select the currently hovered menu node
|
||||
_createMenu(
|
||||
schema: PieMenuSchema,
|
||||
{
|
||||
x,
|
||||
y,
|
||||
widgetComponent,
|
||||
}: {
|
||||
x: number;
|
||||
y: number;
|
||||
widgetComponent: AffinePieMenuWidget;
|
||||
}
|
||||
) {
|
||||
const menu = new PieMenu();
|
||||
menu.id = schema.id;
|
||||
menu.schema = schema;
|
||||
menu.position = [x, y];
|
||||
menu.rootComponent = widgetComponent.rootComponent;
|
||||
menu.widgetComponent = widgetComponent;
|
||||
menu.abortController.signal.addEventListener(
|
||||
'abort',
|
||||
this._onMenuClose.bind(this)
|
||||
);
|
||||
|
||||
return menu;
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
if (!this.isEnabled) return;
|
||||
|
||||
this.handleEvent('keyUp', this._handleKeyUp, { global: true });
|
||||
this.handleEvent('pointerMove', this._handleCursorPos, { global: true });
|
||||
this.handleEvent(
|
||||
'wheel',
|
||||
ctx => {
|
||||
const state = ctx.get('defaultState');
|
||||
if (state.event instanceof WheelEvent) state.event.stopPropagation();
|
||||
},
|
||||
{ global: true }
|
||||
);
|
||||
|
||||
this._initPie();
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
PieManager.dispose();
|
||||
}
|
||||
|
||||
override render() {
|
||||
return this.currentMenu ?? nothing;
|
||||
}
|
||||
|
||||
@state()
|
||||
accessor currentMenu: PieMenu | null = null;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AFFINE_PIE_MENU_WIDGET]: AffinePieMenuWidget;
|
||||
}
|
||||
}
|
||||
|
||||
PieManager.add(edgelessToolsPieSchema);
|
||||
@@ -1,296 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
import type { IVec } from '@blocksuite/global/utils';
|
||||
import { WithDisposable } from '@blocksuite/global/utils';
|
||||
import { html, LitElement } from 'lit';
|
||||
import { property, state } from 'lit/decorators.js';
|
||||
|
||||
import type { PieNodeModel } from './base.js';
|
||||
import type { PieMenu } from './menu.js';
|
||||
import { pieNodeStyles } from './styles.js';
|
||||
import {
|
||||
isAngleBetween,
|
||||
isColorNode,
|
||||
isCommandNode,
|
||||
isNodeWithAction,
|
||||
isNodeWithChildren,
|
||||
isRootNode,
|
||||
} from './utils.js';
|
||||
|
||||
export class PieNode extends WithDisposable(LitElement) {
|
||||
static override styles = pieNodeStyles;
|
||||
|
||||
private readonly _handleChildNodeClick = () => {
|
||||
this.select();
|
||||
if (isCommandNode(this.model)) this.menu.close();
|
||||
};
|
||||
|
||||
private readonly _handleGoBack = () => {
|
||||
// If the node is not active and if it is hovered then we can go back to that node
|
||||
if (this.menu.activeNode !== this) {
|
||||
this.menu.popSelectionChainTo(this);
|
||||
}
|
||||
};
|
||||
|
||||
private readonly _onPointerAngleUpdated = (angle: number | null) => {
|
||||
this._rotatorAngle = angle;
|
||||
this.menu.activeNode.requestUpdate();
|
||||
|
||||
if (isRootNode(this.model) || !this.menu.isChildOfActiveNode(this)) return;
|
||||
if (angle === null) {
|
||||
this._isHovering = false;
|
||||
this.menu.setHovered(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAngleBetween(angle, this.startAngle, this.endAngle)) {
|
||||
if (this.menu.hoveredNode !== this) {
|
||||
this._isHovering = true;
|
||||
this.menu.setHovered(this);
|
||||
}
|
||||
} else {
|
||||
this._isHovering = false;
|
||||
}
|
||||
};
|
||||
|
||||
private _rotatorAngle: number | null = null;
|
||||
|
||||
get icon() {
|
||||
const icon = this.model.icon;
|
||||
if (typeof icon === 'function') {
|
||||
const { menu } = this;
|
||||
const { rootComponent, widgetComponent } = menu;
|
||||
return icon({
|
||||
rootComponent,
|
||||
menu,
|
||||
widgetComponent,
|
||||
node: this,
|
||||
});
|
||||
}
|
||||
return icon;
|
||||
}
|
||||
|
||||
private _renderCenterNode() {
|
||||
const isActiveNode = this.isActive();
|
||||
|
||||
return html`
|
||||
<pie-node-center
|
||||
.node=${this}
|
||||
.hoveredNode=${this.menu.hoveredNode}
|
||||
.isActive=${isActiveNode}
|
||||
.onMouseEnter=${this._handleGoBack}
|
||||
.rotatorAngle="${this._rotatorAngle}"
|
||||
>
|
||||
<slot name="children-slot"></slot>
|
||||
</pie-node-center>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderChildNode() {
|
||||
const visible = this.menu.isChildOfActiveNode(this);
|
||||
return html`<pie-node-child
|
||||
.node="${this}"
|
||||
.visible="${visible}"
|
||||
.hovering="${this._isHovering}"
|
||||
.onClick="${this._handleChildNodeClick}"
|
||||
>
|
||||
</pie-node-child>`;
|
||||
} // for selecting with keyboard
|
||||
|
||||
private _setupEvents() {
|
||||
this._disposables.add(
|
||||
this.menu.slots.pointerAngleUpdated.on(this._onPointerAngleUpdated)
|
||||
);
|
||||
|
||||
this._disposables.add(
|
||||
this.menu.slots.requestNodeUpdate.on(() => this.requestUpdate())
|
||||
);
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this._setupEvents();
|
||||
}
|
||||
|
||||
isActive() {
|
||||
return this.menu.isActiveNode(this);
|
||||
}
|
||||
|
||||
isCenterNode() {
|
||||
return (
|
||||
isNodeWithChildren(this.model) && this.menu.selectionChain.includes(this)
|
||||
);
|
||||
}
|
||||
|
||||
protected override render() {
|
||||
return this.isCenterNode()
|
||||
? this._renderCenterNode()
|
||||
: this._renderChildNode();
|
||||
}
|
||||
|
||||
select() {
|
||||
const schema = this.model;
|
||||
|
||||
if (isRootNode(schema)) return;
|
||||
|
||||
const ctx = {
|
||||
rootComponent: this.menu.rootComponent,
|
||||
menu: this.menu,
|
||||
widgetComponent: this.menu.widgetComponent,
|
||||
node: this,
|
||||
};
|
||||
|
||||
if (isNodeWithAction(schema)) {
|
||||
schema.action(ctx);
|
||||
} else if (isColorNode(schema)) {
|
||||
schema.onChange(schema.color, ctx);
|
||||
}
|
||||
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
@state()
|
||||
private accessor _isHovering = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor angle!: number;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor containerNode: PieNode | null = null;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor endAngle!: number;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor index!: number;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor menu!: PieMenu;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor model!: PieNodeModel;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor position!: IVec;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor startAngle!: number;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'pie-node': PieNode;
|
||||
}
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
|
||||
import { ColorUnit } from '../../edgeless/components/panel/color-panel.js';
|
||||
import type {
|
||||
ActionFunction,
|
||||
PieColorNodeModel,
|
||||
PieCommandNodeModel,
|
||||
PieMenuContext,
|
||||
PieMenuSchema,
|
||||
PieNodeModel,
|
||||
PieSubmenuNodeModel,
|
||||
} from './base.js';
|
||||
import { PieManager } from './pie-manager.js';
|
||||
import { calcNodeAngles, calcNodeWedges, isNodeWithChildren } from './utils.js';
|
||||
|
||||
export interface IPieColorPickerNodeProps {
|
||||
label: string;
|
||||
active: (ctx: PieMenuContext) => string;
|
||||
onChange: PieColorNodeModel['onChange'];
|
||||
openOnHover?: PieSubmenuNodeModel['openOnHover'];
|
||||
hollow?: boolean;
|
||||
colors: { color: string }[];
|
||||
}
|
||||
|
||||
type PieBuilderConstructorProps = Omit<
|
||||
PieMenuSchema,
|
||||
'root' | 'angle' | 'startAngle' | 'endAngle' | 'disabled'
|
||||
> & { icon: PieNodeModel['icon'] };
|
||||
|
||||
export class PieMenuBuilder {
|
||||
private _schema: PieMenuSchema | null = null;
|
||||
|
||||
private _stack: PieNodeModel[] = [];
|
||||
|
||||
constructor(base: PieBuilderConstructorProps) {
|
||||
this._schema = {
|
||||
...base,
|
||||
root: {
|
||||
type: 'root',
|
||||
children: [],
|
||||
label: base.label,
|
||||
icon: base.icon,
|
||||
disabled: false,
|
||||
},
|
||||
};
|
||||
this._stack.push(this._schema.root);
|
||||
}
|
||||
|
||||
private _computeAngles(node: PieNodeModel) {
|
||||
if (
|
||||
!isNodeWithChildren(node) ||
|
||||
!node.children ||
|
||||
node.children.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const parentAngle =
|
||||
node.angle == undefined ? undefined : (node.angle + 180) % 360;
|
||||
const angles = calcNodeAngles(node.children, parentAngle);
|
||||
const wedges = calcNodeWedges(angles, parentAngle);
|
||||
|
||||
for (let i = 0; i < node.children.length; ++i) {
|
||||
const child = node.children[i];
|
||||
child.angle = angles[i];
|
||||
child.startAngle = wedges[i].start;
|
||||
child.endAngle = wedges[i].end;
|
||||
|
||||
this._computeAngles(child);
|
||||
}
|
||||
}
|
||||
|
||||
private _currentNode(): PieNodeModel {
|
||||
const node = this._stack[this._stack.length - 1];
|
||||
assertExists(node, 'No node active');
|
||||
return node;
|
||||
}
|
||||
|
||||
beginSubmenu(
|
||||
node: Omit<PieSubmenuNodeModel, 'type' | 'children' | 'role'>,
|
||||
action?: PieSubmenuNodeModel['action']
|
||||
) {
|
||||
const curNode = this._currentNode();
|
||||
const submenuNode: PieSubmenuNodeModel = {
|
||||
openOnHover: true,
|
||||
...node,
|
||||
type: 'submenu',
|
||||
role: action ? 'default' : 'command',
|
||||
action,
|
||||
children: [],
|
||||
};
|
||||
if (submenuNode.action !== undefined)
|
||||
submenuNode.timeoutOverride =
|
||||
PieManager.settings.EXPANDABLE_ACTION_NODE_TIMEOUT;
|
||||
|
||||
if (isNodeWithChildren(curNode)) {
|
||||
curNode.children.push(submenuNode);
|
||||
}
|
||||
|
||||
this._stack.push(submenuNode);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
build() {
|
||||
const schema = this._schema;
|
||||
assertExists(schema);
|
||||
this._computeAngles(schema.root);
|
||||
|
||||
this._schema = null;
|
||||
this._stack = [];
|
||||
return schema;
|
||||
}
|
||||
|
||||
colorPicker(props: IPieColorPickerNodeProps) {
|
||||
const hollow = props.hollow ?? false;
|
||||
|
||||
const icon = (ctx: PieMenuContext) => {
|
||||
const color = props.active(ctx);
|
||||
|
||||
return ColorUnit(color, { hollowCircle: hollow });
|
||||
};
|
||||
|
||||
const colorPickerNode: PieSubmenuNodeModel = {
|
||||
type: 'submenu',
|
||||
icon,
|
||||
label: props.label,
|
||||
role: 'color-picker',
|
||||
openOnHover: props.openOnHover ?? true,
|
||||
children: props.colors.map(({ color }) => ({
|
||||
icon: () => ColorUnit(color, { hollowCircle: hollow }),
|
||||
type: 'color',
|
||||
hollowCircle: hollow,
|
||||
label: color,
|
||||
color: color,
|
||||
onChange: props.onChange,
|
||||
})),
|
||||
};
|
||||
|
||||
const curNode = this._currentNode();
|
||||
if (isNodeWithChildren(curNode)) {
|
||||
curNode.children.push(colorPickerNode);
|
||||
}
|
||||
}
|
||||
|
||||
command(node: Omit<PieCommandNodeModel, 'type'>) {
|
||||
const curNode = this._currentNode();
|
||||
const actionNode: PieCommandNodeModel = { ...node, type: 'command' };
|
||||
|
||||
if (isNodeWithChildren(curNode)) {
|
||||
curNode.children.push(actionNode);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
endSubmenu() {
|
||||
if (this._stack.length === 1)
|
||||
throw new Error('Cant end submenu already on the root node');
|
||||
this._stack.pop();
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
expandableCommand(
|
||||
node: Omit<PieSubmenuNodeModel, 'type' | 'children' | 'role'> & {
|
||||
action: ActionFunction;
|
||||
submenus: (pie: PieMenuBuilder) => void;
|
||||
}
|
||||
) {
|
||||
const { icon, label } = node;
|
||||
this.beginSubmenu({ icon, label }, node.action);
|
||||
node.submenus(this);
|
||||
this.endSubmenu();
|
||||
}
|
||||
|
||||
reset(base: PieBuilderConstructorProps) {
|
||||
this._stack = [];
|
||||
this._schema = {
|
||||
...base,
|
||||
root: { type: 'root', children: [], label: base.label },
|
||||
};
|
||||
|
||||
this._stack.push(this._schema.root);
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
import { Slot } from '@blocksuite/store';
|
||||
|
||||
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
|
||||
import type { PieMenuId } from '../../types.js';
|
||||
import type { PieMenuSchema } from './base.js';
|
||||
|
||||
/**
|
||||
* Static class for managing pie menus
|
||||
*/
|
||||
|
||||
export class PieManager {
|
||||
private static registeredSchemas: Record<string, PieMenuSchema> = {};
|
||||
|
||||
private static readonly schemas = new Set<PieMenuSchema>();
|
||||
|
||||
static settings = {
|
||||
/**
|
||||
* Specifies the distance between the root-node and the child-nodes
|
||||
*/
|
||||
PIE_RADIUS: 150,
|
||||
/**
|
||||
* After the specified time if trigger is released the menu will select the currently hovered node\
|
||||
* If released before the time the pie menu will stay open and you can select with mouse or the trigger key\
|
||||
* Time is in `milliseconds`
|
||||
* @default 150
|
||||
*/
|
||||
SELECT_ON_RELEASE_TIMEOUT: 150,
|
||||
|
||||
/**
|
||||
* Distance from the center of the active node to start focusing a child node
|
||||
*/
|
||||
ACTIVATE_THRESHOLD_MIN: 60,
|
||||
|
||||
/**
|
||||
* Time delay to open submenu after hovering a submenu node
|
||||
*/
|
||||
SUBMENU_OPEN_TIMEOUT: 200,
|
||||
|
||||
EXPANDABLE_ACTION_NODE_TIMEOUT: 300,
|
||||
};
|
||||
|
||||
static slots = {
|
||||
open: new Slot<PieMenuSchema>(),
|
||||
};
|
||||
|
||||
private static _getSchema(id: string) {
|
||||
const schema = this.registeredSchemas[id];
|
||||
assertExists(schema);
|
||||
return schema;
|
||||
}
|
||||
|
||||
private static _register(schema: PieMenuSchema) {
|
||||
const { id } = schema;
|
||||
|
||||
if (this.registeredSchemas[id]) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.registeredSchemas[id] = schema;
|
||||
}
|
||||
|
||||
private static _setupTriggers(rootComponent: EdgelessRootBlockComponent) {
|
||||
Object.values(this.registeredSchemas).forEach(schema => {
|
||||
const { trigger } = schema;
|
||||
|
||||
rootComponent.handleEvent(
|
||||
'keyDown',
|
||||
ctx => {
|
||||
const ev = ctx.get('keyboardState');
|
||||
|
||||
if (trigger({ keyEvent: ev.raw, rootComponent }) && !ev.raw.repeat) {
|
||||
this.open(schema.id);
|
||||
}
|
||||
},
|
||||
{ global: true }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
static add(schema: PieMenuSchema) {
|
||||
return this.schemas.add(schema);
|
||||
}
|
||||
|
||||
static dispose() {
|
||||
this.registeredSchemas = {};
|
||||
}
|
||||
|
||||
static open(id: PieMenuId) {
|
||||
this.slots.open.emit(this._getSchema(id));
|
||||
}
|
||||
|
||||
static remove(schema: PieMenuSchema) {
|
||||
return this.schemas.delete(schema);
|
||||
}
|
||||
|
||||
static setup({
|
||||
rootComponent,
|
||||
}: {
|
||||
rootComponent: EdgelessRootBlockComponent;
|
||||
}) {
|
||||
this.schemas.forEach(schema => this._register(schema));
|
||||
this._setupTriggers(rootComponent);
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { css } from 'lit';
|
||||
|
||||
export const pieMenuStyles = css`
|
||||
.menu-container {
|
||||
user-select: none;
|
||||
z-index: var(--affine-z-index-popover);
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.pie-menu-container > .overlay {
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
position: fixed;
|
||||
z-index: var(--affine-z-index-popover);
|
||||
}
|
||||
|
||||
.pie-menu {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
box-sizing: border-box;
|
||||
z-index: calc(
|
||||
var(--affine-z-index-popover) + 10
|
||||
); /* This is important or else will hover will not work */
|
||||
}
|
||||
`;
|
||||
|
||||
export const pieNodeStyles = css`
|
||||
.pie-node {
|
||||
position: absolute;
|
||||
background: var(--affine-background-overlay-panel-color);
|
||||
user-select: none;
|
||||
box-shadow: var(--affine-shadow-2);
|
||||
border: 2px solid var(--affine-border-color);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
font-size: 0.8rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
transition: all 250ms cubic-bezier(0.775, 1.325, 0.535, 1);
|
||||
}
|
||||
|
||||
@keyframes my-anim {
|
||||
0% {
|
||||
transform: translate(0, 0);
|
||||
opacity: 0;
|
||||
}
|
||||
40% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 100;
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -1,346 +0,0 @@
|
||||
import { EditPropsStore } from '@blocksuite/affine-shared/services';
|
||||
import type { ToolController } from '@blocksuite/block-std/gfx';
|
||||
import type { IVec } from '@blocksuite/global/utils';
|
||||
|
||||
import { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
|
||||
import { ShapeTool } from '../../edgeless/gfx-tool/shape-tool.js';
|
||||
import type {
|
||||
ActionFunction,
|
||||
IPieNodeWithAction,
|
||||
PieColorNodeModel,
|
||||
PieCommandNodeModel,
|
||||
PieMenuContext,
|
||||
PieNodeModel,
|
||||
PieNonRootNode,
|
||||
PieRootNodeModel,
|
||||
PieSubmenuNodeModel,
|
||||
} from './base.js';
|
||||
|
||||
export function updateShapeOverlay(rootComponent: EdgelessRootBlockComponent) {
|
||||
const controller = rootComponent.gfx.tool.currentTool$.peek();
|
||||
if (controller instanceof ShapeTool) {
|
||||
controller.createOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
export function getActiveShapeColor(type: 'fill' | 'stroke') {
|
||||
return ({ rootComponent }: PieMenuContext) => {
|
||||
if (rootComponent instanceof EdgelessRootBlockComponent) {
|
||||
const props =
|
||||
rootComponent.std.get(EditPropsStore).lastProps$.value[
|
||||
'shape:roundedRect'
|
||||
];
|
||||
const color = type == 'fill' ? props.fillColor : props.strokeColor;
|
||||
return color.toString();
|
||||
}
|
||||
return '';
|
||||
};
|
||||
}
|
||||
|
||||
export function getActiveConnectorStrokeColor({
|
||||
rootComponent,
|
||||
}: PieMenuContext) {
|
||||
if (rootComponent instanceof EdgelessRootBlockComponent) {
|
||||
const props =
|
||||
rootComponent.std.get(EditPropsStore).lastProps$.value.connector;
|
||||
const color = props.stroke;
|
||||
return color.toString();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function setEdgelessToolAction(
|
||||
callback: (tool: ToolController) => void
|
||||
): ActionFunction {
|
||||
return ({ rootComponent }) => {
|
||||
callback(rootComponent.gfx.tool);
|
||||
};
|
||||
}
|
||||
|
||||
export function getPosition(angleRad: number, v: IVec): IVec {
|
||||
const x = Math.cos(angleRad) * v[0];
|
||||
const y = Math.sin(angleRad) * v[1];
|
||||
return [x, y];
|
||||
}
|
||||
|
||||
export function isNodeWithChildren(
|
||||
node: PieNodeModel
|
||||
): node is PieNodeModel & { children: PieNonRootNode[] } {
|
||||
return 'children' in node;
|
||||
}
|
||||
|
||||
export function isRootNode(model: PieNodeModel): model is PieRootNodeModel {
|
||||
return model.type === 'root';
|
||||
}
|
||||
|
||||
export function isSubmenuNode(
|
||||
model: PieNodeModel
|
||||
): model is PieSubmenuNodeModel {
|
||||
return model.type === 'submenu';
|
||||
}
|
||||
|
||||
export function isCommandNode(
|
||||
model: PieNodeModel
|
||||
): model is PieCommandNodeModel {
|
||||
return model.type === 'command';
|
||||
}
|
||||
|
||||
export function isColorNode(model: PieNodeModel): model is PieColorNodeModel {
|
||||
return model.type === 'color';
|
||||
}
|
||||
|
||||
export function isNodeWithAction(
|
||||
node: PieNodeModel
|
||||
): node is IPieNodeWithAction {
|
||||
return 'action' in node && typeof node.action === 'function';
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------------
|
||||
// credits: https://github.com/kando-menu/kando/blob/main/src/renderer/math/index.ts
|
||||
//------------------------------------------------------------------------------------
|
||||
export function calcNodeAngles(
|
||||
node: { angle?: number }[],
|
||||
parentAngle?: number
|
||||
): number[] {
|
||||
const nodeAngles: number[] = [];
|
||||
|
||||
// Shouldn't happen, but who knows...
|
||||
if (node.length == 0) {
|
||||
return nodeAngles;
|
||||
}
|
||||
|
||||
// We begin by storing all fixed angles.
|
||||
const fixedAngles: { angle: number; index: number }[] = [];
|
||||
node.forEach((item, index) => {
|
||||
if (item.angle && item.angle >= 0) {
|
||||
fixedAngles.push({ angle: item.angle, index: index });
|
||||
}
|
||||
});
|
||||
|
||||
// Make sure that the parent link does not collide with a fixed item. For now, we
|
||||
// just move the fixed angle a tiny bit. This is somewhat error-prone as it may
|
||||
// collide with another fixed angle now. Maybe this could be solved in a better way?
|
||||
// Maybe some global minimum angular spacing of items?
|
||||
if (parentAngle != undefined) {
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-for-of
|
||||
for (let i = 0; i < fixedAngles.length; i++) {
|
||||
if (Math.abs(fixedAngles[i].angle - parentAngle) < 0.0001) {
|
||||
fixedAngles[i].angle += 0.1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Make sure that the fixed angles are between 0° and 360°.
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-for-of
|
||||
for (let i = 0; i < fixedAngles.length; i++) {
|
||||
fixedAngles[i].angle = fixedAngles[i].angle % 360;
|
||||
}
|
||||
|
||||
// Make sure that the fixed angles increase monotonically. If a fixed angle is larger
|
||||
// than the next one, the next one will be ignored.
|
||||
for (let i = 0; i < fixedAngles.length - 1; ) {
|
||||
if (fixedAngles[i].angle > fixedAngles[i + 1].angle) {
|
||||
fixedAngles.splice(i + 1, 1);
|
||||
} else {
|
||||
++i;
|
||||
}
|
||||
}
|
||||
|
||||
// If no item has a fixed angle, we assign one to the first item. If there is no
|
||||
// parent item, this is on the top (0°). Else, the angular space will be evenly
|
||||
// distributed to all child items and the first item will be at the first possible
|
||||
// location with an angle > 0.
|
||||
if (fixedAngles.length == 0) {
|
||||
let firstAngle = 0;
|
||||
if (parentAngle != undefined) {
|
||||
const wedgeSize = 360 / (node.length + 1);
|
||||
let minAngle = 360;
|
||||
for (let i = 0; i < node.length; i++) {
|
||||
minAngle = Math.min(
|
||||
minAngle,
|
||||
(parentAngle + (i + 1) * wedgeSize) % 360
|
||||
);
|
||||
}
|
||||
firstAngle = minAngle;
|
||||
}
|
||||
fixedAngles.push({ angle: firstAngle, index: 0 });
|
||||
nodeAngles[0] = firstAngle;
|
||||
}
|
||||
|
||||
// Now we iterate through the fixed angles, always considering wedges between
|
||||
// consecutive pairs of fixed angles. If there is only one fixed angle, there is also
|
||||
// only one 360°-wedge.
|
||||
for (let i = 0; i < fixedAngles.length; i++) {
|
||||
const wedgeBeginIndex = fixedAngles[i].index;
|
||||
const wedgeBeginAngle = fixedAngles[i].angle;
|
||||
const wedgeEndIndex = fixedAngles[(i + 1) % fixedAngles.length].index;
|
||||
let wedgeEndAngle = fixedAngles[(i + 1) % fixedAngles.length].angle;
|
||||
|
||||
// The fixed angle can be stored in our output.
|
||||
nodeAngles[wedgeBeginIndex] = wedgeBeginAngle;
|
||||
|
||||
// Make sure we loop around.
|
||||
if (wedgeEndAngle <= wedgeBeginAngle) {
|
||||
wedgeEndAngle += 360;
|
||||
}
|
||||
|
||||
// Calculate the number of items between the begin and end indices.
|
||||
let wedgeItemCount =
|
||||
(wedgeEndIndex - wedgeBeginIndex - 1 + node.length) % node.length;
|
||||
|
||||
// We have one item more if the parent link is inside our wedge.
|
||||
let parentInWedge = false;
|
||||
|
||||
if (parentAngle != undefined) {
|
||||
// It can be that the parent link is inside the current wedge, but it's angle is
|
||||
// one full turn off.
|
||||
if (parentAngle < wedgeBeginAngle) {
|
||||
parentAngle += 360;
|
||||
}
|
||||
|
||||
parentInWedge =
|
||||
parentAngle > wedgeBeginAngle && parentAngle < wedgeEndAngle;
|
||||
if (parentInWedge) {
|
||||
wedgeItemCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate the angular difference between consecutive items in the current wedge.
|
||||
const wedgeItemGap =
|
||||
(wedgeEndAngle - wedgeBeginAngle) / (wedgeItemCount + 1);
|
||||
|
||||
// Now we assign an angle to each item between the begin and end indices.
|
||||
let index = (wedgeBeginIndex + 1) % node.length;
|
||||
let count = 1;
|
||||
let parentGapRequired = parentInWedge;
|
||||
|
||||
while (index != wedgeEndIndex) {
|
||||
let itemAngle = wedgeBeginAngle + wedgeItemGap * count;
|
||||
|
||||
// Insert gap for parent link if required. for connector
|
||||
if (
|
||||
parentGapRequired &&
|
||||
itemAngle + wedgeItemGap / 2 - (parentAngle ?? 0) > 0
|
||||
) {
|
||||
count += 1;
|
||||
itemAngle = wedgeBeginAngle + wedgeItemGap * count;
|
||||
parentGapRequired = false;
|
||||
}
|
||||
|
||||
nodeAngles[index] = itemAngle % 360;
|
||||
|
||||
index = (index + 1) % node.length;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return nodeAngles;
|
||||
}
|
||||
|
||||
export function calcNodeWedges(
|
||||
nodeAngles: number[],
|
||||
parentAngle?: number
|
||||
): { start: number; end: number }[] {
|
||||
// This should never happen, but who knows...
|
||||
if (nodeAngles.length === 0 && parentAngle === undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// If the node has a single child but no parent (e.g. it's the root node), we can
|
||||
// simply return a full circle.
|
||||
if (nodeAngles.length === 1 && parentAngle === undefined) {
|
||||
return [{ start: 0, end: 360 }];
|
||||
}
|
||||
|
||||
// If the node has a single child and a parent, we can set the start and end
|
||||
// angles to the center angles.
|
||||
if (nodeAngles.length === 1 && parentAngle !== undefined) {
|
||||
let start = parentAngle;
|
||||
let center = nodeAngles[0];
|
||||
let end = parentAngle + 360;
|
||||
|
||||
[start, center, end] = normalizeConsecutiveAngles(start, center, end);
|
||||
[start, end] = scaleWedge(start, center, end, 0.5);
|
||||
|
||||
return [{ start: start, end: end }];
|
||||
}
|
||||
|
||||
// In all other cases, we loop through the items and compute the wedges. If the parent
|
||||
// angle happens to be inside a wedge, we crop the wedge accordingly.
|
||||
const wedges: { start: number; end: number }[] = [];
|
||||
|
||||
for (let i = 0; i < nodeAngles.length; i++) {
|
||||
let start = nodeAngles[(i + nodeAngles.length - 1) % nodeAngles.length];
|
||||
let center = nodeAngles[i];
|
||||
let end = nodeAngles[(i + 1) % nodeAngles.length];
|
||||
|
||||
[start, center, end] = normalizeConsecutiveAngles(start, center, end);
|
||||
|
||||
if (parentAngle !== undefined) {
|
||||
[start, end] = cropWedge(start, center, end, parentAngle);
|
||||
[start, center, end] = normalizeConsecutiveAngles(start, center, end);
|
||||
}
|
||||
|
||||
[start, end] = scaleWedge(start, center, end, 0.5);
|
||||
|
||||
wedges.push({ start: start, end: end });
|
||||
}
|
||||
|
||||
return wedges;
|
||||
}
|
||||
export function isAngleBetween(
|
||||
angle: number,
|
||||
start: number,
|
||||
end: number
|
||||
): boolean {
|
||||
return (
|
||||
(angle > start && angle <= end) ||
|
||||
(angle - 360 > start && angle - 360 <= end) ||
|
||||
(angle + 360 > start && angle + 360 <= end)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeConsecutiveAngles(
|
||||
start: number,
|
||||
center: number,
|
||||
end: number
|
||||
) {
|
||||
while (center < start) {
|
||||
center += 360;
|
||||
}
|
||||
|
||||
while (end < center) {
|
||||
end += 360;
|
||||
}
|
||||
|
||||
while (center >= 360) {
|
||||
start -= 360;
|
||||
center -= 360;
|
||||
end -= 360;
|
||||
}
|
||||
|
||||
return [start, center, end];
|
||||
}
|
||||
|
||||
function cropWedge(
|
||||
start: number,
|
||||
center: number,
|
||||
end: number,
|
||||
cropAngle: number
|
||||
) {
|
||||
if (isAngleBetween(cropAngle, start, center)) {
|
||||
start = cropAngle;
|
||||
}
|
||||
|
||||
if (isAngleBetween(cropAngle, center, end)) {
|
||||
end = cropAngle;
|
||||
}
|
||||
|
||||
return [start, end];
|
||||
}
|
||||
function scaleWedge(start: number, center: number, end: number, scale: number) {
|
||||
start = center - (center - start) * scale;
|
||||
end = center + (end - center) * scale;
|
||||
|
||||
return [start, end];
|
||||
}
|
||||
Reference in New Issue
Block a user