refactor(editor): move mini mindmap to ai module (#9497)

This commit is contained in:
Saul-Mirone
2025-01-03 03:34:56 +00:00
parent cbc84ff672
commit 30a181da38
19 changed files with 85 additions and 210 deletions

View File

@@ -2,16 +2,13 @@ import './action-wrapper';
import type { EditorHost } from '@blocksuite/affine/block-std';
import { ShadowlessElement } from '@blocksuite/affine/block-std';
import { MiniMindmapPreview } from '@blocksuite/affine/blocks';
import { noop, WithDisposable } from '@blocksuite/affine/global/utils';
import { WithDisposable } from '@blocksuite/affine/global/utils';
import { html } from 'lit';
import { property } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import type { ChatAction } from '../chat-context';
noop(MiniMindmapPreview);
export class ActionMindmap extends WithDisposable(ShadowlessElement) {
@property({ attribute: false })
accessor item!: ChatAction;

View File

@@ -3,18 +3,12 @@ import type {
AffineAIPanelWidgetConfig,
MindmapStyle,
} from '@blocksuite/affine/blocks';
import {
markdownToMindmap,
MiniMindmapPreview,
} from '@blocksuite/affine/blocks';
import { noop } from '@blocksuite/affine/global/utils';
import { html, nothing } from 'lit';
import { markdownToMindmap } from '../mini-mindmap';
import { getAIPanelWidget } from '../utils/ai-widgets';
import type { AIContext } from '../utils/context';
noop(MiniMindmapPreview);
export const createMindmapRenderer: (
host: EditorHost,
/**

View File

@@ -0,0 +1,108 @@
import {
defaultBlockMarkdownAdapterMatchers,
inlineDeltaToMarkdownAdapterMatchers,
markdownInlineToDeltaMatchers,
} from '@blocksuite/affine/blocks';
import { Container } from '@blocksuite/affine/global/di';
import { DocCollection, Schema } from '@blocksuite/affine/store';
import { describe, expect, test } from 'vitest';
import { markdownToMindmap } from '../mindmap-preview.js';
const container = new Container();
[
...markdownInlineToDeltaMatchers,
...defaultBlockMarkdownAdapterMatchers,
...inlineDeltaToMarkdownAdapterMatchers,
].forEach(ext => {
ext.setup(container);
});
const provider = container.provider();
describe('markdownToMindmap: convert markdown list to a mind map tree', () => {
test('basic case', () => {
const markdown = `
- Text A
- Text B
- Text C
- Text D
- Text E
`;
const collection = new DocCollection({ schema: new Schema() });
collection.meta.initialize();
const doc = collection.createDoc();
const nodes = markdownToMindmap(markdown, doc, provider);
expect(nodes).toEqual({
text: 'Text A',
children: [
{
text: 'Text B',
children: [
{
text: 'Text C',
children: [],
},
],
},
{
text: 'Text D',
children: [
{
text: 'Text E',
children: [],
},
],
},
],
});
});
test('basic case with different indent', () => {
const markdown = `
- Text A
- Text B
- Text C
- Text D
- Text E
`;
const collection = new DocCollection({ schema: new Schema() });
collection.meta.initialize();
const doc = collection.createDoc();
const nodes = markdownToMindmap(markdown, doc, provider);
expect(nodes).toEqual({
text: 'Text A',
children: [
{
text: 'Text B',
children: [
{
text: 'Text C',
children: [],
},
],
},
{
text: 'Text D',
children: [
{
text: 'Text E',
children: [],
},
],
},
],
});
});
test('empty case', () => {
const markdown = '';
const collection = new DocCollection({ schema: new Schema() });
collection.meta.initialize();
const doc = collection.createDoc();
const nodes = markdownToMindmap(markdown, doc, provider);
expect(nodes).toEqual(null);
});
});

View File

@@ -0,0 +1,14 @@
import { MiniMindmapPreview } from './mindmap-preview.js';
import { MindmapRootBlock } from './mindmap-root-block.js';
import { MindmapSurfaceBlock } from './surface-block.js';
export { markdownToMindmap, MiniMindmapPreview } from './mindmap-preview.js';
export { MindmapRootBlock } from './mindmap-root-block.js';
export { MindmapService } from './mindmap-service.js';
export { MindmapSurfaceBlock } from './surface-block.js';
export function registerMiniMindmapBlocks() {
customElements.define('mini-mindmap-root-block', MindmapRootBlock);
customElements.define('mini-mindmap-preview', MiniMindmapPreview);
customElements.define('mini-mindmap-surface-block', MindmapSurfaceBlock);
}

View File

@@ -0,0 +1,310 @@
import { BlockStdScope, type EditorHost } from '@blocksuite/affine/block-std';
import {
MarkdownAdapter,
type MindmapElementModel,
MindmapStyle,
MindmapStyleFour,
MindmapStyleOne,
MindmapStyleThree,
MindmapStyleTwo,
type SurfaceBlockModel,
} from '@blocksuite/affine/blocks';
import type { ServiceProvider } from '@blocksuite/affine/global/di';
import { WithDisposable } from '@blocksuite/affine/global/utils';
import {
type Doc,
DocCollection,
type DocCollectionOptions,
IdGeneratorType,
Job,
Schema,
} from '@blocksuite/affine/store';
import { css, html, LitElement, nothing } from 'lit';
import { property, query } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js';
import type { Root } from 'mdast';
import { MiniMindmapSchema, MiniMindmapSpecs } from './spec.js';
const mindmapStyles = [
[MindmapStyle.ONE, MindmapStyleOne],
[MindmapStyle.TWO, MindmapStyleTwo],
[MindmapStyle.THREE, MindmapStyleThree],
[MindmapStyle.FOUR, MindmapStyleFour],
];
type Unpacked<T> = T extends (infer U)[] ? U : T;
export class MiniMindmapPreview extends WithDisposable(LitElement) {
static override styles = css`
mini-mindmap-root-block,
mini-mindmap-surface-block,
editor-host {
display: block;
width: 100%;
height: 100%;
}
.select-template-title {
align-self: stretch;
color: var(
--light-textColor-textSecondaryColor,
var(--textColor-textSecondaryColor, #8e8d91)
);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 20px;
margin-bottom: 4px;
}
.template {
display: flex;
gap: 12px;
}
.template-item {
box-sizing: border-box;
border: 2px solid var(--affine-border-color);
border-radius: 4px;
padding: 4px 6px;
}
.template-item.active,
.template-item:hover {
border-color: var(--affine-brand-color);
}
.template-item > svg {
display: block;
}
`;
doc?: Doc;
mindmapId?: string;
surface?: SurfaceBlockModel;
get _mindmap(): MindmapElementModel | null {
return (
(this.surface?.getElementById(
this.mindmapId || ''
) as MindmapElementModel) ?? null
);
}
private _createTemporaryDoc() {
const schema = new Schema();
schema.register(MiniMindmapSchema);
const options: DocCollectionOptions = {
id: 'MINI_MINDMAP_TEMPORARY',
schema,
idGenerator: IdGeneratorType.NanoID,
awarenessSources: [],
};
const collection = new DocCollection(options);
collection.meta.initialize();
collection.start();
const doc = collection.createDoc({ id: 'doc:home' }).load();
const rootId = doc.addBlock('affine:page', {});
const surfaceId = doc.addBlock('affine:surface', {}, rootId);
const surface = doc.getBlockById(surfaceId) as SurfaceBlockModel;
doc.resetHistory();
return {
doc,
surface,
};
}
private _switchStyle(style: MindmapStyle) {
if (!this._mindmap || !this.doc) {
return;
}
this.doc.transact(() => {
if (this._mindmap) {
this._mindmap.style = style;
}
});
this.ctx.set({ style });
this.requestUpdate();
}
private _toMindmapNode(answer: string, doc: Doc) {
return markdownToMindmap(answer, doc, this.host.std.provider);
}
override connectedCallback(): void {
super.connectedCallback();
const tempDoc = this._createTemporaryDoc();
const mindmapNode = this._toMindmapNode(this.answer, tempDoc.doc);
if (!mindmapNode) {
return;
}
this.doc = tempDoc.doc;
this.surface = tempDoc.surface;
this.mindmapId = this.surface.addElement({
type: 'mindmap',
children: mindmapNode,
style: this.mindmapStyle ?? MindmapStyle.FOUR,
});
this.surface.getElementById(this.mindmapId) as MindmapElementModel;
const centerPosition = this._mindmap?.tree.element.xywh;
this.ctx.set({
node: mindmapNode,
style: MindmapStyle.FOUR,
centerPosition,
});
}
override render() {
if (!this.doc || !this.surface || !this._mindmap) return nothing;
const curStyle = this._mindmap.style;
return html` <div>
<div
style=${styleMap({
height: this.height + 'px',
border: '1px solid var(--affine-border-color)',
borderRadius: '4px',
})}
>
${new BlockStdScope({
doc: this.doc,
extensions: MiniMindmapSpecs,
}).render()}
</div>
${this.templateShow
? html` <div class="select-template-title">Select template</div>
<div class="template">
${repeat(
mindmapStyles,
([style]) => style,
([style, icon]) => {
return html`<div
class=${`template-item ${curStyle === style ? 'active' : ''}`}
@click=${() => this._switchStyle(style as MindmapStyle)}
>
${icon}
</div>`;
}
)}
</div>`
: nothing}
</div>`;
}
@property({ attribute: false })
accessor answer!: string;
@property({ attribute: false })
accessor ctx!: {
get(): Record<string, unknown>;
set(data: Record<string, unknown>): void;
};
@property({ attribute: false })
accessor height = 400;
@property({ attribute: false })
accessor host!: EditorHost;
@property({ attribute: false })
accessor mindmapStyle: MindmapStyle | undefined = undefined;
@query('editor-host')
accessor portalHost!: EditorHost;
@property({ attribute: false })
accessor templateShow = true;
}
type Node = {
text: string;
children: Node[];
};
export const markdownToMindmap = (
answer: string,
doc: Doc,
provider: ServiceProvider
) => {
let result: Node | null = null;
const job = new Job({
schema: doc.collection.schema,
blobCRUD: doc.collection.blobSync,
docCRUD: {
create: (id: string) => doc.collection.createDoc({ id }),
get: (id: string) => doc.collection.getDoc(id),
delete: (id: string) => doc.collection.removeDoc(id),
},
});
const markdown = new MarkdownAdapter(job, provider);
const ast: Root = markdown['_markdownToAst'](answer);
const traverse = (
markdownNode: Unpacked<(typeof ast)['children']>,
firstLevel = false
): Node | null => {
switch (markdownNode.type) {
case 'list':
{
const listItems = markdownNode.children
.map(child => traverse(child))
.filter(val => val);
if (firstLevel) {
return listItems[0];
}
}
break;
case 'listItem': {
const paragraph = markdownNode.children[0];
const list = markdownNode.children[1];
const node: Node = {
text: '',
children: [],
};
if (
paragraph?.type === 'paragraph' &&
paragraph.children[0]?.type === 'text'
) {
node.text = paragraph.children[0].value;
}
if (list?.type === 'list') {
node.children = list.children
.map(child => traverse(child))
.filter(val => val) as Node[];
}
return node;
}
}
return null;
};
if (ast?.children?.[0]?.type === 'list') {
result = traverse(ast.children[0], true);
}
return result;
};

View File

@@ -0,0 +1,27 @@
import { BlockComponent } from '@blocksuite/affine/block-std';
import type { RootBlockModel } from '@blocksuite/affine/blocks';
import { html } from 'lit';
export class MindmapRootBlock extends BlockComponent<RootBlockModel> {
override render() {
return html`
<style>
.affine-mini-mindmap-root {
display: block;
width: 100%;
height: 100%;
background-size: 20px 20px;
background-color: var(--affine-background-primary-color);
background-image: radial-gradient(
var(--affine-edgeless-grid-color) 1px,
var(--affine-background-primary-color) 1px
);
}
</style>
<div class="affine-mini-mindmap-root">
${this.host.renderChildren(this.model)}
</div>
`;
}
}

View File

@@ -0,0 +1,15 @@
import { BlockService } from '@blocksuite/affine/block-std';
import { RootBlockSchema } from '@blocksuite/affine/blocks';
import { Slot } from '@blocksuite/affine/global/utils';
export class MindmapService extends BlockService {
static override readonly flavour = RootBlockSchema.model.flavour;
requestCenter = new Slot();
center() {
this.requestCenter.emit();
}
override mounted(): void {}
}

View File

@@ -0,0 +1,35 @@
import {
BlockViewExtension,
type ExtensionType,
FlavourExtension,
} from '@blocksuite/affine/block-std';
import {
DocModeService,
MindMapView,
RootBlockSchema,
SurfaceBlockSchema,
ThemeService,
} from '@blocksuite/affine/blocks';
import type { BlockSchema } from '@blocksuite/affine/store';
import { literal } from 'lit/static-html.js';
import type { z } from 'zod';
import { MindmapService } from './mindmap-service.js';
import { MindmapSurfaceBlockService } from './surface-service.js';
export const MiniMindmapSpecs: ExtensionType[] = [
DocModeService,
ThemeService,
FlavourExtension('affine:page'),
MindmapService,
BlockViewExtension('affine:page', literal`mini-mindmap-root-block`),
FlavourExtension('affine:surface'),
MindMapView,
MindmapSurfaceBlockService,
BlockViewExtension('affine:surface', literal`mini-mindmap-surface-block`),
];
export const MiniMindmapSchema: z.infer<typeof BlockSchema>[] = [
RootBlockSchema,
SurfaceBlockSchema,
];

View File

@@ -0,0 +1,158 @@
/* oxlint-disable @typescript-eslint/no-non-null-assertion */
import { BlockComponent } from '@blocksuite/affine/block-std';
import { GfxControllerIdentifier } from '@blocksuite/affine/block-std/gfx';
import type {
Color,
ShapeElementModel,
SurfaceBlockModel,
} from '@blocksuite/affine/blocks';
import {
CanvasRenderer,
elementRenderers,
fitContent,
ThemeProvider,
} from '@blocksuite/affine/blocks';
import type { Bound } from '@blocksuite/affine/global/utils';
import { html } from 'lit';
import { query } from 'lit/decorators.js';
import type { MindmapService } from './mindmap-service.js';
export class MindmapSurfaceBlock extends BlockComponent<SurfaceBlockModel> {
renderer?: CanvasRenderer;
private get _grid() {
return this.std.get(GfxControllerIdentifier).grid;
}
private get _layer() {
return this.std.get(GfxControllerIdentifier).layer;
}
get mindmapService() {
return this.std.getService('affine:page') as unknown as MindmapService;
}
get viewport() {
return this.std.get(GfxControllerIdentifier).viewport;
}
constructor() {
super();
}
private _adjustNodeWidth() {
this.model.doc.transact(() => {
this.model.elementModels.forEach(element => {
if (element.type === 'shape') {
fitContent(element as ShapeElementModel);
}
});
});
}
private _resizeEffect() {
const observer = new ResizeObserver(() => {
this.viewport.onResize();
});
observer.observe(this.editorContainer);
this._disposables.add(() => {
observer.disconnect();
});
}
private _setupCenterEffect() {
this._disposables.add(
this.mindmapService.requestCenter.on(() => {
let bound: Bound;
this.model.elementModels.forEach(el => {
if (!bound) {
bound = el.elementBound;
} else {
bound = bound.unite(el.elementBound);
}
});
if (bound!) {
this.viewport.setViewportByBound(bound, [10, 10, 10, 10]);
}
})
);
}
private _setupRenderer() {
this._disposables.add(
this.model.elementUpdated.on(() => {
this.mindmapService.center();
})
);
this.viewport.ZOOM_MIN = 0.01;
}
override connectedCallback(): void {
super.connectedCallback();
const themeService = this.std.get(ThemeProvider);
this.renderer = new CanvasRenderer({
viewport: this.viewport,
layerManager: this._layer,
gridManager: this._grid,
enableStackingCanvas: true,
provider: {
selectedElements: () => [],
getColorScheme: () => themeService.edgelessTheme,
getColorValue: (color: Color, fallback?: Color, real?: boolean) =>
themeService.getColorValue(
color,
fallback,
real,
themeService.edgelessTheme
),
generateColorProperty: (color: Color, fallback?: Color) =>
themeService.generateColorProperty(
color,
fallback,
themeService.edgelessTheme
),
getPropertyValue: (property: string) =>
themeService.getCssVariableColor(
property,
themeService.edgelessTheme
),
},
elementRenderers,
surfaceModel: this.model,
});
this._disposables.add(this.renderer);
}
override firstUpdated(_changedProperties: Map<PropertyKey, unknown>): void {
this.renderer?.attach(this.editorContainer);
this._resizeEffect();
this._setupCenterEffect();
this._setupRenderer();
this._adjustNodeWidth();
this.mindmapService.center();
}
override render() {
return html`
<style>
.affine-mini-mindmap-surface {
width: 100%;
height: 100%;
}
</style>
<div class="affine-mini-mindmap-surface">
<!-- attach cavnas later in renderer -->
</div>
`;
}
@query('.affine-mini-mindmap-surface')
accessor editorContainer!: HTMLDivElement;
}

View File

@@ -0,0 +1,6 @@
import { BlockService } from '@blocksuite/affine/block-std';
import { SurfaceBlockSchema } from '@blocksuite/affine/blocks';
export class MindmapSurfaceBlockService extends BlockService {
static override readonly flavour = SurfaceBlockSchema.model.flavour;
}

View File

@@ -21,6 +21,7 @@ import { ChatPanelMessages } from './ai/chat-panel/chat-panel-messages';
import { AIErrorWrapper } from './ai/messages/error';
import { AISlidesRenderer } from './ai/messages/slides-renderer';
import { AIAnswerWrapper } from './ai/messages/wrapper';
import { registerMiniMindmapBlocks } from './ai/mini-mindmap';
import { ChatBlockInput } from './ai/peek-view/chat-block-input';
import { AIChatBlockPeekView } from './ai/peek-view/chat-block-peek-view';
import { DateTime } from './ai/peek-view/date-time';
@@ -38,6 +39,7 @@ import { ImagePlaceholder } from './blocks/ai-chat-block/components/image-placeh
import { UserInfo } from './blocks/ai-chat-block/components/user-info';
export function registerBlocksuitePresetsCustomComponents() {
registerMiniMindmapBlocks();
customElements.define('ask-ai-icon', AskAIIcon);
customElements.define('ask-ai-button', AskAIButton);
customElements.define('ask-ai-toolbar-button', AskAIToolbarButton);