feat(editor): latex inline package (#11051)

This commit is contained in:
Saul-Mirone committed 2025-03-21 01:38:08 +00:00
1 parent 76f5c73fbc
commit e50d09db50
44 files changed
+526 -342

No files matched your search

@@ -1,199 +0,0 @@
import { ColorScheme } from '@blocksuite/affine-model';
import { ThemeProvider } from '@blocksuite/affine-shared/services';
import { unsafeCSSVar } from '@blocksuite/affine-shared/theme';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { type BlockStdScope, ShadowlessElement } from '@blocksuite/block-std';
import { InlineManagerExtension } from '@blocksuite/block-std/inline';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { noop } from '@blocksuite/global/utils';
import { DoneIcon } from '@blocksuite/icons/lit';
import { effect, type Signal, signal } from '@preact/signals-core';
import { css, html } from 'lit';
import { property } from 'lit/decorators.js';
import { codeToTokensBase, type ThemedToken } from 'shiki';
import * as Y from 'yjs';
import { LatexEditorUnitSpecExtension } from '../../inline-spec';
export const LatexEditorInlineManagerExtension =
InlineManagerExtension<AffineTextAttributes>({
id: 'latex-inline-editor',
enableMarkdown: false,
specs: [LatexEditorUnitSpecExtension.identifier],
});
export class LatexEditorMenu extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
static override styles = css`
.latex-editor-container {
display: grid;
grid-template-columns: 1fr auto;
grid-template-rows: auto auto;
grid-template-areas:
'editor-box confirm-box'
'hint-box hint-box';
padding: 8px;
border-radius: 8px;
border: 0.5px solid ${unsafeCSSVar('borderColor')};
background: ${unsafeCSSVar('backgroundOverlayPanelColor')};
/* light/toolbarShadow */
box-shadow: 0px 6px 16px 0px rgba(0, 0, 0, 0.14);
}
.latex-editor {
grid-area: editor-box;
width: 280px;
padding: 4px 10px;
border-radius: 4px;
background: ${unsafeCSSVar('white10')};
/* light/activeShadow */
box-shadow: 0px 0px 0px 2px rgba(30, 150, 235, 0.3);
font-family: ${unsafeCSSVar('fontCodeFamily')};
border: 1px solid transparent;
}
.latex-editor:focus-within {
border: 1px solid ${unsafeCSSVar('blue700')};
}
.latex-editor-confirm {
grid-area: confirm-box;
display: flex;
align-items: flex-end;
padding-left: 10px;
}
.latex-editor-hint {
grid-area: hint-box;
padding-top: 6px;
color: ${unsafeCSSVar('placeholderColor')};
/* MobileTypeface/caption */
font-family: 'SF Pro Text';
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px; /* 133.333% */
letter-spacing: -0.24px;
}
`;
highlightTokens$: Signal<ThemedToken[][]> = signal([]);
yText!: Y.Text;
get inlineManager() {
return this.std.get(LatexEditorInlineManagerExtension.identifier);
}
get richText() {
return this.querySelector('rich-text');
}
private _updateHighlightTokens(text: string) {
const editorTheme = this.std.get(ThemeProvider).theme;
const theme = editorTheme === ColorScheme.Dark ? 'dark-plus' : 'light-plus';
codeToTokensBase(text, {
lang: 'latex',
theme,
})
.then(token => {
this.highlightTokens$.value = token;
})
.catch(console.error);
}
override connectedCallback(): void {
super.connectedCallback();
const doc = new Y.Doc();
this.yText = doc.getText('latex');
this.yText.insert(0, this.latexSignal.value);
const yTextObserver = () => {
const text = this.yText.toString();
this.latexSignal.value = text;
this._updateHighlightTokens(text);
};
this.yText.observe(yTextObserver);
this.disposables.add(() => {
this.yText.unobserve(yTextObserver);
});
this.disposables.add(
effect(() => {
noop(this.highlightTokens$.value);
this.richText?.inlineEditor?.render();
})
);
this.disposables.add(
this.std.get(ThemeProvider).theme$.subscribe(() => {
this._updateHighlightTokens(this.yText.toString());
})
);
this.disposables.addFromEvent(this, 'keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
e.stopPropagation();
this.abortController.abort();
}
});
this.disposables.addFromEvent(this, 'pointerdown', e => {
e.stopPropagation();
});
this.disposables.addFromEvent(this, 'pointerup', e => {
e.stopPropagation();
});
this.updateComplete
.then(async () => {
await this.richText?.updateComplete;
setTimeout(() => {
this.richText?.inlineEditor?.focusEnd();
});
})
.catch(console.error);
}
override render() {
return html`<div class="latex-editor-container">
<div class="latex-editor">
<rich-text
.yText=${this.yText}
.attributesSchema=${this.inlineManager.getSchema()}
.attributeRenderer=${this.inlineManager.getRenderer()}
></rich-text>
</div>
<div class="latex-editor-confirm">
<span @click=${() => this.abortController.abort()}
>${DoneIcon({
width: '24',
height: '24',
})}</span
>
</div>
<div class="latex-editor-hint">Shift Enter to line break</div>
</div>`;
}
@property({ attribute: false })
accessor abortController!: AbortController;
@property({ attribute: false })
accessor latexSignal!: Signal<string>;
@property({ attribute: false })
accessor std!: BlockStdScope;
}
@@ -1,55 +0,0 @@
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { ShadowlessElement } from '@blocksuite/block-std';
import { ZERO_WIDTH_SPACE } from '@blocksuite/block-std/inline';
import type { DeltaInsert } from '@blocksuite/store';
import { html } from 'lit';
import { property } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
export class LatexEditorUnit extends ShadowlessElement {
get latexMenu() {
return this.closest('latex-editor-menu');
}
get vElement() {
return this.closest('v-element');
}
override render() {
const plainContent = html`<span
><v-text .str=${this.delta.insert}></v-text
></span>`;
const latexMenu = this.latexMenu;
const vElement = this.vElement;
if (!latexMenu || !vElement) {
return plainContent;
}
const lineIndex = this.vElement.lineIndex;
const tokens = latexMenu.highlightTokens$.value[lineIndex] ?? [];
if (
tokens.length === 0 ||
tokens.reduce((acc, token) => acc + token.content, '') !==
this.delta.insert
) {
return plainContent;
}
return html`<span
>${tokens.map(token => {
return html`<v-text
.str=${token.content}
style=${styleMap({
color: token.color,
})}
></v-text>`;
})}</span
>`;
}
@property({ attribute: false })
accessor delta: DeltaInsert<AffineTextAttributes> = {
insert: ZERO_WIDTH_SPACE,
};
}
@@ -1,264 +0,0 @@
import { createLitPortal } from '@blocksuite/affine-components/portal';
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import {
type BlockComponent,
type BlockStdScope,
ShadowlessElement,
} from '@blocksuite/block-std';
import {
type InlineEditor,
ZERO_WIDTH_NON_JOINER,
ZERO_WIDTH_SPACE,
} from '@blocksuite/block-std/inline';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import type { DeltaInsert } from '@blocksuite/store';
import { signal } from '@preact/signals-core';
import katex from 'katex';
import { css, html, render } from 'lit';
import { property } from 'lit/decorators.js';
export class AffineLatexNode extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
static override styles = css`
affine-latex-node {
display: inline-block;
}
affine-latex-node .affine-latex {
white-space: nowrap;
word-break: break-word;
color: ${unsafeCSSVar('textPrimaryColor')};
fill: var(--affine-icon-color);
border-radius: 4px;
text-decoration: none;
cursor: pointer;
user-select: none;
padding: 1px 2px 1px 0;
display: grid;
grid-template-columns: auto 0;
place-items: center;
padding: 0 4px;
margin: 0 2px;
}
affine-latex-node .affine-latex:hover {
background: ${unsafeCSSVar('hoverColor')};
}
affine-latex-node .affine-latex[data-selected='true'] {
background: ${unsafeCSSVar('hoverColor')};
}
affine-latex-node .error-placeholder {
display: flex;
padding: 2px 4px;
justify-content: center;
align-items: flex-start;
gap: 10px;
border-radius: 4px;
background: ${unsafeCSSVarV2('chip/label/red')};
color: ${unsafeCSSVarV2('text/highlight/fg/red')};
font-family: Inter;
font-size: 12px;
font-weight: 500;
line-height: normal;
}
affine-latex-node .placeholder {
display: flex;
padding: 2px 4px;
justify-content: center;
align-items: flex-start;
border-radius: 4px;
background: ${unsafeCSSVarV2('layer/background/secondary')};
color: ${unsafeCSSVarV2('text/secondary')};
font-family: Inter;
font-size: 12px;
font-weight: 500;
line-height: normal;
}
`;
private _editorAbortController: AbortController | null = null;
readonly latex$ = signal('');
readonly latexEditorSignal = signal('');
get deltaLatex() {
return this.delta.attributes?.latex as string;
}
get latexContainer() {
return this.querySelector<HTMLElement>('.latex-container');
}
override connectedCallback() {
const result = super.connectedCallback();
this.latex$.value = this.deltaLatex;
this.latexEditorSignal.value = this.deltaLatex;
this.disposables.add(
this.latex$.subscribe(latex => {
this.latexEditorSignal.value = latex;
if (latex !== this.deltaLatex) {
this.editor.formatText(
{
index: this.startOffset,
length: this.endOffset - this.startOffset,
},
{
latex,
}
);
}
})
);
this.disposables.add(
this.latexEditorSignal.subscribe(latex => {
this.updateComplete
.then(() => {
const latexContainer = this.latexContainer;
if (!latexContainer) return;
latexContainer.replaceChildren();
// @ts-expect-error lit hack won't fix
delete latexContainer['_$litPart$'];
if (latex.length === 0) {
render(
html`<span class="placeholder">Equation</span>`,
latexContainer
);
} else {
try {
katex.render(latex, latexContainer, {
displayMode: true,
output: 'mathml',
});
} catch {
latexContainer.replaceChildren();
// @ts-expect-error lit hack won't fix
delete latexContainer['_$litPart$'];
render(
html`<span class="error-placeholder">Error equation</span>`,
latexContainer
);
}
}
})
.catch(console.error);
})
);
this._editorAbortController?.abort();
this._editorAbortController = new AbortController();
this.disposables.add(() => {
this._editorAbortController?.abort();
});
this.disposables.addFromEvent(this, 'click', e => {
e.preventDefault();
e.stopPropagation();
if (this.readonly) {
return;
}
this.toggleEditor();
});
return result;
}
override render() {
return html`<span class="affine-latex" data-selected=${this.selected}
><div class="latex-container"></div>
<v-text .str=${ZERO_WIDTH_NON_JOINER}></v-text
></span>`;
}
toggleEditor() {
const blockComponent = this.closest<BlockComponent>('[data-block-id]');
if (!blockComponent) return;
this._editorAbortController?.abort();
this._editorAbortController = new AbortController();
const portal = createLitPortal({
template: html`<latex-editor-menu
.std=${this.std}
.latexSignal=${this.latexEditorSignal}
.abortController=${this._editorAbortController}
></latex-editor-menu>`,
container: blockComponent.host,
computePosition: {
referenceElement: this,
placement: 'bottom-start',
autoUpdate: {
animationFrame: true,
},
},
closeOnClickAway: true,
abortController: this._editorAbortController,
shadowDom: false,
portalStyles: {
zIndex: 'var(--affine-z-index-popover)',
},
});
this._editorAbortController.signal.addEventListener(
'abort',
() => {
portal.remove();
const latex = this.latexEditorSignal.peek();
this.latex$.value = latex;
if (latex !== this.deltaLatex) {
this.editor.formatText(
{
index: this.startOffset,
length: this.endOffset - this.startOffset,
},
{
latex,
}
);
this.editor.setInlineRange({
index: this.endOffset,
length: 0,
});
}
},
{ once: true }
);
}
get readonly() {
return this.std.store.readonly;
}
@property({ attribute: false })
accessor delta: DeltaInsert<AffineTextAttributes> = {
insert: ZERO_WIDTH_SPACE,
};
@property({ attribute: false })
accessor editor!: InlineEditor<AffineTextAttributes>;
@property({ attribute: false })
accessor endOffset!: number;
@property({ attribute: false })
accessor selected = false;
@property({ attribute: false })
accessor startOffset!: number;
@property({ attribute: false })
accessor std!: BlockStdScope;
}