feat: extract latex block (#9327)

This commit is contained in:
Saul-Mirone
2024-12-26 06:13:07 +00:00
parent 40d8d83b4a
commit fd872943b1
28 changed files with 154 additions and 16 deletions

View File

@@ -8,13 +8,13 @@ import {
embedYoutubeBlockMarkdownAdapterMatcher,
} from '@blocksuite/affine-block-embed';
import { imageBlockMarkdownAdapterMatcher } from '@blocksuite/affine-block-image';
import { latexBlockMarkdownAdapterMatcher } from '@blocksuite/affine-block-latex';
import { listBlockMarkdownAdapterMatcher } from '@blocksuite/affine-block-list';
import { paragraphBlockMarkdownAdapterMatcher } from '@blocksuite/affine-block-paragraph';
import { codeBlockMarkdownAdapterMatcher } from '../../../code-block/adapters/markdown.js';
import { databaseBlockMarkdownAdapterMatcher } from '../../../database-block/adapters/markdown.js';
import { dividerBlockMarkdownAdapterMatcher } from '../../../divider-block/adapters/markdown.js';
import { latexBlockMarkdownAdapterMatcher } from '../../../latex-block/adapters/markdown.js';
import { rootBlockMarkdownAdapterMatcher } from '../../../root-block/adapters/markdown.js';
export const defaultBlockMarkdownAdapterMatchers = [

View File

@@ -7,6 +7,7 @@ import {
EmbedYoutubeBlockNotionHtmlAdapterExtension,
} from '@blocksuite/affine-block-embed';
import { ImageBlockNotionHtmlAdapterExtension } from '@blocksuite/affine-block-image';
import { LatexBlockNotionHtmlAdapterExtension } from '@blocksuite/affine-block-latex';
import { ListBlockNotionHtmlAdapterExtension } from '@blocksuite/affine-block-list';
import { ParagraphBlockNotionHtmlAdapterExtension } from '@blocksuite/affine-block-paragraph';
import type { ExtensionType } from '@blocksuite/block-std';
@@ -14,7 +15,6 @@ import type { ExtensionType } from '@blocksuite/block-std';
import { CodeBlockNotionHtmlAdapterExtension } from '../../../code-block/adapters/notion-html.js';
import { DatabaseBlockNotionHtmlAdapterExtension } from '../../../database-block/adapters/notion-html.js';
import { DividerBlockNotionHtmlAdapterExtension } from '../../../divider-block/adapters/notion-html.js';
import { LatexBlockNotionHtmlAdapterExtension } from '../../../latex-block/adapters/notion-html.js';
import { RootBlockNotionHtmlAdapterExtension } from '../../../root-block/adapters/notion-html.js';
export const defaultBlockNotionHtmlAdapterMatchers: ExtensionType[] = [

View File

@@ -7,6 +7,7 @@ import {
embedSyncedDocBlockPlainTextAdapterMatcher,
embedYoutubeBlockPlainTextAdapterMatcher,
} from '@blocksuite/affine-block-embed';
import { latexBlockPlainTextAdapterMatcher } from '@blocksuite/affine-block-latex';
import { listBlockPlainTextAdapterMatcher } from '@blocksuite/affine-block-list';
import { paragraphBlockPlainTextAdapterMatcher } from '@blocksuite/affine-block-paragraph';
import type { BlockPlainTextAdapterMatcher } from '@blocksuite/affine-shared/adapters';
@@ -14,7 +15,6 @@ import type { BlockPlainTextAdapterMatcher } from '@blocksuite/affine-shared/ada
import { codeBlockPlainTextAdapterMatcher } from '../../../code-block/adapters/plain-text.js';
import { databaseBlockPlainTextAdapterMatcher } from '../../../database-block/adapters/plain-text.js';
import { dividerBlockPlainTextAdapterMatcher } from '../../../divider-block/adapters/plain-text.js';
import { latexBlockPlainTextAdapterMatcher } from '../../../latex-block/adapters/plain-text.js';
export const defaultBlockPlainTextAdapterMatchers: BlockPlainTextAdapterMatcher[] =
[

View File

@@ -1,8 +1,8 @@
import { FrameBlockSpec } from '@blocksuite/affine-block-frame';
import { LatexBlockSpec } from '@blocksuite/affine-block-latex';
import { EdgelessSurfaceBlockSpec } from '@blocksuite/affine-block-surface';
import { EdgelessTextBlockSpec } from '../../edgeless-text-block/index.js';
import { LatexBlockSpec } from '../../latex-block/latex-spec.js';
import { EdgelessRootBlockSpec } from '../../root-block/edgeless/edgeless-root-spec.js';
import { EdgelessSurfaceRefBlockSpec } from '../../surface-ref-block/surface-ref-spec.js';

View File

@@ -1,4 +1,5 @@
import { FrameBlockSpec } from '@blocksuite/affine-block-frame';
import { LatexBlockSpec } from '@blocksuite/affine-block-latex';
import {
ConnectionOverlay,
EdgelessSurfaceBlockSpec,
@@ -7,7 +8,6 @@ import { FontLoaderService } from '@blocksuite/affine-shared/services';
import type { ExtensionType } from '@blocksuite/block-std';
import { EdgelessTextBlockSpec } from '../../edgeless-text-block/edgeless-text-spec.js';
import { LatexBlockSpec } from '../../latex-block/latex-spec.js';
import { EdgelessRootBlockSpec } from '../../root-block/edgeless/edgeless-root-spec.js';
import {
EdgelessFrameManager,

View File

@@ -1,8 +1,8 @@
import { LatexBlockSpec } from '@blocksuite/affine-block-latex';
import { PageSurfaceBlockSpec } from '@blocksuite/affine-block-surface';
import { FontLoaderService } from '@blocksuite/affine-shared/services';
import type { ExtensionType } from '@blocksuite/block-std';
import { LatexBlockSpec } from '../../latex-block/latex-spec.js';
import { PageRootBlockSpec } from '../../root-block/page/page-root-spec.js';
import { PageSurfaceRefBlockSpec } from '../../surface-ref-block/surface-ref-spec.js';
import { CommonFirstPartyBlockSpecs } from '../common.js';

View File

@@ -1,4 +1,5 @@
import { FrameBlockSpec } from '@blocksuite/affine-block-frame';
import { LatexBlockSpec } from '@blocksuite/affine-block-latex';
import {
EdgelessSurfaceBlockSpec,
PageSurfaceBlockSpec,
@@ -19,7 +20,6 @@ import {
import { literal } from 'lit/static-html.js';
import { EdgelessTextBlockSpec } from '../../edgeless-text-block/index.js';
import { LatexBlockSpec } from '../../latex-block/latex-spec.js';
import { PreviewEdgelessRootBlockSpec } from '../../root-block/edgeless/edgeless-root-spec.js';
import { PageRootService } from '../../root-block/page/page-root-service.js';
import {

View File

@@ -3,6 +3,7 @@ import { effects as blockBookmarkEffects } from '@blocksuite/affine-block-bookma
import { effects as blockEmbedEffects } from '@blocksuite/affine-block-embed/effects';
import { effects as blockFrameEffects } from '@blocksuite/affine-block-frame/effects';
import { effects as blockImageEffects } from '@blocksuite/affine-block-image/effects';
import { effects as blockLatexEffects } from '@blocksuite/affine-block-latex/effects';
import { effects as blockListEffects } from '@blocksuite/affine-block-list/effects';
import { effects as blockNoteEffects } from '@blocksuite/affine-block-note/effects';
import { effects as blockParagraphEffects } from '@blocksuite/affine-block-paragraph/effects';
@@ -65,8 +66,6 @@ import {
import { DividerBlockComponent } from './divider-block/index.js';
import type { insertEdgelessTextCommand } from './edgeless-text-block/commands/insert-edgeless-text.js';
import { EdgelessTextBlockComponent } from './edgeless-text-block/index.js';
import { effects as blockLatexEffects } from './latex-block/effects.js';
import { LatexBlockComponent } from './latex-block/index.js';
import { EdgelessAutoCompletePanel } from './root-block/edgeless/components/auto-complete/auto-complete-panel.js';
import { EdgelessAutoComplete } from './root-block/edgeless/components/auto-complete/edgeless-auto-complete.js';
import { EdgelessToolIconButton } from './root-block/edgeless/components/buttons/tool-icon-button.js';
@@ -298,7 +297,6 @@ export function effects() {
customElements.define('center-peek', CenterPeek);
customElements.define('database-datasource-note-renderer', NoteRenderer);
customElements.define('database-datasource-block-renderer', BlockRenderer);
customElements.define('affine-latex', LatexBlockComponent);
customElements.define('affine-page-root', PageRootBlockComponent);
customElements.define('affine-preview-root', PreviewRootBlockComponent);
customElements.define('affine-code', CodeBlockComponent);

View File

@@ -21,7 +21,6 @@ export * from './data-view-block/index.js';
export * from './database-block/index.js';
export * from './divider-block/index.js';
export * from './edgeless-text-block/index.js';
export * from './latex-block/index.js';
export { EdgelessTemplatePanel } from './root-block/edgeless/components/toolbar/template/template-panel.js';
export type {
Template,
@@ -50,6 +49,7 @@ export * from '@blocksuite/affine-block-bookmark';
export * from '@blocksuite/affine-block-embed';
export * from '@blocksuite/affine-block-frame';
export * from '@blocksuite/affine-block-image';
export * from '@blocksuite/affine-block-latex';
export * from '@blocksuite/affine-block-list';
export * from '@blocksuite/affine-block-note';
export * from '@blocksuite/affine-block-paragraph';

View File

@@ -1,11 +0,0 @@
import type { ExtensionType } from '@blocksuite/block-std';
import { LatexBlockMarkdownAdapterExtension } from './markdown.js';
import { LatexBlockNotionHtmlAdapterExtension } from './notion-html.js';
import { LatexBlockPlainTextAdapterExtension } from './plain-text.js';
export const LatexBlockAdapterExtensions: ExtensionType[] = [
LatexBlockMarkdownAdapterExtension,
LatexBlockNotionHtmlAdapterExtension,
LatexBlockPlainTextAdapterExtension,
];

View File

@@ -1,3 +0,0 @@
export * from './markdown.js';
export * from './notion-html.js';
export * from './plain-text.js';

View File

@@ -1,55 +0,0 @@
import { LatexBlockSchema } from '@blocksuite/affine-model';
import {
BlockMarkdownAdapterExtension,
type BlockMarkdownAdapterMatcher,
type MarkdownAST,
} from '@blocksuite/affine-shared/adapters';
import { nanoid } from '@blocksuite/store';
const isLatexNode = (node: MarkdownAST) => node.type === 'math';
export const latexBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher = {
flavour: LatexBlockSchema.model.flavour,
toMatch: o => isLatexNode(o.node),
fromMatch: o => o.node.flavour === LatexBlockSchema.model.flavour,
toBlockSnapshot: {
enter: (o, context) => {
const latex = 'value' in o.node ? o.node.value : '';
const { walkerContext } = context;
walkerContext
.openNode(
{
type: 'block',
id: nanoid(),
flavour: 'affine:latex',
props: {
latex,
},
children: [],
},
'children'
)
.closeNode();
},
},
fromBlockSnapshot: {
enter: (o, context) => {
const latex =
'latex' in o.node.props ? (o.node.props.latex as string) : '';
const { walkerContext } = context;
walkerContext
.openNode(
{
type: 'math',
value: latex,
},
'children'
)
.closeNode();
},
},
};
export const LatexBlockMarkdownAdapterExtension = BlockMarkdownAdapterExtension(
latexBlockMarkdownAdapterMatcher
);

View File

@@ -1,50 +0,0 @@
import { LatexBlockSchema } from '@blocksuite/affine-model';
import {
BlockNotionHtmlAdapterExtension,
type BlockNotionHtmlAdapterMatcher,
HastUtils,
} from '@blocksuite/affine-shared/adapters';
import { nanoid } from '@blocksuite/store';
export const latexBlockNotionHtmlAdapterMatcher: BlockNotionHtmlAdapterMatcher =
{
flavour: LatexBlockSchema.model.flavour,
toMatch: o => {
return (
HastUtils.isElement(o.node) &&
o.node.tagName === 'figure' &&
!!HastUtils.querySelector(o.node, '.equation-container')
);
},
fromMatch: () => false,
toBlockSnapshot: {
enter: (o, context) => {
if (!HastUtils.isElement(o.node)) {
return;
}
const { walkerContext } = context;
const latex = HastUtils.getTextContent(
HastUtils.querySelector(o.node, 'annotation')
);
walkerContext
.openNode(
{
type: 'block',
id: nanoid(),
flavour: LatexBlockSchema.model.flavour,
props: {
latex,
},
children: [],
},
'children'
)
.closeNode();
walkerContext.skipAllChildren();
},
},
fromBlockSnapshot: {},
};
export const LatexBlockNotionHtmlAdapterExtension =
BlockNotionHtmlAdapterExtension(latexBlockNotionHtmlAdapterMatcher);

View File

@@ -1,29 +0,0 @@
import { LatexBlockSchema } from '@blocksuite/affine-model';
import {
BlockPlainTextAdapterExtension,
type BlockPlainTextAdapterMatcher,
} from '@blocksuite/affine-shared/adapters';
const latexPrefix = 'LaTex, with value: ';
export const latexBlockPlainTextAdapterMatcher: BlockPlainTextAdapterMatcher = {
flavour: LatexBlockSchema.model.flavour,
toMatch: () => false,
fromMatch: o => o.node.flavour === LatexBlockSchema.model.flavour,
toBlockSnapshot: {},
fromBlockSnapshot: {
enter: (o, context) => {
const latex =
'latex' in o.node.props ? (o.node.props.latex as string) : '';
const { textBuffer } = context;
if (latex) {
textBuffer.content += `${latexPrefix}${latex}`;
textBuffer.content += '\n';
}
},
},
};
export const LatexBlockPlainTextAdapterExtension =
BlockPlainTextAdapterExtension(latexBlockPlainTextAdapterMatcher);

View File

@@ -1,57 +0,0 @@
import type { LatexProps } from '@blocksuite/affine-model';
import type { BlockCommands, Command } from '@blocksuite/block-std';
import { assertInstanceOf } from '@blocksuite/global/utils';
import { LatexBlockComponent } from './latex-block.js';
export const insertLatexBlockCommand: Command<
'selectedModels',
'insertedLatexBlockId',
{
latex?: string;
place?: 'after' | 'before';
removeEmptyLine?: boolean;
}
> = (ctx, next) => {
const { selectedModels, latex, place, removeEmptyLine, std } = ctx;
if (!selectedModels?.length) return;
const targetModel =
place === 'before'
? selectedModels[0]
: selectedModels[selectedModels.length - 1];
const latexBlockProps: Partial<LatexProps> & {
flavour: 'affine:latex';
} = {
flavour: 'affine:latex',
latex: latex ?? '',
};
const result = std.doc.addSiblingBlocks(
targetModel,
[latexBlockProps],
place
);
if (result.length === 0) return;
if (removeEmptyLine && targetModel.text?.length === 0) {
std.doc.deleteBlock(targetModel);
}
next({
insertedLatexBlockId: std.host.updateComplete.then(async () => {
if (!latex) {
const blockComponent = std.view.getBlock(result[0]);
assertInstanceOf(blockComponent, LatexBlockComponent);
await blockComponent.updateComplete;
blockComponent.toggleEditor();
}
return result[0];
}),
});
};
export const commands: BlockCommands = {
insertLatexBlock: insertLatexBlockCommand,
};

View File

@@ -1,24 +0,0 @@
import type { insertLatexBlockCommand } from './commands.js';
export function effects() {
// TODO(@L-Sun): move other effects to this file
}
declare global {
namespace BlockSuite {
interface CommandContext {
insertedLatexBlockId?: Promise<string>;
}
interface Commands {
/**
* insert a LaTeX block after or before the current block selection
* @param latex the LaTeX content. A input dialog will be shown if not provided
* @param place where to insert the LaTeX block
* @param removeEmptyLine remove the current block if it is empty
* @returns the id of the inserted LaTeX block
*/
insertLatexBlock: typeof insertLatexBlockCommand;
}
}
}

View File

@@ -1,3 +0,0 @@
export * from './adapters/index.js';
export * from './latex-block.js';
export * from './latex-spec.js';

View File

@@ -1,151 +0,0 @@
import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption';
import { createLitPortal } from '@blocksuite/affine-components/portal';
import type { LatexBlockModel } from '@blocksuite/affine-model';
import type { Placement } from '@floating-ui/dom';
import { effect } from '@preact/signals-core';
import katex from 'katex';
import { html, render } from 'lit';
import { query } from 'lit/decorators.js';
import { latexBlockStyles } from './styles.js';
export class LatexBlockComponent extends CaptionedBlockComponent<LatexBlockModel> {
static override styles = latexBlockStyles;
private _editorAbortController: AbortController | null = null;
get editorPlacement(): Placement {
return 'bottom';
}
get isBlockSelected() {
const blockSelection = this.selection.filter('block');
return blockSelection.some(
selection => selection.blockId === this.model.id
);
}
override firstUpdated(props: Map<string, unknown>) {
super.firstUpdated(props);
const { disposables } = this;
this._editorAbortController?.abort();
this._editorAbortController = new AbortController();
disposables.add(() => {
this._editorAbortController?.abort();
});
const katexContainer = this._katexContainer;
if (!katexContainer) return;
disposables.add(
effect(() => {
const latex = this.model.latex$.value;
katexContainer.replaceChildren();
// @ts-expect-error FIXME: ts error
delete katexContainer['_$litPart$'];
if (latex.length === 0) {
render(
html`<span class="latex-block-empty-placeholder">Equation</span>`,
katexContainer
);
} else {
try {
katex.render(latex, katexContainer, {
displayMode: true,
output: 'mathml',
});
} catch {
katexContainer.replaceChildren();
// @ts-expect-error FIXME: ts error
delete katexContainer['_$litPart$'];
render(
html`<span class="latex-block-error-placeholder"
>Error equation</span
>`,
katexContainer
);
}
}
})
);
this.disposables.addFromEvent(this, 'click', () => {
if (this.isBlockSelected) {
this.toggleEditor();
} else {
this.selectBlock();
}
});
}
removeEditor(portal: HTMLDivElement) {
portal.remove();
}
override renderBlock() {
return html`
<div contenteditable="false" class="latex-block-container">
<div class="katex"></div>
</div>
`;
}
selectBlock() {
this.host.command.exec('selectBlock', {
focusBlock: this,
});
}
toggleEditor() {
const katexContainer = this._katexContainer;
if (!katexContainer) return;
this._editorAbortController?.abort();
this._editorAbortController = new AbortController();
this.selection.setGroup('note', []);
const portal = createLitPortal({
template: html`<latex-editor-menu
.std=${this.std}
.latexSignal=${this.model.latex$}
.abortController=${this._editorAbortController}
></latex-editor-menu>`,
container: this.host,
computePosition: {
referenceElement: this,
placement: this.editorPlacement,
autoUpdate: {
animationFrame: true,
},
},
closeOnClickAway: true,
abortController: this._editorAbortController,
shadowDom: false,
portalStyles: {
zIndex: 'var(--affine-z-index-popover)',
},
});
this._editorAbortController.signal.addEventListener(
'abort',
() => {
this.removeEditor(portal);
},
{ once: true }
);
}
@query('.latex-block-container')
private accessor _katexContainer!: HTMLDivElement;
}
declare global {
interface HTMLElementTagNameMap {
'affine-latex': LatexBlockComponent;
}
}

View File

@@ -1,15 +0,0 @@
import {
BlockViewExtension,
CommandExtension,
type ExtensionType,
} from '@blocksuite/block-std';
import { literal } from 'lit/static-html.js';
import { LatexBlockAdapterExtensions } from './adapters/extension.js';
import { commands } from './commands.js';
export const LatexBlockSpec: ExtensionType[] = [
BlockViewExtension('affine:latex', literal`affine-latex`),
CommandExtension(commands),
LatexBlockAdapterExtensions,
].flat();

View File

@@ -1,40 +0,0 @@
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { css } from 'lit';
export const latexBlockStyles = css`
.latex-block-container {
display: flex;
position: relative;
width: 100%;
height: 100%;
padding: 10px 24px;
flex-direction: column;
align-items: center;
justify-content: center;
border-radius: 4px;
overflow-x: auto;
user-select: none;
}
.latex-block-container:hover {
background: ${unsafeCSSVar('hoverColor')};
}
.latex-block-error-placeholder {
color: ${unsafeCSSVarV2('text/highlight/fg/red')};
font-family: Inter;
font-size: 12px;
font-weight: 500;
line-height: normal;
user-select: none;
}
.latex-block-empty-placeholder {
color: ${unsafeCSSVarV2('text/secondary')};
font-family: Inter;
font-size: 12px;
font-weight: 500;
line-height: normal;
user-select: none;
}
`;