feat(editor): create gfx mindmap package (#11100)
This commit is contained in:
@@ -21,10 +21,11 @@ import {
|
||||
resolveColor,
|
||||
StrokeStyle,
|
||||
} from '@blocksuite/affine-model';
|
||||
import type {
|
||||
ToolbarContext,
|
||||
ToolbarGenericAction,
|
||||
ToolbarModuleConfig,
|
||||
import {
|
||||
type ToolbarContext,
|
||||
type ToolbarGenericAction,
|
||||
type ToolbarModuleConfig,
|
||||
ToolbarModuleExtension,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
getMostCommonResolvedValue,
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
LINE_STYLE_LIST,
|
||||
renderMenu,
|
||||
} from '@blocksuite/affine-widget-edgeless-toolbar';
|
||||
import { BlockFlavourIdentifier } from '@blocksuite/block-std';
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
import {
|
||||
AddTextIcon,
|
||||
@@ -417,3 +419,8 @@ function updateModelsWith<
|
||||
.updateElement(model.id, { [field]: value });
|
||||
}
|
||||
}
|
||||
|
||||
export const connectorToolbarExtension = ToolbarModuleExtension({
|
||||
id: BlockFlavourIdentifier('affine:surface:connector'),
|
||||
config: connectorToolbarConfig,
|
||||
});
|
||||
|
||||
50
blocksuite/affine/gfx/mindmap/package.json
Normal file
50
blocksuite/affine/gfx/mindmap/package.json
Normal 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"
|
||||
}
|
||||
20
blocksuite/affine/gfx/mindmap/src/effects.ts
Normal file
20
blocksuite/affine/gfx/mindmap/src/effects.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
1
blocksuite/affine/gfx/mindmap/src/index.ts
Normal file
1
blocksuite/affine/gfx/mindmap/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './toolbar/config';
|
||||
46
blocksuite/affine/gfx/mindmap/src/toolbar/assets.ts
Normal file
46
blocksuite/affine/gfx/mindmap/src/toolbar/assets.ts
Normal 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),
|
||||
},
|
||||
];
|
||||
206
blocksuite/affine/gfx/mindmap/src/toolbar/basket-elements.ts
Normal file
206
blocksuite/affine/gfx/mindmap/src/toolbar/basket-elements.ts
Normal 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;
|
||||
};
|
||||
135
blocksuite/affine/gfx/mindmap/src/toolbar/config.ts
Normal file
135
blocksuite/affine/gfx/mindmap/src/toolbar/config.ts
Normal 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,
|
||||
});
|
||||
619
blocksuite/affine/gfx/mindmap/src/toolbar/icons.ts
Normal file
619
blocksuite/affine/gfx/mindmap/src/toolbar/icons.ts
Normal file
File diff suppressed because one or more lines are too long
@@ -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>`;
|
||||
}
|
||||
}
|
||||
403
blocksuite/affine/gfx/mindmap/src/toolbar/mindmap-menu.ts
Normal file
403
blocksuite/affine/gfx/mindmap/src/toolbar/mindmap-menu.ts
Normal 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;
|
||||
}
|
||||
458
blocksuite/affine/gfx/mindmap/src/toolbar/mindmap-tool-button.ts
Normal file
458
blocksuite/affine/gfx/mindmap/src/toolbar/mindmap-tool-button.ts
Normal 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;
|
||||
}
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
}
|
||||
24
blocksuite/affine/gfx/mindmap/tsconfig.json
Normal file
24
blocksuite/affine/gfx/mindmap/tsconfig.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
"dependencies": {
|
||||
"@blocksuite/affine-block-surface": "workspace:*",
|
||||
"@blocksuite/affine-components": "workspace:*",
|
||||
"@blocksuite/affine-gfx-mindmap": "workspace:*",
|
||||
"@blocksuite/affine-model": "workspace:*",
|
||||
"@blocksuite/affine-rich-text": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
|
||||
@@ -7,6 +7,10 @@ import {
|
||||
type PickColorEvent,
|
||||
} from '@blocksuite/affine-components/color-picker';
|
||||
import type { LineDetailType } from '@blocksuite/affine-components/edgeless-line-styles-panel';
|
||||
import {
|
||||
createMindmapLayoutActionMenu,
|
||||
createMindmapStyleActionMenu,
|
||||
} from '@blocksuite/affine-gfx-mindmap';
|
||||
import {
|
||||
type Color,
|
||||
DefaultTheme,
|
||||
@@ -24,9 +28,10 @@ import {
|
||||
ShapeType,
|
||||
StrokeStyle,
|
||||
} from '@blocksuite/affine-model';
|
||||
import type {
|
||||
ToolbarGenericAction,
|
||||
ToolbarModuleConfig,
|
||||
import {
|
||||
type ToolbarGenericAction,
|
||||
type ToolbarModuleConfig,
|
||||
ToolbarModuleExtension,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { getMostCommonValue } from '@blocksuite/affine-shared/utils';
|
||||
import {
|
||||
@@ -35,6 +40,7 @@ import {
|
||||
LINE_STYLE_LIST,
|
||||
renderMenu,
|
||||
} from '@blocksuite/affine-widget-edgeless-toolbar';
|
||||
import { BlockFlavourIdentifier } from '@blocksuite/block-std';
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
import { AddTextIcon, ShapeIcon } from '@blocksuite/icons/lit';
|
||||
import { html } from 'lit';
|
||||
@@ -46,6 +52,56 @@ import { ShapeComponentConfig } from './shape-menu-config';
|
||||
|
||||
export const shapeToolbarConfig = {
|
||||
actions: [
|
||||
{
|
||||
id: 'a.mindmap-style',
|
||||
when(ctx) {
|
||||
const models = ctx.getSurfaceModelsByType(ShapeElementModel);
|
||||
return models.some(hasGrouped);
|
||||
},
|
||||
content(ctx) {
|
||||
const models = ctx.getSurfaceModelsByType(ShapeElementModel);
|
||||
if (!models.length) return null;
|
||||
|
||||
let mindmaps = models
|
||||
.map(model => model.group)
|
||||
.filter(model => ctx.matchModel(model, MindmapElementModel));
|
||||
if (!mindmaps.length) return null;
|
||||
|
||||
// Not displayed when there is both a normal shape and a mindmap shape.
|
||||
if (models.length !== mindmaps.length) return null;
|
||||
|
||||
mindmaps = Array.from(new Set(mindmaps));
|
||||
|
||||
return createMindmapStyleActionMenu(ctx, mindmaps);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'b.mindmap-layout',
|
||||
when(ctx) {
|
||||
const models = ctx.getSurfaceModelsByType(ShapeElementModel);
|
||||
return models.some(hasGrouped);
|
||||
},
|
||||
content(ctx) {
|
||||
const models = ctx.getSurfaceModelsByType(ShapeElementModel);
|
||||
if (!models.length) return null;
|
||||
|
||||
let mindmaps = models
|
||||
.map(model => model.group)
|
||||
.filter(model => ctx.matchModel(model, MindmapElementModel));
|
||||
if (!mindmaps.length) return null;
|
||||
|
||||
// Not displayed when there is both a normal shape and a mindmap shape.
|
||||
if (models.length !== mindmaps.length) return null;
|
||||
|
||||
mindmaps = Array.from(new Set(mindmaps));
|
||||
|
||||
// It's a sub node.
|
||||
if (models.length === 1 && mindmaps[0].tree.element !== models[0])
|
||||
return null;
|
||||
|
||||
return createMindmapLayoutActionMenu(ctx, mindmaps);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'c.switch-type',
|
||||
when(ctx) {
|
||||
@@ -326,3 +382,8 @@ function getTextColor(fillColor: Color, isNotTransparent = false) {
|
||||
function hasGrouped(model: ShapeElementModel) {
|
||||
return model.group instanceof MindmapElementModel;
|
||||
}
|
||||
|
||||
export const shapeToolbarExtension = ToolbarModuleExtension({
|
||||
id: BlockFlavourIdentifier('affine:surface:shape'),
|
||||
config: shapeToolbarConfig,
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"references": [
|
||||
{ "path": "../../blocks/block-surface" },
|
||||
{ "path": "../../components" },
|
||||
{ "path": "../mindmap" },
|
||||
{ "path": "../../model" },
|
||||
{ "path": "../../rich-text" },
|
||||
{ "path": "../../shared" },
|
||||
|
||||
@@ -3,8 +3,12 @@ import {
|
||||
TextUtils,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import { TextElementModel } from '@blocksuite/affine-model';
|
||||
import { type ToolbarModuleConfig } from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
type ToolbarModuleConfig,
|
||||
ToolbarModuleExtension,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { createTextActions } from '@blocksuite/affine-widget-edgeless-toolbar';
|
||||
import { BlockFlavourIdentifier } from '@blocksuite/block-std';
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
|
||||
export const textToolbarConfig = {
|
||||
@@ -46,3 +50,8 @@ export const textToolbarConfig = {
|
||||
|
||||
when: ctx => ctx.getSurfaceModelsByType(TextElementModel).length > 0,
|
||||
} as const satisfies ToolbarModuleConfig;
|
||||
|
||||
export const textToolbarExtension = ToolbarModuleExtension({
|
||||
id: BlockFlavourIdentifier('affine:surface:text'),
|
||||
config: textToolbarConfig,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user