feat(editor): latex inline package (#11051)
This commit is contained in:
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;
|
||||
}
|
||||
Reference in new issue
Block a user