chore(editor): remove pie menu (#9394)

This commit is contained in:
Saul-Mirone
2024-12-27 12:32:42 +00:00
parent 003ce4c9e9
commit 76d9712f21
17 changed files with 0 additions and 2136 deletions

View File

@@ -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
)}`,

View File

@@ -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;

View File

@@ -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,

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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();

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}
`;

View File

@@ -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];
}