diff --git a/blocksuite/affine/blocks/note/src/commands/block-type.ts b/blocksuite/affine/blocks/note/src/commands/block-type.ts index 521ecadfdc..84837e9d8f 100644 --- a/blocksuite/affine/blocks/note/src/commands/block-type.ts +++ b/blocksuite/affine/blocks/note/src/commands/block-type.ts @@ -121,6 +121,38 @@ export const updateBlockType: Command< } return next({ updatedBlocks: [newModel] }); }; + const transformToLatex: Command<{}, { updatedBlocks: BlockModel[] }> = ( + _, + next + ) => { + if (flavour !== 'affine:latex') return; + + const newModels: BlockModel[] = []; + blockModels.forEach(model => { + if ( + !matchModels(model, [ + ParagraphBlockModel, + ListBlockModel, + CodeBlockModel, + ]) + ) { + return; + } + + const latex = model.text?.toString() ?? ''; + const newId = transformModel(model, 'affine:latex', { latex }); + if (!newId) { + return; + } + const newModel = doc.getModelById(newId); + if (newModel) { + newModels.push(newModel); + } + }); + + if (newModels.length === 0) return; + return next({ updatedBlocks: newModels }); + }; const focusText: Command<{ updatedBlocks: BlockModel[] }> = (ctx, next) => { const { updatedBlocks } = ctx; @@ -185,6 +217,27 @@ export const updateBlockType: Command< }); return next(); }; + const selectBlocks: Command<{ updatedBlocks: BlockModel[] }> = ( + ctx, + next + ) => { + const { updatedBlocks } = ctx; + if (!updatedBlocks || updatedBlocks.length === 0) { + return false; + } + + requestAnimationFrame(() => { + host.selection.setGroup( + 'note', + updatedBlocks.map(model => + host.selection.create(BlockSelection, { + blockId: model.id, + }) + ) + ); + }); + return next(); + }; const [result, resultCtx] = std.command .chain() @@ -196,6 +249,7 @@ export const updateBlockType: Command< .try<{ updatedBlocks: BlockModel[] }>(chain => [ chain.pipe(mergeToCode), chain.pipe(appendDivider), + chain.pipe(transformToLatex), chain.pipe((_, next) => { const newModels: BlockModel[] = []; blockModels.forEach(model => { @@ -227,6 +281,14 @@ export const updateBlockType: Command< ]) // focus .try(chain => [ + chain + .pipe((_, next) => { + if (flavour === 'affine:latex') { + return next(); + } + return false; + }) + .pipe(selectBlocks), chain.pipe((_, next) => { if (['affine:code', 'affine:divider'].includes(flavour)) { return next(); diff --git a/blocksuite/affine/blocks/root/package.json b/blocksuite/affine/blocks/root/package.json index 693b677a40..2ad087624c 100644 --- a/blocksuite/affine/blocks/root/package.json +++ b/blocksuite/affine/blocks/root/package.json @@ -30,6 +30,7 @@ "@blocksuite/affine-gfx-pointer": "workspace:*", "@blocksuite/affine-gfx-shape": "workspace:*", "@blocksuite/affine-gfx-text": "workspace:*", + "@blocksuite/affine-inline-latex": "workspace:*", "@blocksuite/affine-inline-preset": "workspace:*", "@blocksuite/affine-model": "workspace:*", "@blocksuite/affine-rich-text": "workspace:*", diff --git a/blocksuite/affine/blocks/root/src/configs/toolbar.ts b/blocksuite/affine/blocks/root/src/configs/toolbar.ts index b51cb417c7..130609dfc3 100644 --- a/blocksuite/affine/blocks/root/src/configs/toolbar.ts +++ b/blocksuite/affine/blocks/root/src/configs/toolbar.ts @@ -15,6 +15,7 @@ import { import type { HighlightType } from '@blocksuite/affine-components/highlight-dropdown-menu'; import { toast } from '@blocksuite/affine-components/toast'; import { EditorChevronDown } from '@blocksuite/affine-components/toolbar'; +import { insertInlineLatex } from '@blocksuite/affine-inline-latex'; import { deleteTextCommand, formatBlockCommand, @@ -61,6 +62,7 @@ import { DeleteIcon, DuplicateIcon, LinkedPageIcon, + TeXIcon, } from '@blocksuite/icons/lit'; import { type BlockComponent, @@ -199,9 +201,9 @@ const alignActionGroup = { const inlineTextActionGroup = { id: 'b.inline-text', when: ({ chain }) => isFormatSupported(chain).run()[0], - actions: textFormatConfigs.map( + actions: textFormatConfigs.flatMap( ({ id, name, action, activeWhen, icon }, score) => { - return { + const textAction: ToolbarAction = { id, icon, score, @@ -209,6 +211,28 @@ const inlineTextActionGroup = { run: ({ host }) => action(host), active: ({ host }) => activeWhen(host), }; + + if (id !== 'underline') { + return [textAction]; + } + + return [ + textAction, + { + id: 'inline-latex', + icon: TeXIcon(), + score: score + 0.5, + tooltip: 'Inline Equation', + run: ({ host }) => { + host.std.command + .chain() + .pipe(getTextSelectionCommand) + .pipe(insertInlineLatex) + .run(); + }, + active: () => false, + }, + ]; } ), } as const satisfies ToolbarActionGroup; diff --git a/blocksuite/affine/blocks/root/tsconfig.json b/blocksuite/affine/blocks/root/tsconfig.json index 66dea5d4f6..b4986b0c66 100644 --- a/blocksuite/affine/blocks/root/tsconfig.json +++ b/blocksuite/affine/blocks/root/tsconfig.json @@ -27,6 +27,7 @@ { "path": "../../gfx/pointer" }, { "path": "../../gfx/shape" }, { "path": "../../gfx/text" }, + { "path": "../../inlines/latex" }, { "path": "../../inlines/preset" }, { "path": "../../model" }, { "path": "../../rich-text" }, diff --git a/blocksuite/affine/inlines/latex/src/command.ts b/blocksuite/affine/inlines/latex/src/command.ts index bb486b172c..1567f20cc8 100644 --- a/blocksuite/affine/inlines/latex/src/command.ts +++ b/blocksuite/affine/inlines/latex/src/command.ts @@ -2,14 +2,48 @@ import { DocModeProvider, TelemetryProvider, } from '@blocksuite/affine-shared/services'; +import type { AffineInlineEditor } from '@blocksuite/affine-shared/types'; import type { Command, TextSelection } from '@blocksuite/std'; +import type { InlineRange } from '@blocksuite/std/inline'; + +function openInlineLatexEditor( + inlineEditor: AffineInlineEditor, + index: number +) { + inlineEditor + .waitForUpdate() + .then(async () => { + await inlineEditor.waitForUpdate(); + + const textPoint = inlineEditor.getTextPoint(index); + if (!textPoint) return; + const [text] = textPoint; + const latexNode = text.parentElement?.closest('affine-latex-node'); + if (!latexNode) return; + latexNode.toggleEditor(); + }) + .catch(console.error); +} + +function getSingleBlockInlineRange( + textSelection: TextSelection +): InlineRange | null { + if (textSelection.to) { + return null; + } + + return { + index: textSelection.from.index, + length: textSelection.from.length, + }; +} export const insertInlineLatex: Command<{ currentTextSelection?: TextSelection; textSelection?: TextSelection; }> = (ctx, next) => { const textSelection = ctx.textSelection ?? ctx.currentTextSelection; - if (!textSelection || !textSelection.isCollapsed()) return; + if (!textSelection) return; const blockComponent = ctx.std.view.getBlock(textSelection.from.blockId); if (!blockComponent) return; @@ -20,24 +54,19 @@ export const insertInlineLatex: Command<{ const inlineEditor = richText.inlineEditor; if (!inlineEditor) return; - inlineEditor.insertText( - { - index: textSelection.from.index, - length: 0, - }, - ' ' - ); - inlineEditor.formatText( - { - index: textSelection.from.index, - length: 1, - }, - { - latex: '', - } - ); + const inlineRange = getSingleBlockInlineRange(textSelection); + if (!inlineRange) return; + + const latex = textSelection.isCollapsed() + ? '' + : inlineEditor.yTextString.slice( + inlineRange.index, + inlineRange.index + inlineRange.length + ); + + inlineEditor.insertText(inlineRange, ' ', { latex }); inlineEditor.setInlineRange({ - index: textSelection.from.index, + index: inlineRange.index, length: 1, }); @@ -56,19 +85,9 @@ export const insertInlineLatex: Command<{ control: 'create inline equation', }); - inlineEditor - .waitForUpdate() - .then(async () => { - await inlineEditor.waitForUpdate(); - - const textPoint = inlineEditor.getTextPoint(textSelection.from.index + 1); - if (!textPoint) return; - const [text] = textPoint; - const latexNode = text.parentElement?.closest('affine-latex-node'); - if (!latexNode) return; - latexNode.toggleEditor(); - }) - .catch(console.error); + if (textSelection.isCollapsed()) { + openInlineLatexEditor(inlineEditor, inlineRange.index + 1); + } next(); }; diff --git a/blocksuite/affine/inlines/latex/src/latex-node/latex-node.ts b/blocksuite/affine/inlines/latex/src/latex-node/latex-node.ts index ad419c5138..df8afc8510 100644 --- a/blocksuite/affine/inlines/latex/src/latex-node/latex-node.ts +++ b/blocksuite/affine/inlines/latex/src/latex-node/latex-node.ts @@ -15,7 +15,7 @@ import { import type { DeltaInsert } from '@blocksuite/store'; import { signal } from '@preact/signals-core'; import katex from 'katex'; -import { css, html, render } from 'lit'; +import { css, html, type PropertyValues, render } from 'lit'; import { property } from 'lit/decorators.js'; export class AffineLatexNode extends SignalWatcher( @@ -85,6 +85,8 @@ export class AffineLatexNode extends SignalWatcher( private _editorAbortController: AbortController | null = null; + private _isEditorOpen = false; + readonly latex$ = signal(''); readonly latexEditorSignal = signal(''); @@ -174,6 +176,22 @@ export class AffineLatexNode extends SignalWatcher( return result; } + protected override updated(changedProperties: PropertyValues) { + super.updated(changedProperties); + + if (!changedProperties.has('delta') || this._isEditorOpen) { + return; + } + + const latex = this.deltaLatex; + if (this.latex$.peek() !== latex) { + this.latex$.value = latex; + } + if (this.latexEditorSignal.peek() !== latex) { + this.latexEditorSignal.value = latex; + } + } + override render() { return html`
@@ -212,9 +230,11 @@ export class AffineLatexNode extends SignalWatcher( }, }); + this._isEditorOpen = true; this._editorAbortController.signal.addEventListener( 'abort', () => { + this._isEditorOpen = false; portal.remove(); const latex = this.latexEditorSignal.peek(); this.latex$.value = latex; diff --git a/blocksuite/affine/rich-text/src/conversion.ts b/blocksuite/affine/rich-text/src/conversion.ts index a48250bea8..796e49268e 100644 --- a/blocksuite/affine/rich-text/src/conversion.ts +++ b/blocksuite/affine/rich-text/src/conversion.ts @@ -13,6 +13,7 @@ import { QuoteIcon, TextIcon, } from '@blocksuite/affine-components/icons'; +import { TeXIcon } from '@blocksuite/icons/lit'; import type { TemplateResult } from 'lit'; /** @@ -119,6 +120,15 @@ export const textConversionConfigs: TextConversionConfig[] = [ hotkey: [`Mod-Alt-c`], icon: CodeBlockIcon, }, + { + flavour: 'affine:latex', + type: undefined, + name: 'Equation', + description: 'Formula block with LaTeX rendering.', + hotkey: null, + icon: TeXIcon(), + searchAlias: ['mathBlock', 'equationBlock', 'latexBlock'], + }, { flavour: 'affine:paragraph', type: 'quote', diff --git a/blocksuite/affine/widgets/keyboard-toolbar/src/config.ts b/blocksuite/affine/widgets/keyboard-toolbar/src/config.ts index 51aa7df3aa..cdce1dcb0e 100644 --- a/blocksuite/affine/widgets/keyboard-toolbar/src/config.ts +++ b/blocksuite/affine/widgets/keyboard-toolbar/src/config.ts @@ -222,6 +222,17 @@ const textToolActionItems: KeyboardToolbarActionItem[] = [ }); }, }, + { + name: 'Equation', + showWhen: ({ std }) => + std.store.schema.flavourSchemaMap.has('affine:latex'), + icon: TeXIcon(), + action: ({ std }) => { + std.command.exec(updateBlockType, { + flavour: 'affine:latex', + }); + }, + }, { name: 'Quote', showWhen: ({ std }) => diff --git a/tests/blocksuite/e2e/latex/inline.spec.ts b/tests/blocksuite/e2e/latex/inline.spec.ts index 08a0ac9c14..353f46a30e 100644 --- a/tests/blocksuite/e2e/latex/inline.spec.ts +++ b/tests/blocksuite/e2e/latex/inline.spec.ts @@ -1,5 +1,12 @@ import { expect } from '@playwright/test'; +import { + dragBetweenIndices, + enterPlaygroundRoom, + focusRichText, + initEmptyParagraphState, + waitNextFrame, +} from '../utils/actions/index.js'; import { cutByKeyboard, pasteByKeyboard, @@ -15,17 +22,13 @@ import { type, undoByKeyboard, } from '../utils/actions/keyboard.js'; -import { - enterPlaygroundRoom, - focusRichText, - initEmptyParagraphState, -} from '../utils/actions/misc.js'; import { assertRichTextInlineDeltas, assertRichTextInlineRange, } from '../utils/asserts.js'; import { ZERO_WIDTH_FOR_EMPTY_LINE } from '../utils/inline-editor.js'; import { test } from '../utils/playwright.js'; +import { getFormatBar } from '../utils/query.js'; test('add inline latex at the start of line', async ({ page }, testInfo) => { await enterPlaygroundRoom(page); @@ -240,6 +243,63 @@ test('add inline latex using slash menu', async ({ page }, testInfo) => { expect(await latexElement.locator('.katex').innerHTML()).toBe(innerHTML); }); +test('should preserve distinct latex values when converting selections in reverse order', async ({ + page, +}) => { + await enterPlaygroundRoom(page); + await initEmptyParagraphState(page); + await focusRichText(page); + + await type(page, 'a+b test a^2 test'); + + await dragBetweenIndices(page, [0, 9], [0, 12]); + const { formatBar } = getFormatBar(page); + await expect(formatBar).toBeVisible(); + await formatBar.getByRole('button', { name: 'Inline equation' }).click(); + await waitNextFrame(page); + + await assertRichTextInlineDeltas(page, [ + { + insert: 'a+b test ', + }, + { + insert: ' ', + attributes: { + latex: 'a^2', + }, + }, + { + insert: ' test', + }, + ]); + + await dragBetweenIndices(page, [0, 0], [0, 3]); + await expect(formatBar).toBeVisible(); + await formatBar.getByRole('button', { name: 'Inline equation' }).click(); + await waitNextFrame(page); + + await assertRichTextInlineDeltas(page, [ + { + insert: ' ', + attributes: { + latex: 'a+b', + }, + }, + { + insert: ' test ', + }, + { + insert: ' ', + attributes: { + latex: 'a^2', + }, + }, + { + insert: ' test', + }, + ]); +}); + test('add inline latex using markdown shortcut', async ({ page }) => { await enterPlaygroundRoom(page); await initEmptyParagraphState(page); diff --git a/tools/utils/src/workspace.gen.ts b/tools/utils/src/workspace.gen.ts index 7644ca4e68..25f042cf0f 100644 --- a/tools/utils/src/workspace.gen.ts +++ b/tools/utils/src/workspace.gen.ts @@ -365,6 +365,7 @@ export const PackageList = [ 'blocksuite/affine/gfx/pointer', 'blocksuite/affine/gfx/shape', 'blocksuite/affine/gfx/text', + 'blocksuite/affine/inlines/latex', 'blocksuite/affine/inlines/preset', 'blocksuite/affine/model', 'blocksuite/affine/rich-text', diff --git a/yarn.lock b/yarn.lock index 995c612758..2f9f033ce2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2241,6 +2241,7 @@ __metadata: "@blocksuite/affine-gfx-pointer": "workspace:*" "@blocksuite/affine-gfx-shape": "workspace:*" "@blocksuite/affine-gfx-text": "workspace:*" + "@blocksuite/affine-inline-latex": "workspace:*" "@blocksuite/affine-inline-preset": "workspace:*" "@blocksuite/affine-model": "workspace:*" "@blocksuite/affine-rich-text": "workspace:*"