refactor(editor): move mindmap view to mindmap package (#11102)

This commit is contained in:
Saul-Mirone
2025-03-24 03:14:22 +00:00
parent 8e08b9000d
commit 5525c2bc8d
26 changed files with 63 additions and 93 deletions

View File

@@ -1,57 +0,0 @@
import { TextUtils } from '@blocksuite/affine-block-surface';
import { FontFamily, FontFamilyList } from '@blocksuite/affine-model';
import { DoneIcon } from '@blocksuite/icons/lit';
import { css, html, LitElement, nothing } from 'lit';
import { property } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
export class EdgelessFontFamilyPanel extends LitElement {
static override styles = css`
:host {
display: flex;
align-items: start;
flex-direction: column;
min-width: 136px;
}
edgeless-tool-icon-button {
width: 100%;
}
`;
private _onSelect(value: FontFamily) {
this.value = value;
if (this.onSelect) {
this.onSelect(value);
}
}
override render() {
return repeat(
FontFamilyList,
item => item[0],
([font, name]) => {
const active = this.value === font;
return html`
<edgeless-tool-icon-button
data-font="${name}"
style="font-family: ${TextUtils.wrapFontFamily(font)}"
.iconContainerPadding=${[4, 8]}
.justify=${'space-between'}
.active=${active}
.iconSize=${'20px'}
@click=${() => this._onSelect(font)}
>
${name} ${active ? DoneIcon() : nothing}
</edgeless-tool-icon-button>
`;
}
);
}
@property({ attribute: false })
accessor onSelect: ((value: FontFamily) => void) | undefined = undefined;
@property({ attribute: false })
accessor value: FontFamily = FontFamily.Inter;
}

View File

@@ -1,163 +0,0 @@
import { TextUtils } from '@blocksuite/affine-block-surface';
import {
FontFamily,
FontFamilyMap,
FontStyle,
FontWeight,
} from '@blocksuite/affine-model';
import { DoneIcon } from '@blocksuite/icons/lit';
import { css, html, LitElement, nothing } from 'lit';
import { property } from 'lit/decorators.js';
import { choose } from 'lit/directives/choose.js';
import { join } from 'lit/directives/join.js';
import { repeat } from 'lit/directives/repeat.js';
const FONT_WEIGHT_CHOOSE: [FontWeight, () => string][] = [
[FontWeight.Light, () => 'Light'],
[FontWeight.Regular, () => 'Regular'],
[FontWeight.SemiBold, () => 'Semibold'],
];
export class EdgelessFontWeightAndStylePanel extends LitElement {
static override styles = css`
:host {
display: flex;
align-items: start;
flex-direction: column;
min-width: 124px;
}
edgeless-tool-icon-button {
width: 100%;
}
`;
private _isActive(
fontWeight: FontWeight,
fontStyle: FontStyle = FontStyle.Normal
) {
return this.fontWeight === fontWeight && this.fontStyle === fontStyle;
}
private _isDisabled(
fontWeight: FontWeight,
fontStyle: FontStyle = FontStyle.Normal
) {
// Compatible with old data
if (!(this.fontFamily in FontFamilyMap)) return false;
const fontFace = TextUtils.getFontFaces()
.filter(TextUtils.isSameFontFamily(this.fontFamily))
.find(
fontFace =>
fontFace.weight === fontWeight && fontFace.style === fontStyle
);
return !fontFace;
}
private _onSelect(
fontWeight: FontWeight,
fontStyle: FontStyle = FontStyle.Normal
) {
this.fontWeight = fontWeight;
this.fontStyle = fontStyle;
if (this.onSelect) {
this.onSelect(fontWeight, fontStyle);
}
}
override render() {
let fontFaces = TextUtils.getFontFacesByFontFamily(this.fontFamily);
// Compatible with old data
if (fontFaces.length === 0) {
fontFaces = TextUtils.getFontFacesByFontFamily(FontFamily.Inter);
}
const fontFacesWithNormal = fontFaces.filter(
fontFace => fontFace.style === FontStyle.Normal
);
const fontFacesWithItalic = fontFaces.filter(
fontFace => fontFace.style === FontStyle.Italic
);
return join(
[
fontFacesWithNormal.length > 0
? repeat(
fontFacesWithNormal,
fontFace => fontFace.weight,
fontFace => {
const active = this._isActive(fontFace.weight as FontWeight);
return html`
<edgeless-tool-icon-button
data-weight="${fontFace.weight}"
.iconContainerPadding=${[4, 8]}
.justify=${'space-between'}
.disabled=${this._isDisabled(fontFace.weight as FontWeight)}
.active=${active}
.iconSize=${'20px'}
@click=${() =>
this._onSelect(fontFace.weight as FontWeight)}
>
${choose(fontFace.weight, FONT_WEIGHT_CHOOSE)}
${active ? DoneIcon() : nothing}
</edgeless-tool-icon-button>
`;
}
)
: nothing,
fontFacesWithItalic.length > 0
? repeat(
fontFacesWithItalic,
fontFace => fontFace.weight,
fontFace => {
const active = this._isActive(
fontFace.weight as FontWeight,
FontStyle.Italic
);
return html`
<edgeless-tool-icon-button
data-weight="${fontFace.weight} italic"
.iconContainerPadding=${[4, 8]}
.justify=${'space-between'}
.disabled=${this._isDisabled(
fontFace.weight as FontWeight,
FontStyle.Italic
)}
.active=${active}
@click=${() =>
this._onSelect(
fontFace.weight as FontWeight,
FontStyle.Italic
)}
>
${choose(fontFace.weight, FONT_WEIGHT_CHOOSE)} Italic
${active ? DoneIcon() : nothing}
</edgeless-tool-icon-button>
`;
}
)
: nothing,
].filter(item => item !== nothing),
() => html`
<edgeless-menu-divider
data-orientation="horizontal"
></edgeless-menu-divider>
`
);
}
@property({ attribute: false })
accessor fontFamily = FontFamily.Inter;
@property({ attribute: false })
accessor fontStyle = FontStyle.Normal;
@property({ attribute: false })
accessor fontWeight = FontWeight.Regular;
@property({ attribute: false })
accessor onSelect:
| ((fontWeight: FontWeight, fontStyle: FontStyle) => void)
| undefined;
}

View File

@@ -5,6 +5,7 @@ import {
} from '@blocksuite/affine-block-frame';
import { ConnectionOverlay } from '@blocksuite/affine-block-surface';
import { ConnectorTool } from '@blocksuite/affine-gfx-connector';
import { MindMapIndicatorOverlay } from '@blocksuite/affine-gfx-mindmap';
import { NoteTool } from '@blocksuite/affine-gfx-note';
import { ShapeTool } from '@blocksuite/affine-gfx-shape';
import { TextTool } from '@blocksuite/affine-gfx-text';
@@ -19,7 +20,6 @@ import { EdgelessRootBlockSpec } from './edgeless-root-spec.js';
import { ConnectorFilter } from './element-transform/connector-filter.js';
import { MindMapDragExtension } from './element-transform/mind-map-drag.js';
import { SnapExtension } from './element-transform/snap-manager.js';
import { MindMapIndicatorOverlay } from './element-transform/utils/indicator-overlay.js';
import { BrushTool } from './gfx-tool/brush-tool.js';
import { DefaultTool } from './gfx-tool/default-tool.js';
import { EmptyTool } from './gfx-tool/empty-tool.js';

View File

@@ -1,3 +1,5 @@
import { ConnectorElementView } from '@blocksuite/affine-gfx-connector';
import { MindMapView } from '@blocksuite/affine-gfx-mindmap';
import { ViewportElementExtension } from '@blocksuite/affine-shared/services';
import { autoConnectWidget } from '@blocksuite/affine-widget-edgeless-auto-connect';
import { edgelessToolbarWidget } from '@blocksuite/affine-widget-edgeless-toolbar';
@@ -59,6 +61,8 @@ const EdgelessCommonExtension: ExtensionType[] = [
ToolController,
EdgelessRootService,
ViewportElementExtension('.affine-edgeless-viewport'),
MindMapView,
ConnectorElementView,
...quickTools,
...seniorTools,
].flat();

View File

@@ -1,10 +1,18 @@
import {
MindmapUtils,
NODE_HORIZONTAL_SPACING,
NODE_VERTICAL_SPACING,
OverlayIdentifier,
type SurfaceBlockComponent,
} from '@blocksuite/affine-block-surface';
import {
containsNode,
createFromTree,
detachMindmap,
findTargetNode,
hideNodeConnector,
type MindMapIndicatorOverlay,
NODE_HORIZONTAL_SPACING,
NODE_VERTICAL_SPACING,
tryMoveNode,
} from '@blocksuite/affine-gfx-mindmap';
import {
type LayoutType,
type LocalConnectorElementModel,
@@ -26,7 +34,6 @@ import type { Bound, IVec } from '@blocksuite/global/gfx';
import { isSingleMindMapNode } from '../utils/mindmap';
import { isMindmapNode } from '../utils/query';
import { calculateResponseArea } from './utils/drag-utils';
import type { MindMapIndicatorOverlay } from './utils/indicator-overlay';
type DragMindMapCtx = {
mindmap: MindmapElementModel;
@@ -89,7 +96,7 @@ export class MindMapDragExtension extends TransformExtension {
hoveredCtx?.abort?.();
const hoveredNode = hoveredMindMap
? MindmapUtils.findTargetNode(hoveredMindMap, [x, y])
? findTargetNode(hoveredMindMap, [x, y])
: null;
hoveredCtx = {
@@ -104,13 +111,9 @@ export class MindMapDragExtension extends TransformExtension {
if (
hoveredNode &&
hoveredMindMap &&
!MindmapUtils.containsNode(
hoveredMindMap,
hoveredNode,
dragMindMapCtx.node
)
!containsNode(hoveredMindMap, hoveredNode, dragMindMapCtx.node)
) {
const operation = MindmapUtils.tryMoveNode(
const operation = tryMoveNode(
hoveredMindMap,
hoveredNode,
dragMindMapCtx.mindmap,
@@ -156,7 +159,7 @@ export class MindMapDragExtension extends TransformExtension {
} else {
hoveredCtx.detach = true;
const reset = (hoveredCtx.abort = MindmapUtils.hideNodeConnector(
const reset = (hoveredCtx.abort = hideNodeConnector(
dragMindMapCtx.mindmap,
dragMindMapCtx.node
));
@@ -183,11 +186,8 @@ export class MindMapDragExtension extends TransformExtension {
.serialize();
if (dragMindMapCtx.node !== dragMindMapCtx.mindmap.tree) {
MindmapUtils.detachMindmap(
dragMindMapCtx.mindmap,
dragMindMapCtx.node
);
const mindmap = MindmapUtils.createFromTree(
detachMindmap(dragMindMapCtx.mindmap, dragMindMapCtx.node);
const mindmap = createFromTree(
dragMindMapCtx.node,
dragMindMapCtx.mindmap.style,
dragMindMapCtx.mindmap.layoutType,

View File

@@ -1,7 +1,7 @@
import {
NODE_HORIZONTAL_SPACING,
NODE_VERTICAL_SPACING,
} from '@blocksuite/affine-block-surface';
} from '@blocksuite/affine-gfx-mindmap';
import {
LayoutType,
type MindmapElementModel,

View File

@@ -1,303 +0,0 @@
import {
NODE_HORIZONTAL_SPACING,
NODE_VERTICAL_SPACING,
Overlay,
PathGenerator,
} from '@blocksuite/affine-block-surface';
import {
ConnectorMode,
LayoutType,
type MindmapElementModel,
type MindmapNode,
} from '@blocksuite/affine-model';
import { ThemeProvider } from '@blocksuite/affine-shared/services';
import {
type Bound,
isVecZero,
type IVec,
PointLocation,
toRadian,
Vec,
} from '@blocksuite/global/gfx';
import last from 'lodash-es/last';
export class MindMapIndicatorOverlay extends Overlay {
static INDICATOR_SIZE = [48, 22];
static override overlayName: string = 'mindmap-indicator';
currentDragPos: IVec | null = null;
direction: LayoutType.LEFT | LayoutType.RIGHT = LayoutType.RIGHT;
dragNodeImage: HTMLCanvasElement | null = null;
dragNodePos: IVec = [0, 0];
mode: ConnectorMode = ConnectorMode.Straight;
parentBound: Bound | null = null;
pathGen = new PathGenerator();
targetBound: Bound | null = null;
get themeService() {
return this.gfx.std.get(ThemeProvider);
}
private _generatePath() {
const startRelativePos =
this.direction === LayoutType.RIGHT
? PointLocation.fromVec([1, 0.5])
: PointLocation.fromVec([0, 0.5]);
const endRelativePos =
this.direction === LayoutType.RIGHT
? PointLocation.fromVec([0, 0.5])
: PointLocation.fromVec([1, 0.5]);
const { parentBound, targetBound: newPosBound } = this;
if (this.mode === ConnectorMode.Orthogonal) {
return this.pathGen
.generateOrthogonalConnectorPath({
startPoint: this._getRelativePoint(parentBound!, startRelativePos),
endPoint: this._getRelativePoint(newPosBound!, endRelativePos),
startBound: parentBound,
endBound: newPosBound,
})
.map(p => new PointLocation(p));
} else if (this.mode === ConnectorMode.Curve) {
const startPoint = this._getRelativePoint(
this.parentBound!,
startRelativePos
);
const endPoint = this._getRelativePoint(
this.targetBound!,
endRelativePos
);
const startTangentVertical = Vec.rot(startPoint.tangent, -Math.PI / 2);
startPoint.out = Vec.mul(
startTangentVertical,
Math.max(
100,
Math.abs(
Vec.pry(Vec.sub(endPoint, startPoint), startTangentVertical)
) / 3
)
);
const endTangentVertical = Vec.rot(endPoint.tangent, -Math.PI / 2);
endPoint.in = Vec.mul(
endTangentVertical,
Math.max(
100,
Math.abs(Vec.pry(Vec.sub(startPoint, endPoint), endTangentVertical)) /
3
)
);
return [startPoint, endPoint];
} else {
const startPoint = new PointLocation(
this.parentBound!.getRelativePoint(startRelativePos)
);
const endPoint = new PointLocation(
this.targetBound!.getRelativePoint(endRelativePos)
);
return [startPoint, endPoint];
}
}
private _getRelativePoint(bound: Bound, position: IVec) {
const location = new PointLocation(
bound.getRelativePoint(position as IVec)
);
if (isVecZero(Vec.sub(position, [0, 0.5])))
location.tangent = Vec.rot([0, -1], toRadian(0));
else if (isVecZero(Vec.sub(position, [1, 0.5])))
location.tangent = Vec.rot([0, 1], toRadian(0));
else if (isVecZero(Vec.sub(position, [0.5, 0])))
location.tangent = Vec.rot([1, 0], toRadian(0));
else if (isVecZero(Vec.sub(position, [0.5, 1])))
location.tangent = Vec.rot([-1, 0], toRadian(0));
return location;
}
/**
* Use to calculate the position of the indicator given its sibling's bound
* @param siblingBound
* @param direction
*/
private _moveRelativeToBound(
siblingBound: Bound,
direction: 'up' | 'down',
layoutDir: Exclude<LayoutType, LayoutType.BALANCE>
) {
const isLeftLayout = layoutDir === LayoutType.LEFT;
const isUpDirection = direction === 'up';
return siblingBound.moveDelta(
isLeftLayout
? siblingBound.w - MindMapIndicatorOverlay.INDICATOR_SIZE[0]
: 0,
isUpDirection
? -(
NODE_VERTICAL_SPACING / 2 +
MindMapIndicatorOverlay.INDICATOR_SIZE[1] / 2
)
: siblingBound.h +
NODE_VERTICAL_SPACING / 2 -
MindMapIndicatorOverlay.INDICATOR_SIZE[1] / 2
);
}
override clear() {
this.targetBound = null;
this.parentBound = null;
}
override render(ctx: CanvasRenderingContext2D): void {
if (this.currentDragPos && this.dragNodeImage) {
ctx.save();
ctx.globalAlpha = 0.3;
ctx.drawImage(
this.dragNodeImage,
this.currentDragPos[0] + this.dragNodePos[0],
this.currentDragPos[1] + this.dragNodePos[1],
this.dragNodeImage.width / 2,
this.dragNodeImage.height / 2
);
ctx.restore();
}
if (!this.parentBound || !this.targetBound) {
return;
}
const targetPos = this.targetBound;
const points = this._generatePath();
const color = this.themeService.getColorValue(
'--affine-primary-color',
'#1E96EB',
true
);
ctx.strokeStyle = color;
ctx.fillStyle = color;
ctx.lineWidth = 3;
ctx.beginPath();
ctx.roundRect(targetPos.x, targetPos.y, targetPos.w, targetPos.h, 4);
ctx.fill();
ctx.beginPath();
ctx.moveTo(points[0][0], points[0][1]);
if (this.mode === ConnectorMode.Curve) {
points.forEach((point, idx) => {
if (idx === 0) return;
const last = points[idx - 1];
ctx.bezierCurveTo(
last.absOut[0],
last.absOut[1],
point.absIn[0],
point.absIn[1],
point[0],
point[1]
);
});
} else {
points.forEach((point, idx) => {
if (idx === 0) return;
ctx.lineTo(point[0], point[1]);
});
}
ctx.stroke();
ctx.closePath();
}
setIndicatorInfo(options: {
targetMindMap: MindmapElementModel;
target: MindmapNode;
parent: MindmapNode;
parentChildren: MindmapNode[];
insertPosition:
| {
type: 'sibling';
layoutDir: Exclude<LayoutType, LayoutType.BALANCE>;
position: 'prev' | 'next';
}
| { type: 'child'; layoutDir: Exclude<LayoutType, LayoutType.BALANCE> };
path: number[];
}) {
const {
insertPosition,
parent,
parentChildren,
targetMindMap,
target,
path,
} = options;
const parentBound = parent.element.elementBound;
const isBalancedMindMap = targetMindMap.layoutType === LayoutType.BALANCE;
const isLeftLayout = insertPosition.layoutDir === LayoutType.LEFT;
const isFirstLevel = path.length === 2;
this.direction = insertPosition.layoutDir;
this.parentBound = parentBound;
if (insertPosition.type === 'sibling') {
const targetBound = target.element.elementBound;
this.targetBound =
isBalancedMindMap && isFirstLevel && isLeftLayout
? this._moveRelativeToBound(
targetBound,
insertPosition.position === 'next' ? 'up' : 'down',
insertPosition.layoutDir
)
: this._moveRelativeToBound(
targetBound,
insertPosition.position === 'next' ? 'down' : 'up',
insertPosition.layoutDir
);
} else {
if (parentChildren.length === 0 || parent.detail.collapsed) {
this.targetBound = parentBound.moveDelta(
(isLeftLayout ? -1 : 1) *
(NODE_HORIZONTAL_SPACING / 2 + parentBound.w),
parentBound.h / 2 - MindMapIndicatorOverlay.INDICATOR_SIZE[1] / 2
);
} else {
const lastChildBound = last(parentChildren)!.element.elementBound;
this.targetBound =
isBalancedMindMap && isFirstLevel && isLeftLayout
? this._moveRelativeToBound(
lastChildBound,
'up',
insertPosition.layoutDir
)
: this._moveRelativeToBound(
lastChildBound,
'down',
insertPosition.layoutDir
);
}
}
this.targetBound.w = MindMapIndicatorOverlay.INDICATOR_SIZE[0];
this.targetBound.h = MindMapIndicatorOverlay.INDICATOR_SIZE[1];
this.mode = targetMindMap.styleGetter.getNodeStyle(
target,
options.path
).connector.mode;
}
}

View File

@@ -11,8 +11,6 @@ import {
NOTE_SLICER_WIDGET,
NoteSlicer,
} from './edgeless/components/note-slicer/index.js';
import { EdgelessFontFamilyPanel } from './edgeless/components/panel/font-family-panel.js';
import { EdgelessFontWeightAndStylePanel } from './edgeless/components/panel/font-weight-and-style-panel.js';
import { EdgelessScalePanel } from './edgeless/components/panel/scale-panel.js';
import { EdgelessSizePanel } from './edgeless/components/panel/size-panel.js';
import { StrokeStylePanel } from './edgeless/components/panel/stroke-style-panel.js';
@@ -151,13 +149,8 @@ function registerEdgelessToolbarComponents() {
}
function registerEdgelessPanelComponents() {
customElements.define(
'edgeless-font-weight-and-style-panel',
EdgelessFontWeightAndStylePanel
);
customElements.define('edgeless-size-panel', EdgelessSizePanel);
customElements.define('edgeless-scale-panel', EdgelessScalePanel);
customElements.define('edgeless-font-family-panel', EdgelessFontFamilyPanel);
customElements.define('stroke-style-panel', StrokeStylePanel);
}
@@ -217,8 +210,6 @@ declare global {
'edgeless-auto-complete-panel': EdgelessAutoCompletePanel;
'edgeless-auto-complete': EdgelessAutoComplete;
'note-slicer': NoteSlicer;
'edgeless-font-family-panel': EdgelessFontFamilyPanel;
'edgeless-font-weight-and-style-panel': EdgelessFontWeightAndStylePanel;
'edgeless-scale-panel': EdgelessScalePanel;
'edgeless-size-panel': EdgelessSizePanel;
'stroke-style-panel': StrokeStylePanel;