mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-01 06:10:16 +08:00
chore: merge blocksuite source code (#9213)
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
import { CodeBlockSchema } from '@blocksuite/affine-model';
|
||||
import {
|
||||
BlockHtmlAdapterExtension,
|
||||
type BlockHtmlAdapterMatcher,
|
||||
HastUtils,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import { nanoid } from '@blocksuite/store';
|
||||
import { bundledLanguagesInfo, codeToHast } from 'shiki';
|
||||
|
||||
export const codeBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = {
|
||||
flavour: CodeBlockSchema.model.flavour,
|
||||
toMatch: o => HastUtils.isElement(o.node) && o.node.tagName === 'pre',
|
||||
fromMatch: o => o.node.flavour === 'affine:code',
|
||||
toBlockSnapshot: {
|
||||
enter: (o, context) => {
|
||||
if (!HastUtils.isElement(o.node)) {
|
||||
return;
|
||||
}
|
||||
const code = HastUtils.querySelector(o.node, 'code');
|
||||
if (!code) {
|
||||
return;
|
||||
}
|
||||
|
||||
const codeText =
|
||||
code.children.length === 1 && code.children[0].type === 'text'
|
||||
? code.children[0]
|
||||
: { ...code, tagName: 'div' };
|
||||
let codeLang = Array.isArray(code.properties?.className)
|
||||
? code.properties.className.find(
|
||||
className =>
|
||||
typeof className === 'string' && className.startsWith('code-')
|
||||
)
|
||||
: undefined;
|
||||
codeLang =
|
||||
typeof codeLang === 'string'
|
||||
? codeLang.replace('code-', '')
|
||||
: undefined;
|
||||
|
||||
const { walkerContext, deltaConverter } = context;
|
||||
walkerContext
|
||||
.openNode(
|
||||
{
|
||||
type: 'block',
|
||||
id: nanoid(),
|
||||
flavour: 'affine:code',
|
||||
props: {
|
||||
language: codeLang ?? 'Plain Text',
|
||||
text: {
|
||||
'$blocksuite:internal:text$': true,
|
||||
delta: deltaConverter.astToDelta(codeText, {
|
||||
trim: false,
|
||||
pre: true,
|
||||
}),
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
'children'
|
||||
)
|
||||
.closeNode();
|
||||
walkerContext.skipAllChildren();
|
||||
},
|
||||
},
|
||||
fromBlockSnapshot: {
|
||||
enter: async (o, context) => {
|
||||
const { walkerContext } = context;
|
||||
const rawLang = o.node.props.language as string | null;
|
||||
const matchedLang = rawLang
|
||||
? (bundledLanguagesInfo.find(
|
||||
info =>
|
||||
info.id === rawLang ||
|
||||
info.name === rawLang ||
|
||||
info.aliases?.includes(rawLang)
|
||||
)?.id ?? 'text')
|
||||
: 'text';
|
||||
|
||||
// @ts-expect-error FIXME: ts error
|
||||
const text = o.node.props.text.delta as DeltaInsert[];
|
||||
const code = text.map(delta => delta.insert).join('');
|
||||
const hast = await codeToHast(code, {
|
||||
lang: matchedLang,
|
||||
theme: 'light-plus',
|
||||
});
|
||||
|
||||
// @ts-expect-error FIXME: ts error
|
||||
walkerContext.openNode(hast, 'children').closeNode();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const CodeBlockHtmlAdapterExtension = BlockHtmlAdapterExtension(
|
||||
codeBlockHtmlAdapterMatcher
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './html.js';
|
||||
export * from './markdown.js';
|
||||
export * from './notion-html.js';
|
||||
export * from './plain-text.js';
|
||||
@@ -0,0 +1,70 @@
|
||||
import { CodeBlockSchema } from '@blocksuite/affine-model';
|
||||
import {
|
||||
BlockMarkdownAdapterExtension,
|
||||
type BlockMarkdownAdapterMatcher,
|
||||
type MarkdownAST,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import type { DeltaInsert } from '@blocksuite/inline';
|
||||
import { nanoid } from '@blocksuite/store';
|
||||
import type { Code } from 'mdast';
|
||||
|
||||
const isCodeNode = (node: MarkdownAST): node is Code => node.type === 'code';
|
||||
|
||||
export const codeBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher = {
|
||||
flavour: CodeBlockSchema.model.flavour,
|
||||
toMatch: o => isCodeNode(o.node),
|
||||
fromMatch: o => o.node.flavour === 'affine:code',
|
||||
toBlockSnapshot: {
|
||||
enter: (o, context) => {
|
||||
if (!isCodeNode(o.node)) {
|
||||
return;
|
||||
}
|
||||
const { walkerContext } = context;
|
||||
walkerContext
|
||||
.openNode(
|
||||
{
|
||||
type: 'block',
|
||||
id: nanoid(),
|
||||
flavour: 'affine:code',
|
||||
props: {
|
||||
language: o.node.lang ?? 'Plain Text',
|
||||
text: {
|
||||
'$blocksuite:internal:text$': true,
|
||||
delta: [
|
||||
{
|
||||
insert: o.node.value,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
'children'
|
||||
)
|
||||
.closeNode();
|
||||
},
|
||||
},
|
||||
fromBlockSnapshot: {
|
||||
enter: (o, context) => {
|
||||
const text = (o.node.props.text ?? { delta: [] }) as {
|
||||
delta: DeltaInsert[];
|
||||
};
|
||||
const { walkerContext } = context;
|
||||
walkerContext
|
||||
.openNode(
|
||||
{
|
||||
type: 'code',
|
||||
lang: (o.node.props.language as string) ?? null,
|
||||
meta: null,
|
||||
value: text.delta.map(delta => delta.insert).join(''),
|
||||
},
|
||||
'children'
|
||||
)
|
||||
.closeNode();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const CodeBlockMarkdownAdapterExtension = BlockMarkdownAdapterExtension(
|
||||
codeBlockMarkdownAdapterMatcher
|
||||
);
|
||||
@@ -0,0 +1,56 @@
|
||||
import { CodeBlockSchema } from '@blocksuite/affine-model';
|
||||
import {
|
||||
BlockNotionHtmlAdapterExtension,
|
||||
type BlockNotionHtmlAdapterMatcher,
|
||||
HastUtils,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import { nanoid } from '@blocksuite/store';
|
||||
|
||||
export const codeBlockNotionHtmlAdapterMatcher: BlockNotionHtmlAdapterMatcher =
|
||||
{
|
||||
flavour: CodeBlockSchema.model.flavour,
|
||||
toMatch: o => HastUtils.isElement(o.node) && o.node.tagName === 'pre',
|
||||
fromMatch: () => false,
|
||||
toBlockSnapshot: {
|
||||
enter: (o, context) => {
|
||||
if (!HastUtils.isElement(o.node)) {
|
||||
return;
|
||||
}
|
||||
const code = HastUtils.querySelector(o.node, 'code');
|
||||
if (!code) {
|
||||
return;
|
||||
}
|
||||
const { walkerContext, deltaConverter } = context;
|
||||
const codeText =
|
||||
code.children.length === 1 && code.children[0].type === 'text'
|
||||
? code.children[0]
|
||||
: { ...code, tag: 'div' };
|
||||
walkerContext
|
||||
.openNode(
|
||||
{
|
||||
type: 'block',
|
||||
id: nanoid(),
|
||||
flavour: CodeBlockSchema.model.flavour,
|
||||
props: {
|
||||
language: 'Plain Text',
|
||||
text: {
|
||||
'$blocksuite:internal:text$': true,
|
||||
delta: deltaConverter.astToDelta(codeText, {
|
||||
trim: false,
|
||||
pre: true,
|
||||
}),
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
'children'
|
||||
)
|
||||
.closeNode();
|
||||
walkerContext.skipAllChildren();
|
||||
},
|
||||
},
|
||||
fromBlockSnapshot: {},
|
||||
};
|
||||
|
||||
export const CodeBlockNotionHtmlAdapterExtension =
|
||||
BlockNotionHtmlAdapterExtension(codeBlockNotionHtmlAdapterMatcher);
|
||||
@@ -0,0 +1,26 @@
|
||||
import { CodeBlockSchema } from '@blocksuite/affine-model';
|
||||
import {
|
||||
BlockPlainTextAdapterExtension,
|
||||
type BlockPlainTextAdapterMatcher,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import type { DeltaInsert } from '@blocksuite/inline';
|
||||
|
||||
export const codeBlockPlainTextAdapterMatcher: BlockPlainTextAdapterMatcher = {
|
||||
flavour: CodeBlockSchema.model.flavour,
|
||||
toMatch: () => false,
|
||||
fromMatch: o => o.node.flavour === CodeBlockSchema.model.flavour,
|
||||
toBlockSnapshot: {},
|
||||
fromBlockSnapshot: {
|
||||
enter: (o, context) => {
|
||||
const text = (o.node.props.text ?? { delta: [] }) as {
|
||||
delta: DeltaInsert[];
|
||||
};
|
||||
const buffer = text.delta.map(delta => delta.insert).join('');
|
||||
context.textBuffer.content += buffer;
|
||||
context.textBuffer.content += '\n';
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const CodeBlockPlainTextAdapterExtension =
|
||||
BlockPlainTextAdapterExtension(codeBlockPlainTextAdapterMatcher);
|
||||
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
type BlockComponent,
|
||||
Clipboard,
|
||||
type UIEventHandler,
|
||||
} from '@blocksuite/block-std';
|
||||
import { assertExists, DisposableGroup } from '@blocksuite/global/utils';
|
||||
|
||||
import { HtmlAdapter, PlainTextAdapter } from '../../_common/adapters/index.js';
|
||||
import { pasteMiddleware } from '../../root-block/clipboard/middlewares/index.js';
|
||||
|
||||
export class CodeClipboardController {
|
||||
private _clipboard!: Clipboard;
|
||||
|
||||
protected _disposables = new DisposableGroup();
|
||||
|
||||
protected _init = () => {
|
||||
this._clipboard.registerAdapter('text/plain', PlainTextAdapter, 90);
|
||||
this._clipboard.registerAdapter('text/html', HtmlAdapter, 80);
|
||||
const paste = pasteMiddleware(this._std);
|
||||
this._clipboard.use(paste);
|
||||
|
||||
this._disposables.add({
|
||||
dispose: () => {
|
||||
this._clipboard.unregisterAdapter('text/plain');
|
||||
this._clipboard.unregisterAdapter('text/html');
|
||||
this._clipboard.unuse(paste);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
host: BlockComponent;
|
||||
|
||||
onPagePaste: UIEventHandler = ctx => {
|
||||
const e = ctx.get('clipboardState').raw;
|
||||
e.preventDefault();
|
||||
|
||||
this._std.doc.captureSync();
|
||||
this._std.command
|
||||
.chain()
|
||||
.try(cmd => [
|
||||
cmd.getTextSelection().inline<'currentSelectionPath'>((ctx, next) => {
|
||||
const textSelection = ctx.currentTextSelection;
|
||||
assertExists(textSelection);
|
||||
const end = textSelection.to ?? textSelection.from;
|
||||
next({ currentSelectionPath: end.blockId });
|
||||
}),
|
||||
cmd.getBlockSelections().inline<'currentSelectionPath'>((ctx, next) => {
|
||||
const currentBlockSelections = ctx.currentBlockSelections;
|
||||
assertExists(currentBlockSelections);
|
||||
const blockSelection = currentBlockSelections.at(-1);
|
||||
if (!blockSelection) {
|
||||
return;
|
||||
}
|
||||
next({ currentSelectionPath: blockSelection.blockId });
|
||||
}),
|
||||
])
|
||||
.getBlockIndex()
|
||||
.try(cmd => [cmd.getTextSelection().deleteText()])
|
||||
.inline((ctx, next) => {
|
||||
if (!ctx.parentBlock) {
|
||||
return;
|
||||
}
|
||||
this._clipboard
|
||||
.paste(
|
||||
e,
|
||||
this._std.doc,
|
||||
ctx.parentBlock.model.id,
|
||||
ctx.blockIndex ? ctx.blockIndex + 1 : 1
|
||||
)
|
||||
.catch(console.error);
|
||||
|
||||
return next();
|
||||
})
|
||||
.run();
|
||||
return true;
|
||||
};
|
||||
|
||||
private get _std() {
|
||||
return this.host.std;
|
||||
}
|
||||
|
||||
constructor(host: BlockComponent) {
|
||||
this.host = host;
|
||||
}
|
||||
|
||||
hostConnected() {
|
||||
if (this._disposables.disposed) {
|
||||
this._disposables = new DisposableGroup();
|
||||
}
|
||||
if (navigator.clipboard) {
|
||||
this._clipboard = new Clipboard(this._std);
|
||||
this.host.handleEvent('paste', this.onPagePaste);
|
||||
this._init();
|
||||
}
|
||||
}
|
||||
|
||||
hostDisconnected() {
|
||||
this._disposables.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { BundledLanguageInfo, ThemeInput } from 'shiki';
|
||||
|
||||
export interface CodeBlockConfig {
|
||||
theme?: {
|
||||
dark?: ThemeInput;
|
||||
light?: ThemeInput;
|
||||
};
|
||||
langs?: BundledLanguageInfo[];
|
||||
|
||||
/**
|
||||
* Whether to show line numbers in the code block.
|
||||
* @default true
|
||||
*/
|
||||
showLineNumbers?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
BackgroundInlineSpecExtension,
|
||||
BoldInlineSpecExtension,
|
||||
CodeInlineSpecExtension,
|
||||
ColorInlineSpecExtension,
|
||||
InlineManagerExtension,
|
||||
InlineSpecExtension,
|
||||
ItalicInlineSpecExtension,
|
||||
LatexInlineSpecExtension,
|
||||
LinkInlineSpecExtension,
|
||||
StrikeInlineSpecExtension,
|
||||
UnderlineInlineSpecExtension,
|
||||
} from '@blocksuite/affine-components/rich-text';
|
||||
import { html } from 'lit';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const CodeBlockUnitSpecExtension = InlineSpecExtension({
|
||||
name: 'code-block-unit',
|
||||
schema: z.undefined(),
|
||||
match: () => true,
|
||||
renderer: ({ delta }) => {
|
||||
return html`<affine-code-unit .delta=${delta}></affine-code-unit>`;
|
||||
},
|
||||
});
|
||||
|
||||
export const CodeBlockInlineManagerExtension = InlineManagerExtension({
|
||||
id: 'CodeBlockInlineManager',
|
||||
enableMarkdown: false,
|
||||
specs: [
|
||||
BoldInlineSpecExtension.identifier,
|
||||
ItalicInlineSpecExtension.identifier,
|
||||
UnderlineInlineSpecExtension.identifier,
|
||||
StrikeInlineSpecExtension.identifier,
|
||||
CodeInlineSpecExtension.identifier,
|
||||
BackgroundInlineSpecExtension.identifier,
|
||||
ColorInlineSpecExtension.identifier,
|
||||
LatexInlineSpecExtension.identifier,
|
||||
LinkInlineSpecExtension.identifier,
|
||||
CodeBlockUnitSpecExtension.identifier,
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { textKeymap } from '@blocksuite/affine-components/rich-text';
|
||||
import { CodeBlockSchema, ColorScheme } from '@blocksuite/affine-model';
|
||||
import { ThemeProvider } from '@blocksuite/affine-shared/services';
|
||||
import { BlockService } from '@blocksuite/block-std';
|
||||
import { type Signal, signal } from '@preact/signals-core';
|
||||
import {
|
||||
bundledLanguagesInfo,
|
||||
createHighlighterCore,
|
||||
type HighlighterCore,
|
||||
type MaybeGetter,
|
||||
} from 'shiki';
|
||||
import getWasm from 'shiki/wasm';
|
||||
|
||||
import {
|
||||
CODE_BLOCK_DEFAULT_DARK_THEME,
|
||||
CODE_BLOCK_DEFAULT_LIGHT_THEME,
|
||||
} from './highlight/const.js';
|
||||
|
||||
export class CodeBlockService extends BlockService {
|
||||
static override readonly flavour = CodeBlockSchema.model.flavour;
|
||||
|
||||
private _darkThemeKey: string | undefined;
|
||||
|
||||
private _lightThemeKey: string | undefined;
|
||||
|
||||
highlighter$: Signal<HighlighterCore | null> = signal(null);
|
||||
|
||||
get langs() {
|
||||
return this.std.getConfig('affine:code')?.langs ?? bundledLanguagesInfo;
|
||||
}
|
||||
|
||||
get themeKey() {
|
||||
const theme = this.std.get(ThemeProvider).theme$.value;
|
||||
return theme === ColorScheme.Dark
|
||||
? this._darkThemeKey
|
||||
: this._lightThemeKey;
|
||||
}
|
||||
|
||||
override mounted(): void {
|
||||
super.mounted();
|
||||
|
||||
this.bindHotKey(textKeymap(this.std));
|
||||
|
||||
createHighlighterCore({
|
||||
loadWasm: getWasm,
|
||||
})
|
||||
.then(async highlighter => {
|
||||
const config = this.std.getConfig('affine:code');
|
||||
const darkTheme = config?.theme?.dark ?? CODE_BLOCK_DEFAULT_DARK_THEME;
|
||||
const lightTheme =
|
||||
config?.theme?.light ?? CODE_BLOCK_DEFAULT_LIGHT_THEME;
|
||||
|
||||
this._darkThemeKey = (await normalizeGetter(darkTheme)).name;
|
||||
this._lightThemeKey = (await normalizeGetter(lightTheme)).name;
|
||||
|
||||
await highlighter.loadTheme(darkTheme, lightTheme);
|
||||
|
||||
this.highlighter$.value = highlighter;
|
||||
|
||||
this.disposables.add(() => {
|
||||
highlighter.dispose();
|
||||
});
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* https://github.com/shikijs/shiki/blob/933415cdc154fe74ccfb6bbb3eb6a7b7bf183e60/packages/core/src/internal.ts#L31
|
||||
*/
|
||||
export async function normalizeGetter<T>(p: MaybeGetter<T>): Promise<T> {
|
||||
return Promise.resolve(typeof p === 'function' ? (p as any)() : p).then(
|
||||
r => r.default || r
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
BlockViewExtension,
|
||||
type ExtensionType,
|
||||
FlavourExtension,
|
||||
WidgetViewMapExtension,
|
||||
} from '@blocksuite/block-std';
|
||||
import { literal, unsafeStatic } from 'lit/static-html.js';
|
||||
|
||||
import { AFFINE_CODE_TOOLBAR_WIDGET } from '../root-block/widgets/code-toolbar/index.js';
|
||||
import {
|
||||
CodeBlockInlineManagerExtension,
|
||||
CodeBlockUnitSpecExtension,
|
||||
} from './code-block-inline.js';
|
||||
import { CodeBlockService } from './code-block-service.js';
|
||||
|
||||
export const CodeBlockSpec: ExtensionType[] = [
|
||||
FlavourExtension('affine:code'),
|
||||
CodeBlockService,
|
||||
BlockViewExtension('affine:code', literal`affine-code`),
|
||||
WidgetViewMapExtension('affine:code', {
|
||||
codeToolbar: literal`${unsafeStatic(AFFINE_CODE_TOOLBAR_WIDGET)}`,
|
||||
}),
|
||||
CodeBlockInlineManagerExtension,
|
||||
CodeBlockUnitSpecExtension,
|
||||
];
|
||||
@@ -0,0 +1,440 @@
|
||||
import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption';
|
||||
import {
|
||||
focusTextModel,
|
||||
type RichText,
|
||||
} from '@blocksuite/affine-components/rich-text';
|
||||
import type { CodeBlockModel } from '@blocksuite/affine-model';
|
||||
import { BRACKET_PAIRS, NOTE_SELECTOR } from '@blocksuite/affine-shared/consts';
|
||||
import { NotificationProvider } from '@blocksuite/affine-shared/services';
|
||||
import { getViewportElement } from '@blocksuite/affine-shared/utils';
|
||||
import type { BlockComponent } from '@blocksuite/block-std';
|
||||
import { getInlineRangeProvider } from '@blocksuite/block-std';
|
||||
import { IS_MAC } from '@blocksuite/global/env';
|
||||
import { noop } from '@blocksuite/global/utils';
|
||||
import {
|
||||
INLINE_ROOT_ATTR,
|
||||
type InlineRangeProvider,
|
||||
type InlineRootElement,
|
||||
type VLine,
|
||||
} from '@blocksuite/inline';
|
||||
import { Slice } from '@blocksuite/store';
|
||||
import { computed, effect, type Signal, signal } from '@preact/signals-core';
|
||||
import { html, nothing, type TemplateResult } from 'lit';
|
||||
import { query } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import type { ThemedToken } from 'shiki';
|
||||
|
||||
import { EdgelessRootBlockComponent } from '../root-block/edgeless/edgeless-root-block.js';
|
||||
import { CodeClipboardController } from './clipboard/index.js';
|
||||
import { CodeBlockInlineManagerExtension } from './code-block-inline.js';
|
||||
import type { CodeBlockService } from './code-block-service.js';
|
||||
import { codeBlockStyles } from './styles.js';
|
||||
|
||||
export class CodeBlockComponent extends CaptionedBlockComponent<
|
||||
CodeBlockModel,
|
||||
CodeBlockService
|
||||
> {
|
||||
static override styles = codeBlockStyles;
|
||||
|
||||
private _inlineRangeProvider: InlineRangeProvider | null = null;
|
||||
|
||||
clipboardController = new CodeClipboardController(this);
|
||||
|
||||
highlightTokens$: Signal<ThemedToken[][]> = signal([]);
|
||||
|
||||
languageName$: Signal<string> = computed(() => {
|
||||
const lang = this.model.language$.value;
|
||||
if (lang === null) {
|
||||
return 'Plain Text';
|
||||
}
|
||||
|
||||
const matchedInfo = this.service.langs.find(info => info.id === lang);
|
||||
return matchedInfo ? matchedInfo.name : 'Plain Text';
|
||||
});
|
||||
|
||||
get inlineEditor() {
|
||||
const inlineRoot = this.querySelector<InlineRootElement>(
|
||||
`[${INLINE_ROOT_ATTR}]`
|
||||
);
|
||||
return inlineRoot?.inlineEditor;
|
||||
}
|
||||
|
||||
get inlineManager() {
|
||||
return this.std.get(CodeBlockInlineManagerExtension.identifier);
|
||||
}
|
||||
|
||||
get notificationService() {
|
||||
return this.std.getOptional(NotificationProvider);
|
||||
}
|
||||
|
||||
get readonly() {
|
||||
return this.doc.readonly;
|
||||
}
|
||||
|
||||
override get topContenteditableElement() {
|
||||
if (this.rootComponent instanceof EdgelessRootBlockComponent) {
|
||||
const el = this.closest<BlockComponent>(NOTE_SELECTOR);
|
||||
return el;
|
||||
}
|
||||
return this.rootComponent;
|
||||
}
|
||||
|
||||
private _updateHighlightTokens() {
|
||||
const modelLang = this.model.language$.value;
|
||||
if (modelLang === null) {
|
||||
this.highlightTokens$.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const matchedInfo = this.service.langs.find(
|
||||
info =>
|
||||
info.id === modelLang ||
|
||||
info.name === modelLang ||
|
||||
info.aliases?.includes(modelLang)
|
||||
);
|
||||
|
||||
if (matchedInfo) {
|
||||
this.model.language$.value = matchedInfo.id;
|
||||
const langImport = matchedInfo.import;
|
||||
const lang = matchedInfo.id;
|
||||
|
||||
const highlighter = this.service.highlighter$.value;
|
||||
const theme = this.service.themeKey;
|
||||
if (!theme || !highlighter) {
|
||||
this.highlightTokens$.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
noop(this.model.text.deltas$.value);
|
||||
const code = this.model.text.toString();
|
||||
|
||||
const loadedLanguages = highlighter.getLoadedLanguages();
|
||||
if (!loadedLanguages.includes(lang)) {
|
||||
highlighter
|
||||
.loadLanguage(langImport)
|
||||
.then(() => {
|
||||
this.highlightTokens$.value = highlighter.codeToTokensBase(code, {
|
||||
lang,
|
||||
theme,
|
||||
});
|
||||
})
|
||||
.catch(console.error);
|
||||
} else {
|
||||
this.highlightTokens$.value = highlighter.codeToTokensBase(code, {
|
||||
lang,
|
||||
theme,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.highlightTokens$.value = [];
|
||||
// clear language if not found
|
||||
this.model.language$.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
// set highlight options getter used by "exportToHtml"
|
||||
this.clipboardController.hostConnected();
|
||||
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
this._updateHighlightTokens();
|
||||
})
|
||||
);
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
noop(this.highlightTokens$.value);
|
||||
this._richTextElement?.inlineEditor?.render();
|
||||
})
|
||||
);
|
||||
|
||||
const selectionManager = this.host.selection;
|
||||
const INDENT_SYMBOL = ' ';
|
||||
const LINE_BREAK_SYMBOL = '\n';
|
||||
const allIndexOf = (
|
||||
text: string,
|
||||
symbol: string,
|
||||
start = 0,
|
||||
end = text.length
|
||||
) => {
|
||||
const indexArr: number[] = [];
|
||||
let i = start;
|
||||
|
||||
while (i < end) {
|
||||
const index = text.indexOf(symbol, i);
|
||||
if (index === -1 || index > end) {
|
||||
break;
|
||||
}
|
||||
indexArr.push(index);
|
||||
i = index + 1;
|
||||
}
|
||||
return indexArr;
|
||||
};
|
||||
|
||||
// TODO: move to service for better performance
|
||||
this.bindHotKey({
|
||||
Backspace: ctx => {
|
||||
const state = ctx.get('keyboardState');
|
||||
const textSelection = selectionManager.find('text');
|
||||
if (!textSelection) {
|
||||
state.raw.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
const from = textSelection.from;
|
||||
|
||||
if (from.index === 0 && from.length === 0) {
|
||||
state.raw.preventDefault();
|
||||
selectionManager.setGroup('note', [
|
||||
selectionManager.create('block', { blockId: this.blockId }),
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
|
||||
const inlineEditor = this.inlineEditor;
|
||||
const inlineRange = inlineEditor?.getInlineRange();
|
||||
if (!inlineRange || !inlineEditor) return;
|
||||
const left = inlineEditor.yText.toString()[inlineRange.index - 1];
|
||||
const right = inlineEditor.yText.toString()[inlineRange.index];
|
||||
const leftBrackets = BRACKET_PAIRS.map(pair => pair.left);
|
||||
if (BRACKET_PAIRS[leftBrackets.indexOf(left)]?.right === right) {
|
||||
const index = inlineRange.index - 1;
|
||||
inlineEditor.deleteText({
|
||||
index: index,
|
||||
length: 2,
|
||||
});
|
||||
inlineEditor.setInlineRange({
|
||||
index: index,
|
||||
length: 0,
|
||||
});
|
||||
state.raw.preventDefault();
|
||||
return true;
|
||||
}
|
||||
|
||||
return;
|
||||
},
|
||||
Tab: ctx => {
|
||||
if (this.doc.readonly) return;
|
||||
const state = ctx.get('keyboardState');
|
||||
const event = state.raw;
|
||||
const inlineEditor = this.inlineEditor;
|
||||
if (!inlineEditor) return;
|
||||
const inlineRange = inlineEditor.getInlineRange();
|
||||
if (inlineRange) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
const text = this.inlineEditor.yText.toString();
|
||||
const index = text.lastIndexOf(
|
||||
LINE_BREAK_SYMBOL,
|
||||
inlineRange.index - 1
|
||||
);
|
||||
const indexArr = allIndexOf(
|
||||
text,
|
||||
LINE_BREAK_SYMBOL,
|
||||
inlineRange.index,
|
||||
inlineRange.index + inlineRange.length
|
||||
)
|
||||
.map(i => i + 1)
|
||||
.reverse();
|
||||
if (index !== -1) {
|
||||
indexArr.push(index + 1);
|
||||
} else {
|
||||
indexArr.push(0);
|
||||
}
|
||||
indexArr.forEach(i => {
|
||||
if (!this.inlineEditor) return;
|
||||
this.inlineEditor.insertText(
|
||||
{
|
||||
index: i,
|
||||
length: 0,
|
||||
},
|
||||
INDENT_SYMBOL
|
||||
);
|
||||
});
|
||||
this.inlineEditor.setInlineRange({
|
||||
index: inlineRange.index + 2,
|
||||
length:
|
||||
inlineRange.length + (indexArr.length - 1) * INDENT_SYMBOL.length,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return;
|
||||
},
|
||||
'Shift-Tab': ctx => {
|
||||
const state = ctx.get('keyboardState');
|
||||
const event = state.raw;
|
||||
const inlineEditor = this.inlineEditor;
|
||||
if (!inlineEditor) return;
|
||||
const inlineRange = inlineEditor.getInlineRange();
|
||||
if (inlineRange) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
const text = this.inlineEditor.yText.toString();
|
||||
const index = text.lastIndexOf(
|
||||
LINE_BREAK_SYMBOL,
|
||||
inlineRange.index - 1
|
||||
);
|
||||
let indexArr = allIndexOf(
|
||||
text,
|
||||
LINE_BREAK_SYMBOL,
|
||||
inlineRange.index,
|
||||
inlineRange.index + inlineRange.length
|
||||
)
|
||||
.map(i => i + 1)
|
||||
.reverse();
|
||||
if (index !== -1) {
|
||||
indexArr.push(index + 1);
|
||||
} else {
|
||||
indexArr.push(0);
|
||||
}
|
||||
indexArr = indexArr.filter(
|
||||
i => text.slice(i, i + 2) === INDENT_SYMBOL
|
||||
);
|
||||
indexArr.forEach(i => {
|
||||
if (!this.inlineEditor) return;
|
||||
this.inlineEditor.deleteText({
|
||||
index: i,
|
||||
length: 2,
|
||||
});
|
||||
});
|
||||
if (indexArr.length > 0) {
|
||||
this.inlineEditor.setInlineRange({
|
||||
index:
|
||||
inlineRange.index -
|
||||
(indexArr[indexArr.length - 1] < inlineRange.index ? 2 : 0),
|
||||
length:
|
||||
inlineRange.length -
|
||||
(indexArr.length - 1) * INDENT_SYMBOL.length,
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return;
|
||||
},
|
||||
'Control-d': () => {
|
||||
if (!IS_MAC) return;
|
||||
return true;
|
||||
},
|
||||
Delete: () => {
|
||||
return true;
|
||||
},
|
||||
Enter: () => {
|
||||
this.doc.captureSync();
|
||||
return true;
|
||||
},
|
||||
'Mod-Enter': () => {
|
||||
const { model, std } = this;
|
||||
if (!model || !std) return;
|
||||
const inlineEditor = this.inlineEditor;
|
||||
const inlineRange = inlineEditor?.getInlineRange();
|
||||
if (!inlineRange || !inlineEditor) return;
|
||||
const isEnd = model.text.length === inlineRange.index;
|
||||
if (!isEnd) return;
|
||||
const parent = this.doc.getParent(model);
|
||||
if (!parent) return;
|
||||
const index = parent.children.indexOf(model);
|
||||
if (index === -1) return;
|
||||
const id = this.doc.addBlock('affine:paragraph', {}, parent, index + 1);
|
||||
focusTextModel(std, id);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
this._inlineRangeProvider = getInlineRangeProvider(this);
|
||||
}
|
||||
|
||||
copyCode() {
|
||||
const model = this.model;
|
||||
const slice = Slice.fromModels(model.doc, [model]);
|
||||
this.std.clipboard
|
||||
.copySlice(slice)
|
||||
.then(() => {
|
||||
this.notificationService?.toast('Copied to clipboard');
|
||||
})
|
||||
.catch(e => {
|
||||
this.notificationService?.toast('Copied failed, something went wrong');
|
||||
console.error(e);
|
||||
});
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.clipboardController.hostDisconnected();
|
||||
}
|
||||
|
||||
override async getUpdateComplete() {
|
||||
const result = await super.getUpdateComplete();
|
||||
await this._richTextElement?.updateComplete;
|
||||
return result;
|
||||
}
|
||||
|
||||
override renderBlock(): TemplateResult<1> {
|
||||
const showLineNumbers =
|
||||
this.std.getConfig('affine:code')?.showLineNumbers ?? true;
|
||||
|
||||
return html`
|
||||
<div
|
||||
class=${classMap({
|
||||
'affine-code-block-container': true,
|
||||
wrap: this.model.wrap,
|
||||
})}
|
||||
>
|
||||
<rich-text
|
||||
.yText=${this.model.text.yText}
|
||||
.inlineEventSource=${this.topContenteditableElement ?? nothing}
|
||||
.undoManager=${this.doc.history}
|
||||
.attributesSchema=${this.inlineManager.getSchema()}
|
||||
.attributeRenderer=${this.inlineManager.getRenderer()}
|
||||
.readonly=${this.doc.readonly}
|
||||
.inlineRangeProvider=${this._inlineRangeProvider}
|
||||
.enableClipboard=${false}
|
||||
.enableUndoRedo=${false}
|
||||
.wrapText=${this.model.wrap}
|
||||
.verticalScrollContainerGetter=${() => getViewportElement(this.host)}
|
||||
.vLineRenderer=${showLineNumbers
|
||||
? (vLine: VLine) => {
|
||||
return html`
|
||||
<span contenteditable="false" class="line-number"
|
||||
>${vLine.index + 1}</span
|
||||
>
|
||||
${vLine.renderVElements()}
|
||||
`;
|
||||
}
|
||||
: undefined}
|
||||
>
|
||||
</rich-text>
|
||||
|
||||
${this.renderChildren(this.model)} ${Object.values(this.widgets)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
setWrap(wrap: boolean) {
|
||||
this.doc.updateBlock(this.model, { wrap });
|
||||
}
|
||||
|
||||
@query('rich-text')
|
||||
private accessor _richTextElement: RichText | null = null;
|
||||
|
||||
override accessor blockContainerStyles = {
|
||||
margin: '18px 0',
|
||||
};
|
||||
|
||||
override accessor useCaptionEditor = true;
|
||||
|
||||
override accessor useZeroWidth = true;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-code': CodeBlockComponent;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import { ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { type DeltaInsert, ZERO_WIDTH_SPACE } from '@blocksuite/inline';
|
||||
import { html } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
import type { ThemedToken } from 'shiki';
|
||||
|
||||
export class AffineCodeUnit extends ShadowlessElement {
|
||||
get codeBlock() {
|
||||
return this.closest('affine-code');
|
||||
}
|
||||
|
||||
get vElement() {
|
||||
return this.closest('v-element');
|
||||
}
|
||||
|
||||
override render() {
|
||||
const plainContent = html`<span
|
||||
><v-text .str=${this.delta.insert}></v-text
|
||||
></span>`;
|
||||
|
||||
const codeBlock = this.codeBlock;
|
||||
const vElement = this.vElement;
|
||||
if (!codeBlock || !vElement) return plainContent;
|
||||
const tokens = codeBlock.highlightTokens$.value;
|
||||
if (tokens.length === 0) return plainContent;
|
||||
// copy the tokens to avoid modifying the original tokens
|
||||
const lineTokens = structuredClone(tokens[vElement.lineIndex]);
|
||||
if (lineTokens.length === 0) return plainContent;
|
||||
|
||||
const startOffset = vElement.startOffset;
|
||||
const endOffset = vElement.endOffset;
|
||||
const includedTokens: ThemedToken[] = [];
|
||||
lineTokens.forEach(token => {
|
||||
if (
|
||||
(token.offset <= startOffset &&
|
||||
token.offset + token.content.length >= startOffset) ||
|
||||
(token.offset >= startOffset &&
|
||||
token.offset + token.content.length <= endOffset) ||
|
||||
(token.offset <= endOffset &&
|
||||
token.offset + token.content.length >= endOffset)
|
||||
) {
|
||||
includedTokens.push(token);
|
||||
}
|
||||
});
|
||||
if (includedTokens.length === 0) return plainContent;
|
||||
|
||||
if (includedTokens.length === 1) {
|
||||
const token = includedTokens[0];
|
||||
const content = token.content.slice(
|
||||
startOffset - token.offset,
|
||||
endOffset - token.offset
|
||||
);
|
||||
|
||||
return html`<v-text
|
||||
.str=${content}
|
||||
style=${styleMap({
|
||||
color: token.color,
|
||||
})}
|
||||
></v-text>`;
|
||||
} else {
|
||||
const firstToken = includedTokens[0];
|
||||
const lastToken = includedTokens[includedTokens.length - 1];
|
||||
|
||||
const firstContent = firstToken.content.slice(
|
||||
startOffset - firstToken.offset,
|
||||
firstToken.content.length
|
||||
);
|
||||
const lastContent = lastToken.content.slice(
|
||||
0,
|
||||
endOffset - lastToken.offset
|
||||
);
|
||||
firstToken.content = firstContent;
|
||||
lastToken.content = lastContent;
|
||||
|
||||
const vTexts = includedTokens.map(token => {
|
||||
return html`<v-text
|
||||
.str=${token.content}
|
||||
style=${styleMap({
|
||||
color: token.color,
|
||||
})}
|
||||
></v-text>`;
|
||||
});
|
||||
|
||||
return html`<span>${vTexts}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
@property({ type: Object })
|
||||
accessor delta: DeltaInsert<AffineTextAttributes> = {
|
||||
insert: ZERO_WIDTH_SPACE,
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-code-unit': AffineCodeUnit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export const CODE_BLOCK_DEFAULT_DARK_THEME = import(
|
||||
'shiki/themes/dark-plus.mjs'
|
||||
);
|
||||
export const CODE_BLOCK_DEFAULT_LIGHT_THEME = import(
|
||||
'shiki/themes/light-plus.mjs'
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './adapters/markdown.js';
|
||||
export * from './code-block.js';
|
||||
export * from './code-block-config.js';
|
||||
@@ -0,0 +1,49 @@
|
||||
import { css } from 'lit';
|
||||
|
||||
export const codeBlockStyles = css`
|
||||
affine-code {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.affine-code-block-container {
|
||||
font-size: var(--affine-font-xs);
|
||||
line-height: var(--affine-line-height);
|
||||
position: relative;
|
||||
padding: 12px;
|
||||
background: var(--affine-background-code-block);
|
||||
border-radius: 10px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.affine-code-block-container .inline-editor {
|
||||
font-family: var(--affine-font-code-family);
|
||||
font-variant-ligatures: none;
|
||||
}
|
||||
|
||||
.affine-code-block-container v-line {
|
||||
position: relative;
|
||||
display: inline-grid !important;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.affine-code-block-container div:has(> v-line) {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.affine-code-block-container .line-number {
|
||||
position: sticky;
|
||||
text-align: left;
|
||||
padding-right: 4px;
|
||||
width: 24px;
|
||||
word-break: break-word;
|
||||
white-space: nowrap;
|
||||
left: -0.5px;
|
||||
z-index: 1;
|
||||
background: var(--affine-background-code-block);
|
||||
font-size: var(--affine-font-xs);
|
||||
line-height: var(--affine-line-height);
|
||||
color: var(--affine-text-secondary);
|
||||
box-sizing: border-box;
|
||||
user-select: none;
|
||||
}
|
||||
`;
|
||||
Reference in New Issue
Block a user