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

@@ -1,106 +0,0 @@
import { Container } from '@blocksuite/global/di';
import { DocCollection, Schema } from '@blocksuite/store';
import { describe, expect, test } from 'vitest';
import { defaultBlockMarkdownAdapterMatchers } from '../../_common/adapters/index.js';
import { inlineDeltaToMarkdownAdapterMatchers } from '../../_common/adapters/markdown/delta-converter/inline-delta.js';
import { markdownInlineToDeltaMatchers } from '../../_common/adapters/markdown/delta-converter/markdown-inline.js';
import { markdownToMindmap } from '../../surface-block/mini-mindmap/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

@@ -181,11 +181,6 @@ import {
AFFINE_VIEWPORT_OVERLAY_WIDGET,
AffineViewportOverlayWidget,
} from './root-block/widgets/viewport-overlay/viewport-overlay.js';
import {
MindmapRootBlock,
MindmapSurfaceBlock,
MiniMindmapPreview,
} from './surface-block/mini-mindmap/index.js';
export function effects() {
registerSpecs();
@@ -232,8 +227,6 @@ export function effects() {
customElements.define('affine-page-root', PageRootBlockComponent);
customElements.define('affine-preview-root', PreviewRootBlockComponent);
customElements.define('mini-mindmap-preview', MiniMindmapPreview);
customElements.define('mini-mindmap-surface-block', MindmapSurfaceBlock);
customElements.define('affine-edgeless-root', EdgelessRootBlockComponent);
customElements.define('edgeless-copilot-panel', EdgelessCopilotPanel);
customElements.define(
@@ -371,7 +364,6 @@ export function effects() {
);
customElements.define('edgeless-text-editor', EdgelessTextEditor);
customElements.define('affine-image-toolbar', AffineImageToolbar);
customElements.define('mini-mindmap-root-block', MindmapRootBlock);
customElements.define('affine-block-selection', BlockSelection);
customElements.define('edgeless-slide-menu', EdgelessSlideMenu);
customElements.define(

View File

@@ -33,11 +33,6 @@ export { EditPropsMiddlewareBuilder } from './root-block/edgeless/middlewares/ba
export { EdgelessSnapManager } from './root-block/edgeless/utils/snap-manager.js';
export * from './root-block/index.js';
export * from './schemas.js';
export {
markdownToMindmap,
MindmapSurfaceBlock,
MiniMindmapPreview,
} from './surface-block/mini-mindmap/index.js';
export * from '@blocksuite/affine-block-attachment';
export * from '@blocksuite/affine-block-bookmark';
export * from '@blocksuite/affine-block-code';

View File

@@ -1,4 +0,0 @@
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';

View File

@@ -1,310 +0,0 @@
import type { SurfaceBlockModel } from '@blocksuite/affine-block-surface';
import {
MindmapStyleFour,
MindmapStyleOne,
MindmapStyleThree,
MindmapStyleTwo,
} from '@blocksuite/affine-components/icons';
import {
type MindmapElementModel,
MindmapStyle,
} from '@blocksuite/affine-model';
import { MarkdownAdapter } from '@blocksuite/affine-shared/adapters';
import { BlockStdScope, type EditorHost } from '@blocksuite/block-std';
import type { ServiceProvider } from '@blocksuite/global/di';
import { WithDisposable } from '@blocksuite/global/utils';
import {
type Doc,
DocCollection,
type DocCollectionOptions,
IdGeneratorType,
Job,
Schema,
} from '@blocksuite/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(() => {
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

@@ -1,27 +0,0 @@
import type { RootBlockModel } from '@blocksuite/affine-model';
import { BlockComponent } from '@blocksuite/block-std';
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

@@ -1,15 +0,0 @@
import { RootBlockSchema } from '@blocksuite/affine-model';
import { BlockService } from '@blocksuite/block-std';
import { Slot } from '@blocksuite/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

@@ -1,37 +0,0 @@
import {
MindMapView,
SurfaceBlockSchema,
} from '@blocksuite/affine-block-surface';
import { RootBlockSchema } from '@blocksuite/affine-model';
import {
DocModeService,
ThemeService,
} from '@blocksuite/affine-shared/services';
import {
BlockViewExtension,
type ExtensionType,
FlavourExtension,
} from '@blocksuite/block-std';
import type { BlockSchema } from '@blocksuite/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

@@ -1,155 +0,0 @@
/* oxlint-disable @typescript-eslint/no-non-null-assertion */
import type { SurfaceBlockModel } from '@blocksuite/affine-block-surface';
import {
CanvasRenderer,
elementRenderers,
fitContent,
} from '@blocksuite/affine-block-surface';
import type { Color, ShapeElementModel } from '@blocksuite/affine-model';
import { ThemeProvider } from '@blocksuite/affine-shared/services';
import { BlockComponent } from '@blocksuite/block-std';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import type { Bound } from '@blocksuite/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

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

View File

@@ -1,117 +0,0 @@
import type { EditorHost } from '@blocksuite/block-std';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import {
type EdgelessRootBlockComponent,
type MindmapElementModel,
type MindmapSurfaceBlock,
MiniMindmapPreview,
} from '@blocksuite/blocks';
import { beforeEach, describe, expect, test } from 'vitest';
import { wait } from '../utils/common.js';
import { getDocRootBlock } from '../utils/edgeless.js';
import { setupEditor } from '../utils/setup.js';
describe('mini mindmap preview', () => {
let edgeless!: EdgelessRootBlockComponent;
const createPreview = (host: EditorHost, answer: string) => {
const mindmapPreview = new MiniMindmapPreview();
mindmapPreview.answer = answer;
mindmapPreview.host = host;
mindmapPreview.ctx = {
get() {
return {};
},
set() {},
};
document.body.append(mindmapPreview);
return mindmapPreview;
};
beforeEach(async () => {
const cleanup = await setupEditor('edgeless');
edgeless = getDocRootBlock(doc, editor, 'edgeless');
edgeless.gfx.tool.setTool('default');
return cleanup;
});
test('mini mindmap basic', async () => {
const mindmapAnswer = `
- Mindmap
- Node 1
- Node 1.1
- Node 1.2
- Node 2
- Node 2.1
- Node 2.2
`;
const miniMindMapPreview = createPreview(
window.editor.host!,
mindmapAnswer
);
await wait(50);
const miniMindMapSurface = miniMindMapPreview.renderRoot.querySelector(
'mini-mindmap-surface-block'
) as MindmapSurfaceBlock;
// model-related properties
expect(miniMindMapPreview.mindmapId).toBeDefined();
expect(miniMindMapPreview.portalHost).toBeDefined();
expect(miniMindMapPreview.doc).toBeDefined();
expect(miniMindMapPreview.surface).toBeDefined();
expect(miniMindMapPreview.surface!.elementModels.length).toBe(8);
// renderer
expect(miniMindMapSurface.renderer).toBeDefined();
expect(miniMindMapSurface.renderer?.canvas.isConnected).toBe(true);
expect(miniMindMapSurface.renderer?.canvas.width).toBeGreaterThan(0);
expect(miniMindMapSurface.renderer?.canvas.height).toBeGreaterThan(0);
return () => {
miniMindMapPreview.remove();
};
});
test('mini mindmap should layout automatically', async () => {
const mindmapAnswer = `
- Main node
- Child node
- Second child node
- Third child node
`;
const miniMindMapPreview = createPreview(
window.editor.host!,
mindmapAnswer
);
await wait(50);
const gfx = miniMindMapPreview.portalHost.std.get(GfxControllerIdentifier);
const mindmap = gfx.surface!.elementModels.filter(
model => model.type === 'mindmap'
)[0] as MindmapElementModel;
const [child1, child2, child3] = mindmap.tree.children;
const root = mindmap.tree;
expect(mindmap).not.toBeUndefined();
expect(root.children.length).toBe(3);
expect(root.element.x).toBeLessThan(child1.element.x);
// children should be aligned horizontally
expect(child1.element.x).toBe(child2.element.x);
expect(child2.element.x).toBe(child3.element.x);
// children
expect(child1.element.y + child1.element.h).toBeLessThan(child2.element.y);
expect(child2.element.y + child2.element.h).toBeLessThan(child3.element.y);
});
});

View File

@@ -1,16 +0,0 @@
import type { Doc, DocCollection, Job } from '@blocksuite/store';
import type { AffineEditorContainer } from '../index.js';
declare global {
const editor: AffineEditorContainer;
const doc: Doc;
const collection: DocCollection;
const job: Job;
interface Window {
editor: AffineEditorContainer;
doc: Doc;
job: Job;
collection: DocCollection;
}
}

View File

@@ -1,5 +1,5 @@
import { effects as blocksEffects } from '@blocksuite/blocks/effects';
import type { BlockCollection } from '@blocksuite/store';
import type { BlockCollection, Doc, Job } from '@blocksuite/store';
import { effects } from '../../effects.js';
@@ -109,3 +109,16 @@ export function cleanup() {
delete (window as any).doc;
}
declare global {
const editor: AffineEditorContainer;
const doc: Doc;
const collection: DocCollection;
const job: Job;
interface Window {
editor: AffineEditorContainer;
doc: Doc;
job: Job;
collection: DocCollection;
}
}

View File

@@ -1,8 +1,8 @@
import '@blocksuite/affine-block-surface/effects';
export * from './editors/index.js';
export * from './fragments/index.js';
export * from './helpers/index.js';
export * from './editors';
export * from './fragments';
export * from './helpers';
const env =
typeof globalThis !== 'undefined'