feat(editor): create gfx mindmap package (#11100)

This commit is contained in:
Saul-Mirone
2025-03-24 03:14:22 +00:00
parent ed888d6507
commit 8e08b9000d
29 changed files with 292 additions and 135 deletions

View File

@@ -0,0 +1,50 @@
{
"name": "@blocksuite/affine-gfx-mindmap",
"description": "Gfx mindmap for BlockSuite.",
"type": "module",
"scripts": {
"build": "tsc"
},
"sideEffects": false,
"keywords": [],
"author": "toeverything",
"license": "MIT",
"dependencies": {
"@blocksuite/affine-block-attachment": "workspace:*",
"@blocksuite/affine-block-edgeless-text": "workspace:*",
"@blocksuite/affine-block-image": "workspace:*",
"@blocksuite/affine-block-surface": "workspace:*",
"@blocksuite/affine-components": "workspace:*",
"@blocksuite/affine-gfx-text": "workspace:*",
"@blocksuite/affine-model": "workspace:*",
"@blocksuite/affine-rich-text": "workspace:*",
"@blocksuite/affine-shared": "workspace:*",
"@blocksuite/affine-widget-edgeless-toolbar": "workspace:*",
"@blocksuite/block-std": "workspace:*",
"@blocksuite/global": "workspace:*",
"@blocksuite/icons": "^2.2.6",
"@blocksuite/store": "workspace:*",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.12",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.21",
"minimatch": "^10.0.1",
"rxjs": "^7.8.1",
"simple-xml-to-json": "^1.2.2",
"yjs": "^13.6.21",
"zod": "^3.23.8"
},
"exports": {
".": "./src/index.ts",
"./effects": "./src/effects.ts"
},
"files": [
"src",
"dist",
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.20.0"
}

View File

@@ -0,0 +1,20 @@
import { MindMapPlaceholder } from './toolbar/mindmap-importing-placeholder';
import { EdgelessMindmapMenu } from './toolbar/mindmap-menu';
import { EdgelessMindmapToolButton } from './toolbar/mindmap-tool-button';
export function effects() {
customElements.define(
'edgeless-mindmap-tool-button',
EdgelessMindmapToolButton
);
customElements.define('edgeless-mindmap-menu', EdgelessMindmapMenu);
customElements.define('mindmap-import-placeholder', MindMapPlaceholder);
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-mindmap-tool-button': EdgelessMindmapToolButton;
'edgeless-mindmap-menu': EdgelessMindmapMenu;
'mindmap-import-placeholder': MindMapPlaceholder;
}
}

View File

@@ -0,0 +1 @@
export * from './toolbar/config';

View File

@@ -0,0 +1,46 @@
import { ColorScheme, MindmapStyle } from '@blocksuite/affine-model';
import type { TemplateResult } from 'lit';
import { type DraggableTool, getMindmapRender } from './basket-elements.js';
import {
mindMapStyle1Dark,
mindMapStyle1Light,
mindMapStyle2Dark,
mindMapStyle2Light,
mindMapStyle3,
mindMapStyle4,
} from './icons.js';
export type ToolbarMindmapItem = {
type: 'mindmap';
icon: TemplateResult;
style: MindmapStyle;
render: DraggableTool['render'];
};
export const getMindMaps = (theme: ColorScheme): ToolbarMindmapItem[] => [
{
type: 'mindmap',
icon: theme === ColorScheme.Dark ? mindMapStyle1Dark : mindMapStyle1Light,
style: MindmapStyle.ONE,
render: getMindmapRender(MindmapStyle.ONE),
},
{
type: 'mindmap',
icon: mindMapStyle4,
style: MindmapStyle.FOUR,
render: getMindmapRender(MindmapStyle.FOUR),
},
{
type: 'mindmap',
icon: mindMapStyle3,
style: MindmapStyle.THREE,
render: getMindmapRender(MindmapStyle.THREE),
},
{
type: 'mindmap',
icon: theme === 'light' ? mindMapStyle2Light : mindMapStyle2Dark,
style: MindmapStyle.TWO,
render: getMindmapRender(MindmapStyle.TWO),
},
];

View File

@@ -0,0 +1,206 @@
import { addAttachments } from '@blocksuite/affine-block-attachment';
import { insertEdgelessTextCommand } from '@blocksuite/affine-block-edgeless-text';
import { addImages } from '@blocksuite/affine-block-image';
import {
CanvasElementType,
EdgelessCRUDIdentifier,
} from '@blocksuite/affine-block-surface';
import { mountTextElementEditor } from '@blocksuite/affine-gfx-text';
import {
MAX_IMAGE_WIDTH,
type MindmapStyle,
TextElementModel,
} from '@blocksuite/affine-model';
import {
FeatureFlagService,
TelemetryProvider,
} from '@blocksuite/affine-shared/services';
import { openFileOrFiles } from '@blocksuite/affine-shared/utils';
import type { BlockComponent } from '@blocksuite/block-std';
import { Bound } from '@blocksuite/global/gfx';
import type { TemplateResult } from 'lit';
import * as Y from 'yjs';
export type ConfigProperty = 'x' | 'y' | 'r' | 's' | 'z' | 'o';
export type ConfigState = 'default' | 'active' | 'hover' | 'next';
export type ConfigStyle = Partial<Record<ConfigProperty, number | string>>;
export type ToolConfig = Record<ConfigState, ConfigStyle>;
export type DraggableTool = {
name: 'text' | 'mindmap' | 'media';
icon: TemplateResult;
config: ToolConfig;
standardWidth?: number;
render: (bound: Bound, edgeless: BlockComponent) => Promise<string | null>;
};
const unitMap = { x: 'px', y: 'px', r: 'deg', s: '', z: '', o: '' };
export const textConfig: ToolConfig = {
default: { x: -20, y: -8, r: 7.74, s: 0.92, z: 3 },
active: { x: -22, y: -9, r: -8, s: 0.92 },
hover: { x: -22, y: -9, r: -8, s: 1, z: 3 },
next: { x: -22, y: 64, r: 0 },
};
export const mindmapConfig: ToolConfig = {
default: { x: 4, y: -4, s: 1, r: -7, z: 2 },
active: { x: 11, y: -14, r: 9, s: 1 },
hover: { x: 11, y: -14, r: 9, s: 1.16, z: 3 },
next: { y: 64, r: 0 },
};
export const mediaConfig: ToolConfig = {
default: { x: -20, y: -15, r: 23, s: 1.2, z: 1 },
active: { x: -25, y: -20, r: -9, s: 1.2 },
hover: { x: -25, y: -20, r: -9, s: 1.5, z: 3 },
next: { y: 64, r: 0 },
};
export const getMindmapRender =
(mindmapStyle: MindmapStyle): DraggableTool['render'] =>
async (bound, edgeless) => {
const [x, y, _, h] = bound.toXYWH();
const rootW = 145;
const rootH = 50;
const nodeW = 80;
const nodeH = 35;
const centerVertical = y + h / 2;
const rootX = x;
const rootY = centerVertical - rootH / 2;
type MindMapNode = {
children: MindMapNode[];
text: string;
xywh: string;
};
const root: MindMapNode = {
children: [],
text: 'Mind Map',
xywh: `[${rootX},${rootY},${rootW},${rootH}]`,
};
for (let i = 0; i < 3; i++) {
const nodeX = x + rootW + 300;
const nodeY = centerVertical - nodeH / 2 + (i - 1) * 50;
root.children.push({
children: [],
text: 'Text',
xywh: `[${nodeX},${nodeY},${nodeW},${nodeH}]`,
});
}
const crud = edgeless.std.get(EdgelessCRUDIdentifier);
const mindmapId = crud.addElement('mindmap', {
style: mindmapStyle,
children: root,
}) as string;
edgeless.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', {
control: 'toolbar:dnd', // for now we use toolbar:dnd for all mindmap creation here
page: 'whiteboard editor',
module: 'toolbar',
segment: 'toolbar',
type: 'mindmap',
});
return mindmapId;
};
export const textRender: DraggableTool['render'] = async (bound, edgeless) => {
const vCenter = bound.y + bound.h / 2;
const w = 100;
const h = 32;
const flag = edgeless.doc
.get(FeatureFlagService)
.getFlag('enable_edgeless_text');
let id: string;
if (flag) {
const [_, { textId }] = edgeless.std.command.exec(
insertEdgelessTextCommand,
{
x: bound.x,
y: vCenter - h / 2,
}
);
id = textId!;
} else {
const crud = edgeless.std.get(EdgelessCRUDIdentifier);
id = crud.addElement(CanvasElementType.TEXT, {
xywh: new Bound(bound.x, vCenter - h / 2, w, h).serialize(),
text: new Y.Text(),
}) as string;
edgeless.doc.captureSync();
const textElement = crud.getElementById(id);
if (!(textElement instanceof TextElementModel)) {
console.error('Cannot mount text editor on a non-text element');
return null;
}
mountTextElementEditor(textElement, edgeless);
}
edgeless.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', {
control: 'toolbar:dnd',
page: 'whiteboard editor',
module: 'toolbar',
segment: 'toolbar',
type: 'text',
});
return id;
};
export const mediaRender: DraggableTool['render'] = async (bound, edgeless) => {
let file: File | null = null;
try {
file = await openFileOrFiles();
} catch (e) {
console.error(e);
return null;
}
if (!file) return null;
// image
if (file.type.startsWith('image/')) {
const [id] = await addImages(edgeless.std, [file], {
point: [bound.x, bound.y],
maxWidth: MAX_IMAGE_WIDTH,
transformPoint: false,
});
if (id) return id;
return null;
}
// attachment
const [id] = await addAttachments(
edgeless.std,
[file],
[bound.x, bound.y],
false
);
return id;
};
const toolStyle2StyleObj = (state: ConfigState, style: ConfigStyle = {}) => {
const styleObj = {} as Record<string, string>;
for (const [key, value] of Object.entries(style)) {
styleObj[`--${state}-${key}`] = `${value}${unitMap[key as ConfigProperty]}`;
}
return styleObj;
};
export const toolConfig2StyleObj = (config: ToolConfig) => {
const styleObj = {} as Record<string, string>;
for (const [state, style] of Object.entries(config)) {
Object.assign(
styleObj,
toolStyle2StyleObj(state as ConfigState, {
...config.default,
...style,
})
);
}
return styleObj;
};

View File

@@ -0,0 +1,135 @@
import {
MindmapStyleFour,
MindmapStyleOne,
MindmapStyleThree,
MindmapStyleTwo,
} from '@blocksuite/affine-block-surface';
import {
LayoutType,
MindmapElementModel,
MindmapStyle,
} from '@blocksuite/affine-model';
import {
type ToolbarContext,
type ToolbarModuleConfig,
ToolbarModuleExtension,
} from '@blocksuite/affine-shared/services';
import { getMostCommonValue } from '@blocksuite/affine-shared/utils';
import {
type MenuItem,
renderMenu,
} from '@blocksuite/affine-widget-edgeless-toolbar';
import { BlockFlavourIdentifier } from '@blocksuite/block-std';
import { RadiantIcon, RightLayoutIcon, StyleIcon } from '@blocksuite/icons/lit';
const MINDMAP_STYLE_LIST = [
{
value: MindmapStyle.ONE,
icon: MindmapStyleOne,
},
{
value: MindmapStyle.FOUR,
icon: MindmapStyleFour,
},
{
value: MindmapStyle.THREE,
icon: MindmapStyleThree,
},
{
value: MindmapStyle.TWO,
icon: MindmapStyleTwo,
},
] as const satisfies MenuItem<MindmapStyle>[];
const MINDMAP_LAYOUT_LIST = [
{
key: 'Left',
value: LayoutType.LEFT,
icon: RightLayoutIcon({
style: 'transform: rotate(0.5turn); transform-origin: center;',
}),
},
{
key: 'Radial',
value: LayoutType.BALANCE,
icon: RadiantIcon(),
},
{
key: 'Right',
value: LayoutType.RIGHT,
icon: RightLayoutIcon(),
},
] as const satisfies MenuItem<LayoutType>[];
export const createMindmapStyleActionMenu = (
ctx: ToolbarContext,
models: MindmapElementModel[]
) => {
const style = getMostCommonValue(models, 'style') ?? MindmapStyle.ONE;
const onPick = (style: MindmapStyle) => {
for (const model of models) {
model.style = style;
}
ctx.settings.recordLastProps('mindmap', { style });
};
return renderMenu({
label: 'Style',
icon: StyleIcon(),
items: MINDMAP_STYLE_LIST,
currentValue: style,
onPick,
});
};
export const createMindmapLayoutActionMenu = (
ctx: ToolbarContext,
models: MindmapElementModel[]
) => {
const layoutType =
getMostCommonValue(models, 'layoutType') ?? LayoutType.BALANCE;
const onPick = (layoutType: LayoutType) => {
for (const model of models) {
model.layoutType = layoutType;
model.layout();
}
ctx.settings.recordLastProps('mindmap', { layoutType });
};
return renderMenu({
label: 'Layout',
items: MINDMAP_LAYOUT_LIST,
currentValue: layoutType,
onPick,
});
};
export const mindmapToolbarConfig = {
actions: [
{
id: 'a.style',
content(ctx) {
const models = ctx.getSurfaceModelsByType(MindmapElementModel);
if (!models.length) return null;
return createMindmapStyleActionMenu(ctx, models);
},
},
{
id: 'b.layout',
content(ctx) {
const models = ctx.getSurfaceModelsByType(MindmapElementModel);
if (!models.length) return null;
return createMindmapLayoutActionMenu(ctx, models);
},
},
],
when: ctx => ctx.getSurfaceModelsByType(MindmapElementModel).length > 0,
} as const satisfies ToolbarModuleConfig;
export const mindmapToolbarExtension = ToolbarModuleExtension({
id: BlockFlavourIdentifier('affine:surface:mindmap'),
config: mindmapToolbarConfig,
});

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,54 @@
import { LightLoadingIcon } from '@blocksuite/affine-components/icons';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { ShadowlessElement } from '@blocksuite/block-std';
import { css, html } from 'lit';
import { importMindMapIcon } from './icons.js';
export class MindMapPlaceholder extends ShadowlessElement {
static override styles = css`
mindmap-import-placeholder {
display: flex;
flex-direction: column;
padding: 28px 12px 12px;
box-sizing: border-box;
width: 200px;
height: 122px;
border-radius: 12px;
gap: 12px;
background-color: ${unsafeCSSVarV2('layer/background/secondary')};
border: 1px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
color: ${unsafeCSSVarV2('text/placeholder')};
box-shadow: 0px 0px 4px 0px rgba(66, 65, 73, 0.14);
}
mindmap-import-placeholder .preview-icon {
text-align: center;
}
mindmap-import-placeholder .description {
display: flex;
gap: 8px;
color: ${unsafeCSSVarV2('text/placeholder')};
font-size: 14px;
line-height: 22px;
align-items: center;
}
`;
override render() {
return html`<div class="placeholder-container">
<div class="preview-icon">${importMindMapIcon}</div>
<div class="description">
${LightLoadingIcon}
<span>Importing mind map...</span>
</div>
</div>`;
}
}

View File

@@ -0,0 +1,403 @@
import { toast } from '@blocksuite/affine-components/toast';
import type { MindmapStyle } from '@blocksuite/affine-model';
import {
EditPropsStore,
FeatureFlagService,
TelemetryProvider,
} from '@blocksuite/affine-shared/services';
import {
EdgelessDraggableElementController,
EdgelessToolbarToolMixin,
} from '@blocksuite/affine-widget-edgeless-toolbar';
import type { BlockStdScope } from '@blocksuite/block-std';
import { modelContext, stdContext } from '@blocksuite/block-std';
import { ErrorCode } from '@blocksuite/global/exceptions';
import type { Bound } from '@blocksuite/global/gfx';
import { SignalWatcher } from '@blocksuite/global/lit';
import type { BlockModel } from '@blocksuite/store';
import { consume } from '@lit/context';
import { computed } from '@preact/signals-core';
import { css, html, LitElement, nothing, type TemplateResult } from 'lit';
import { property } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import { getMindMaps, type ToolbarMindmapItem } from './assets.js';
import { mediaRender, textRender } from './basket-elements.js';
import { importMindMapIcon, mindmapMenuMediaIcon, textIcon } from './icons.js';
import { MindMapPlaceholder } from './mindmap-importing-placeholder.js';
type TextItem = {
type: 'text';
icon: TemplateResult;
render: typeof textRender;
};
type MediaItem = {
type: 'media';
icon: TemplateResult;
render: typeof mediaRender;
};
type ImportItem = {
type: 'import';
icon: TemplateResult;
};
const textItem: TextItem = { type: 'text', icon: textIcon, render: textRender };
const mediaItem: MediaItem = {
type: 'media',
icon: mindmapMenuMediaIcon,
render: mediaRender,
};
export class EdgelessMindmapMenu extends EdgelessToolbarToolMixin(
SignalWatcher(LitElement)
) {
static override styles = css`
:host {
display: flex;
z-index: -1;
justify-content: flex-end;
}
.text-and-mindmap {
display: flex;
gap: 10px;
padding: 8px 0px;
box-sizing: border-box;
}
.thin-divider {
width: 1px;
transform: scaleX(0.5);
height: 48px;
background: var(--affine-border-color);
}
.text-item,
.media-item {
width: 60px;
}
.mindmap-item {
width: 64px;
}
.text-item,
.media-item,
.mindmap-item {
border-radius: 4px;
height: 48px;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.text-item > button,
.media-item > button,
.mindmap-item > button {
position: absolute;
border-radius: inherit;
border: none;
background: none;
cursor: grab;
padding: 0;
}
.text-item:hover,
.media-item:hover,
.mindmap-item[data-is-active='true'],
.mindmap-item:hover {
background: var(--affine-hover-color);
}
.text-item > button.next,
.media-item > button.next,
.mindmap-item > button.next {
transition: transform 0.3s ease-in-out;
}
`;
private readonly _style$ = computed(() => {
const { style } =
this.edgeless.std.get(EditPropsStore).lastProps$.value.mindmap;
return style;
});
draggableController!: EdgelessDraggableElementController<
ToolbarMindmapItem | TextItem | ImportItem | MediaItem
>;
// @ts-expect-error FIXME: resolve after gfx tool refactor
override type = 'empty' as const;
get mindMaps() {
return getMindMaps(this.theme);
}
private _importMindMapEntry() {
const { draggingElement } = this.draggableController?.states || {};
const isBeingDragged = draggingElement?.data.type === 'import';
return html`<div class="mindmap-item">
<button
style="opacity: ${isBeingDragged ? 0 : 1}"
class="next"
@mousedown=${(e: MouseEvent) => {
this.draggableController.onMouseDown(e, {
preview: importMindMapIcon,
data: {
type: 'import',
icon: importMindMapIcon,
},
standardWidth: 350,
});
}}
@touchstart=${(e: TouchEvent) => {
this.draggableController.onTouchStart(e, {
preview: importMindMapIcon,
data: {
type: 'import',
icon: importMindMapIcon,
},
standardWidth: 350,
});
}}
@click=${() => {
this.draggableController.cancel();
const viewportBound = this.gfx.viewport.viewportBounds;
viewportBound.x += viewportBound.w / 2;
viewportBound.y += viewportBound.h / 2;
this._onImportMindMap(viewportBound);
}}
>
${importMindMapIcon}
</button>
<affine-tooltip tip-position="top" .offset=${12}>
<affine-tooltip-content-with-shortcut
data-tip="${'Support import of FreeMind,OPML.'}"
></affine-tooltip-content-with-shortcut>
</affine-tooltip>
</div>`;
}
private _onImportMindMap(bound: Bound) {
const placeholder = new MindMapPlaceholder();
placeholder.style.position = 'absolute';
placeholder.style.left = `${bound.x}px`;
placeholder.style.top = `${bound.y}px`;
this.gfx.viewport.element?.append(placeholder);
this.onImportMindMap?.(bound)
.then(() => {
this.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', {
page: 'whiteboard editor',
type: 'imported mind map',
other: 'success',
module: 'toolbar',
});
})
.catch(e => {
if (e.code === ErrorCode.UserAbortError) return;
this.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', {
page: 'whiteboard editor',
type: 'imported mind map',
other: 'failed',
module: 'toolbar',
});
toast(this.edgeless.host, 'Import failed, please try again');
console.error(e);
})
.finally(() => {
placeholder.remove();
});
}
initDragController() {
if (this.draggableController || !this.edgeless) return;
this.draggableController = new EdgelessDraggableElementController(this, {
edgeless: this.edgeless,
scopeElement: this,
clickToDrag: true,
onOverlayCreated: (_layer, element) => {
if (element.data.type === 'mindmap') {
this.onActiveStyleChange?.(element.data.style);
}
// a workaround to active mindmap, so that menu cannot be closed by `Escape`
// @ts-expect-error FIXME: resolve after gfx tool refactor
this.setEdgelessTool({ type: 'empty' });
},
onDrop: (element, bound) => {
if ('render' in element.data) {
element.data
.render(bound, this.edgeless)
.then(id => {
if (!id) return;
if (element.data.type === 'mindmap') {
this.onActiveStyleChange?.(element.data.style);
// @ts-expect-error FIXME: resolve after gfx tool refactor
this.setEdgelessTool({ type: 'default' });
this.gfx.selection.set({
elements: [id],
editing: false,
});
} else if (
element.data.type === 'text' ||
element.data.type === 'media'
) {
// @ts-expect-error FIXME: resolve after gfx tool refactor
this.setEdgelessTool({ type: 'default' });
}
})
.catch(console.error);
} else if (element.data.type === 'import') {
this._onImportMindMap?.(bound);
}
},
});
}
override render() {
const { cancelled, draggingElement, dragOut } =
this.draggableController?.states || {};
const isDraggingMedia = draggingElement?.data?.type === 'media';
const isDraggingText = draggingElement?.data?.type === 'text';
const showNextText = dragOut && !cancelled;
return html`<edgeless-slide-menu .height=${'64px'}>
<div class="text-and-mindmap">
<div class="media-item">
${isDraggingMedia
? html`<button
class="next"
style="transform: translateY(${showNextText ? 0 : 64}px)"
>
${mediaItem.icon}
</button>`
: nothing}
<button
style="opacity: ${isDraggingMedia ? 0 : 1}"
@mousedown=${(e: MouseEvent) =>
this.draggableController.onMouseDown(e, {
preview: mediaItem.icon,
data: mediaItem,
})}
@touchstart=${(e: TouchEvent) =>
this.draggableController.onTouchStart(e, {
preview: mediaItem.icon,
data: mediaItem,
})}
>
${mediaItem.icon}
</button>
<affine-tooltip tip-position="top" .offset=${12}>
<affine-tooltip-content-with-shortcut
data-tip="${'Add media'}"
></affine-tooltip-content-with-shortcut>
</affine-tooltip>
</div>
<div class="thin-divider"></div>
<div class="text-item">
${isDraggingText
? html`<button
class="next"
style="transform: translateY(${showNextText ? 0 : 64}px)"
>
${textItem.icon}
</button>`
: nothing}
<button
style="opacity: ${isDraggingText ? 0 : 1}"
@mousedown=${(e: MouseEvent) =>
this.draggableController.onMouseDown(e, {
preview: textItem.icon,
data: textItem,
})}
@touchstart=${(e: TouchEvent) =>
this.draggableController.onTouchStart(e, {
preview: textItem.icon,
data: textItem,
})}
>
${textItem.icon}
</button>
<affine-tooltip tip-position="top" .offset=${12}>
<affine-tooltip-content-with-shortcut
data-tip="${'Edgeless Text'}"
data-shortcup="${'T'}"
></affine-tooltip-content-with-shortcut>
</affine-tooltip>
</div>
<div class="thin-divider"></div>
<!-- mind map -->
${repeat(this.mindMaps, mindMap => {
const isDraggingMindMap = draggingElement?.data?.type !== 'text';
const draggingEle = draggingElement?.data as ToolbarMindmapItem;
const isBeingDragged =
isDraggingMindMap && draggingEle?.style === mindMap.style;
const showNext = dragOut && !cancelled;
const isActive = this._style$.value === mindMap.style;
return html`
<div class="mindmap-item" data-is-active=${isActive}>
${isBeingDragged
? html`<button
style="transform: translateY(${showNext ? 0 : 64}px)"
class="next"
>
${mindMap.icon}
</button>`
: nothing}
<button
style="opacity: ${isBeingDragged ? 0 : 1}"
@mousedown=${(e: MouseEvent) => {
this.draggableController.onMouseDown(e, {
preview: mindMap.icon,
data: mindMap,
standardWidth: 350,
});
}}
@touchstart=${(e: TouchEvent) => {
this.draggableController.onTouchStart(e, {
preview: mindMap.icon,
data: mindMap,
standardWidth: 350,
});
}}
@click=${() => this.onActiveStyleChange?.(mindMap.style)}
>
${mindMap.icon}
</button>
<affine-tooltip tip-position="top" .offset=${12}>
<affine-tooltip-content-with-shortcut
data-tip="${'Mind Map'}"
data-shortcup="${'M'}"
></affine-tooltip-content-with-shortcut>
</affine-tooltip>
</div>
`;
})}
${this.std.store
.get(FeatureFlagService)
.getFlag('enable_mind_map_import')
? this._importMindMapEntry()
: nothing}
</div>
</edgeless-slide-menu>`;
}
override updated(changedProperties: Map<PropertyKey, unknown>) {
if (!changedProperties.has('edgeless')) return;
this.initDragController();
}
@consume({ context: modelContext })
accessor model!: BlockModel;
@property({ attribute: false })
accessor onActiveStyleChange!: (style: MindmapStyle) => void;
@property({ attribute: false })
accessor onImportMindMap!: (bound: Bound) => Promise<void>;
@consume({ context: stdContext })
accessor std!: BlockStdScope;
}

View File

@@ -0,0 +1,458 @@
import { EdgelessCRUDIdentifier } from '@blocksuite/affine-block-surface';
import type {
MindmapElementModel,
MindmapStyle,
} from '@blocksuite/affine-model';
import {
EditPropsStore,
ThemeProvider,
ViewportElementProvider,
} from '@blocksuite/affine-shared/services';
import {
EdgelessDraggableElementController,
EdgelessToolbarToolMixin,
} from '@blocksuite/affine-widget-edgeless-toolbar';
import type { GfxToolsFullOptionValue } from '@blocksuite/block-std/gfx';
import type { Bound } from '@blocksuite/global/gfx';
import { SignalWatcher } from '@blocksuite/global/lit';
import { computed } from '@preact/signals-core';
import { css, html, LitElement, nothing } from 'lit';
import { property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js';
import { getMindMaps } from './assets.js';
import {
type DraggableTool,
getMindmapRender,
mediaConfig,
mediaRender,
mindmapConfig,
textConfig,
textRender,
toolConfig2StyleObj,
} from './basket-elements.js';
import {
basketIconDark,
basketIconLight,
mindmapMenuMediaIcon,
textIcon,
} from './icons.js';
import { importMindmap } from './utils/import-mindmap.js';
export class EdgelessMindmapToolButton extends EdgelessToolbarToolMixin(
SignalWatcher(LitElement)
) {
static override styles = css`
:host {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.partial-clip {
flex-shrink: 0;
box-sizing: border-box;
width: calc(100% + 20px);
pointer-events: none;
padding: 0 10px;
overflow: hidden;
}
.basket-wrapper {
pointer-events: auto;
height: 64px;
width: 96px;
display: flex;
justify-content: center;
align-items: flex-end;
position: relative;
}
.basket,
.basket-tool-item {
transition: transform 0.3s ease-in-out;
position: absolute;
}
.basket {
bottom: 0;
height: 17px;
width: 76px;
}
.basket > div,
.basket > svg {
position: absolute;
}
.glass {
width: 76px;
height: 17px;
border-radius: 2px;
mask: url(#mindmap-basket-body-mask);
}
.glass.enabled {
backdrop-filter: blur(2px);
}
@-moz-document url-prefix() {
.glass.enabled {
backdrop-filter: none;
}
}
.basket {
z-index: 3;
}
.basket-tool-item {
cursor: grab;
}
.basket-tool-item svg {
display: block;
}
.basket-tool-item {
transform: translate(var(--default-x, 0), var(--default-y, 0))
rotate(var(--default-r, 0)) scale(var(--default-s, 1));
z-index: var(--default-z, 0);
}
.basket-tool-item.next {
transform: translate(var(--next-x, 0), var(--next-y, 0))
rotate(var(--next-r, 0)) scale(var(--next-s, 1));
z-index: var(--next-z, 0);
}
/* active & hover */
.basket-wrapper:hover .basket,
.basket-wrapper.active .basket {
z-index: 0;
}
.basket-wrapper:hover .basket-tool-item.current,
.basket-wrapper.active .basket-tool-item.current {
transform: translate(var(--active-x, 0), var(--active-y, 0))
rotate(var(--active-r, 0)) scale(var(--active-s, 1));
z-index: var(--active-z, 0);
}
.basket-tool-item.next.coming,
.basket-wrapper:hover .basket-tool-item.current:hover {
transform: translate(var(--hover-x, 0), var(--hover-y, 0))
rotate(var(--hover-r, 0)) scale(var(--hover-s, 1));
z-index: var(--hover-z, 0);
}
`;
private readonly _style$ = computed(() => {
const { style } =
this.edgeless.std.get(EditPropsStore).lastProps$.value.mindmap;
return style;
});
draggableController!: EdgelessDraggableElementController<DraggableTool>;
override enableActiveBackground = true;
// @ts-expect-error FIXME: resolve after gfx tool refactor
override type: GfxToolsFullOptionValue['type'][] = ['empty', 'text'];
get draggableTools(): DraggableTool[] {
const style = this._style$.value;
const mindmap =
this.mindmaps.find(m => m.style === style) || this.mindmaps[0];
return [
{
name: 'media',
icon: mindmapMenuMediaIcon,
config: mediaConfig,
standardWidth: 100,
render: mediaRender,
},
{
name: 'text',
icon: textIcon,
config: textConfig,
standardWidth: 100,
render: textRender,
},
{
name: 'mindmap',
icon: mindmap.icon,
config: mindmapConfig,
standardWidth: 350,
render: getMindmapRender(style),
},
];
}
get mindmaps() {
return getMindMaps(this.theme);
}
get crud() {
return this.edgeless.std.get(EdgelessCRUDIdentifier);
}
private _toggleMenu() {
if (this.tryDisposePopper()) return;
// @ts-expect-error FIXME: resolve after gfx tool refactor
this.setEdgelessTool({ type: 'default' });
const menu = this.createPopper('edgeless-mindmap-menu', this);
Object.assign(menu.element, {
edgeless: this.edgeless,
onActiveStyleChange: (style: MindmapStyle) => {
this.edgeless.std.get(EditPropsStore).recordLastProps('mindmap', {
style,
});
},
onImportMindMap: (bound: Bound) => {
return importMindmap(bound).then(mindmap => {
const id = this.crud.addElement('mindmap', {
children: mindmap,
layoutType: mindmap?.layoutType === 'left' ? 1 : 0,
});
if (!id) return;
const element = this.crud.getElementById(id) as MindmapElementModel;
this.tryDisposePopper();
// @ts-expect-error FIXME: resolve after gfx tool refactor
this.setEdgelessTool({ type: 'default' });
this.gfx.selection.set({
elements: [element.tree.id],
editing: false,
});
});
},
});
}
initDragController() {
if (!this.edgeless || !this.toolbarContainer) return;
if (this.draggableController) return;
this.draggableController = new EdgelessDraggableElementController(this, {
edgeless: this.edgeless,
scopeElement: this.toolbarContainer,
standardWidth: 100,
clickToDrag: true,
onOverlayCreated: (overlay, { data }) => {
const tool = this.draggableTools.find(t => t.name === data.name);
if (!tool) return;
// recover the rotation
const rotate = tool.config?.hover?.r ?? tool.config?.default?.r ?? 0;
overlay.element.style.setProperty('--rotate', rotate + 'deg');
setTimeout(() => {
overlay.transitionWrapper.style.setProperty(
'--rotate',
-rotate + 'deg'
);
}, 50);
// set the scale (without transition)
const scale = tool.config?.hover?.s ?? tool.config?.default?.s ?? 1;
overlay.element.style.setProperty('--scale', `${scale}`);
// a workaround to handle getBoundingClientRect() when the element is rotated
const _left = parseInt(overlay.element.style.left);
const _top = parseInt(overlay.element.style.top);
if (data.name === 'mindmap') {
overlay.element.style.left = _left + 3 + 'px';
overlay.element.style.top = _top + 5 + 'px';
} else if (data.name === 'text') {
overlay.element.style.left = _left + 0 + 'px';
overlay.element.style.top = _top + 3 + 'px';
}
this.readyToDrop = true;
},
onCanceled: overlay => {
overlay.transitionWrapper.style.transformOrigin = 'unset';
overlay.transitionWrapper.style.setProperty('--rotate', '0deg');
this.readyToDrop = false;
},
onDrop: (el, bound) => {
el.data
.render(bound, this.edgeless)
.then(id => {
if (!id) return;
this.readyToDrop = false;
if (el.data.name === 'mindmap') {
// @ts-expect-error FIXME: resolve after gfx tool refactor
this.setEdgelessTool({ type: 'default' });
this.gfx.selection.set({
elements: [id],
editing: false,
});
} else if (el.data.name === 'text') {
// @ts-expect-error FIXME: resolve after gfx tool refactor
this.setEdgelessTool({ type: 'default' });
}
})
.catch(console.error);
},
});
this.edgeless.bindHotKey(
{
m: () => {
const gfx = this.gfx;
const locked = gfx.viewport.locked;
if (locked) return;
if (gfx.selection.editing) return;
if (this.readyToDrop) {
// change the style
const activeIndex = this.mindmaps.findIndex(
m => m.style === this._style$.value
);
const nextIndex = (activeIndex + 1) % this.mindmaps.length;
const next = this.mindmaps[nextIndex];
this.edgeless.std.get(EditPropsStore).recordLastProps('mindmap', {
style: next.style,
});
const tool = this.draggableTools.find(t => t.name === 'mindmap');
this.draggableController.updateElementInfo({
data: tool,
preview: next.icon,
});
return;
}
// @ts-expect-error FIXME: resolve after gfx tool refactor
this.setEdgelessTool({ type: 'empty' });
const icon = this.mindmapElement;
const { x, y } = gfx.tool.lastMousePos$.peek();
const { viewport } = this.edgeless.std.get(ViewportElementProvider);
const { left, top } = viewport;
const clientPos = { x: x + left, y: y + top };
this.draggableController.clickToDrag(icon, clientPos);
},
},
{ global: true }
);
}
override render() {
const { popper } = this;
const appTheme = this.edgeless.std.get(ThemeProvider).app$.value;
const basketIcon = appTheme === 'light' ? basketIconLight : basketIconDark;
const glassBg =
appTheme === 'light' ? 'rgba(255,255,255,0.5)' : 'rgba(74, 74, 74, 0.6)';
const { cancelled, dragOut, draggingElement } =
this.draggableController?.states || {};
const active = popper || draggingElement;
return html`<edgeless-toolbar-button
class="edgeless-mindmap-button"
?withHover=${true}
.tooltip=${popper ? '' : 'Others'}
.tooltipOffset=${4}
@click=${this._toggleMenu}
style="width: 100%; height: 100%; display: inline-block"
>
<div class="partial-clip">
<div class="basket-wrapper ${active ? 'active' : ''}">
${repeat(
this.draggableTools,
t => t.name,
tool => {
const isBeingDragged = draggingElement?.data.name === tool.name;
const variables = toolConfig2StyleObj(tool.config);
const nextStyle = styleMap({
...variables,
});
const currentStyle = styleMap({
...variables,
opacity: isBeingDragged ? 0 : 1,
pointerEvents: draggingElement ? 'none' : 'auto',
});
return html`${isBeingDragged
? html`<div
class=${classMap({
'basket-tool-item': true,
next: true,
coming: !!dragOut && !cancelled,
})}
style=${nextStyle}
>
${tool.icon}
</div>`
: nothing}
<div
style=${currentStyle}
@mousedown=${(e: MouseEvent) =>
this.draggableController.onMouseDown(e, {
data: tool,
preview: tool.icon,
standardWidth: tool.standardWidth,
})}
@touchstart=${(e: TouchEvent) =>
this.draggableController.onTouchStart(e, {
data: tool,
preview: tool.icon,
standardWidth: tool.standardWidth,
})}
class="basket-tool-item current ${tool.name}"
>
${tool.icon}
</div>`;
}
)}
<div class="basket">
<div
class="glass ${this.enableBlur ? 'enabled' : ''}"
style="background: ${glassBg}"
></div>
${basketIcon}
</div>
</div>
</div>
<svg width="0" height="0" style="opacity: 0; pointer-events: none">
<defs>
<mask id="mindmap-basket-body-mask">
<rect
x="2"
width="71.8"
y="2"
height="15"
rx="1.5"
ry="1.5"
fill="white"
/>
<rect
width="32"
height="6"
x="22"
y="5.9"
fill="black"
rx="3"
ry="3"
/>
</mask>
</defs>
</svg>
</edgeless-toolbar-button>`;
}
override updated(_changedProperties: Map<PropertyKey, unknown>) {
const controllerRequiredProps = ['edgeless', 'toolbarContainer'] as const;
if (
controllerRequiredProps.some(p => _changedProperties.has(p)) &&
!this.draggableController
) {
this.initDragController();
}
}
@property({ type: Boolean })
accessor enableBlur = true;
@query('.basket-tool-item.mindmap')
accessor mindmapElement!: HTMLElement;
@state()
accessor readyToDrop = false;
}

View File

@@ -0,0 +1,145 @@
import { openFileOrFiles } from '@blocksuite/affine-shared/utils';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import type { Bound } from '@blocksuite/global/gfx';
import c from 'simple-xml-to-json';
type MindMapNode = {
children: MindMapNode[];
text: string;
xywh?: string;
title?: string;
layoutType?: 'left' | 'right';
};
export async function importMindmap(bound: Bound): Promise<MindMapNode> {
const file = await openFileOrFiles({
acceptType: 'MindMap',
});
if (!file) {
throw new BlockSuiteError(ErrorCode.UserAbortError, 'Aborted by user');
}
let result;
if (file.name.endsWith('.mm')) {
result = await parseMmFile(file);
} else if (file.name.endsWith('.opml') || file.name.endsWith('.xml')) {
result = await parseOPMLFile(file);
} else {
throw new BlockSuiteError(ErrorCode.ParsingError, 'Unsupported file type');
}
if (result) {
result.xywh = bound.serialize();
}
return result;
}
function readAsText(file: File) {
return file.text();
}
type RawMmNode = {
node?: {
TEXT: string;
POSITION: 'left' | 'right';
children?: RawMmNode[];
};
};
async function parseMmFile(file: File): Promise<MindMapNode> {
const content = await readAsText(file);
try {
const parsed = c.convertXML(content);
const map = parsed.map.children[0];
const traverse = (node: RawMmNode): MindMapNode | null => {
if (!node.node) {
return null;
}
return node.node.POSITION
? {
layoutType: node.node.POSITION,
text: node.node.TEXT ?? 'MINDMAP',
children:
(node.node.children
?.map(traverse)
.filter(node => node) as MindMapNode[]) ?? [],
}
: {
text: node.node.TEXT ?? 'MINDMAP',
children:
(node.node.children
?.map(traverse)
.filter(node => node) as MindMapNode[]) ?? [],
};
};
const result = traverse(map);
if (!result) {
throw new BlockSuiteError(
ErrorCode.ParsingError,
'Failed to parse mm file'
);
}
return result;
} catch (e) {
console.error(e);
throw new BlockSuiteError(
ErrorCode.ParsingError,
'Failed to parse mm file'
);
}
}
type RawOPMLOutline = {
outline: {
text: string;
children: RawOPMLOutline[];
};
};
async function parseOPMLFile(file: File): Promise<MindMapNode> {
const content = await readAsText(file);
try {
const parsed = c.convertXML(content);
const outline = parsed.opml?.children[1].body?.children?.[0];
const traverse = (node: RawOPMLOutline): MindMapNode | null => {
if (!node.outline?.text && !node.outline?.children) {
return null;
}
return {
text: node.outline?.text ?? 'MINDMAP',
children: node.outline.children
? (node.outline.children.map(traverse) as MindMapNode[])
: [],
};
};
const result = traverse(outline);
if (!result) {
throw new BlockSuiteError(
ErrorCode.ParsingError,
'Failed to parse OPML file'
);
}
return result;
} catch (e) {
console.error(e);
throw new BlockSuiteError(
ErrorCode.ParsingError,
'Failed to parse OPML file'
);
}
}

View File

@@ -0,0 +1,24 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src"],
"references": [
{ "path": "../../blocks/block-attachment" },
{ "path": "../../blocks/block-edgeless-text" },
{ "path": "../../blocks/block-image" },
{ "path": "../../blocks/block-surface" },
{ "path": "../../components" },
{ "path": "../text" },
{ "path": "../../model" },
{ "path": "../../rich-text" },
{ "path": "../../shared" },
{ "path": "../../widgets/widget-edgeless-toolbar" },
{ "path": "../../../framework/block-std" },
{ "path": "../../../framework/global" },
{ "path": "../../../framework/store" }
]
}