import { DefaultTool } from '@blocksuite/affine-block-surface'; import { toast } from '@blocksuite/affine-components/toast'; import { EmptyTool } from '@blocksuite/affine-gfx-pointer'; 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 { ErrorCode } from '@blocksuite/global/exceptions'; import type { Bound } from '@blocksuite/global/gfx'; import { SignalWatcher } from '@blocksuite/global/lit'; import type { BlockStdScope } from '@blocksuite/std'; import { modelContext, stdContext } from '@blocksuite/std'; 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 >; override type = EmptyTool; get mindMaps() { return getMindMaps(this.theme); } private _importMindMapEntry() { const { draggingElement } = this.draggableController?.states || {}; const isBeingDragged = draggingElement?.data.type === 'import'; return html`
`; } 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` this.setEdgelessTool(EmptyTool); }, 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); this.setEdgelessTool(DefaultTool); this.gfx.selection.set({ elements: [id], editing: false, }); } else if ( element.data.type === 'text' || element.data.type === 'media' ) { this.setEdgelessTool(DefaultTool); } }) .catch(console.error); } else if (element.data.type === 'import') { this._onImportMindMap?.(bound); } }, }); this.edgeless.bindHotKey( { m: () => { const gfx = this.gfx; const locked = gfx.viewport.locked; if (locked) return; if (gfx.selection.editing) return; // toolbar mindmap button will capture the `m` key and create a new overlay this.draggableController.cancelWithoutAnimation(); }, }, { global: true } ); } 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`
${isDraggingMedia ? html`` : nothing}
${isDraggingText ? html`` : nothing}
${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`
${isBeingDragged ? html`` : nothing}
`; })} ${this.std.store .get(FeatureFlagService) .getFlag('enable_mind_map_import') ? this._importMindMapEntry() : nothing}
`; } override updated(changedProperties: Map) { 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; @consume({ context: stdContext }) accessor std!: BlockStdScope; }