refactor(editor): extract code block (#9397)

This commit is contained in:
Saul-Mirone
2024-12-27 14:45:11 +00:00
parent 5e1d936c2e
commit 6ebefbbf2b
42 changed files with 177 additions and 52 deletions

View File

@@ -1,4 +1,5 @@
import { BookmarkBlockHtmlAdapterExtension } from '@blocksuite/affine-block-bookmark';
import { CodeBlockHtmlAdapterExtension } from '@blocksuite/affine-block-code';
import { DividerBlockHtmlAdapterExtension } from '@blocksuite/affine-block-divider';
import {
EmbedFigmaBlockHtmlAdapterExtension,
@@ -12,7 +13,6 @@ import { ImageBlockHtmlAdapterExtension } from '@blocksuite/affine-block-image';
import { ListBlockHtmlAdapterExtension } from '@blocksuite/affine-block-list';
import { ParagraphBlockHtmlAdapterExtension } from '@blocksuite/affine-block-paragraph';
import { CodeBlockHtmlAdapterExtension } from '../../../code-block/adapters/html.js';
import { DatabaseBlockHtmlAdapterExtension } from '../../../database-block/adapters/html.js';
import { RootBlockHtmlAdapterExtension } from '../../../root-block/adapters/html.js';

View File

@@ -1,4 +1,5 @@
import { bookmarkBlockMarkdownAdapterMatcher } from '@blocksuite/affine-block-bookmark';
import { codeBlockMarkdownAdapterMatcher } from '@blocksuite/affine-block-code';
import { dividerBlockMarkdownAdapterMatcher } from '@blocksuite/affine-block-divider';
import {
embedFigmaBlockMarkdownAdapterMatcher,
@@ -13,7 +14,6 @@ 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 { rootBlockMarkdownAdapterMatcher } from '../../../root-block/adapters/markdown.js';

View File

@@ -1,5 +1,6 @@
import { AttachmentBlockNotionHtmlAdapterExtension } from '@blocksuite/affine-block-attachment';
import { BookmarkBlockNotionHtmlAdapterExtension } from '@blocksuite/affine-block-bookmark';
import { CodeBlockNotionHtmlAdapterExtension } from '@blocksuite/affine-block-code';
import { DividerBlockNotionHtmlAdapterExtension } from '@blocksuite/affine-block-divider';
import {
EmbedFigmaBlockNotionHtmlAdapterExtension,
@@ -13,7 +14,6 @@ import { ListBlockNotionHtmlAdapterExtension } from '@blocksuite/affine-block-li
import { ParagraphBlockNotionHtmlAdapterExtension } from '@blocksuite/affine-block-paragraph';
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 { RootBlockNotionHtmlAdapterExtension } from '../../../root-block/adapters/notion-html.js';

View File

@@ -1,4 +1,5 @@
import { BookmarkBlockPlainTextAdapterExtension } from '@blocksuite/affine-block-bookmark';
import { CodeBlockPlainTextAdapterExtension } from '@blocksuite/affine-block-code';
import { DividerBlockPlainTextAdapterExtension } from '@blocksuite/affine-block-divider';
import {
EmbedFigmaBlockPlainTextAdapterExtension,
@@ -13,7 +14,6 @@ import { ListBlockPlainTextAdapterExtension } from '@blocksuite/affine-block-lis
import { ParagraphBlockPlainTextAdapterExtension } from '@blocksuite/affine-block-paragraph';
import type { ExtensionType } from '@blocksuite/block-std';
import { CodeBlockPlainTextAdapterExtension } from '../../../code-block/adapters/plain-text.js';
import { DatabaseBlockPlainTextAdapterExtension } from '../../../database-block/adapters/plain-text.js';
export const defaultBlockPlainTextAdapterMatchers: ExtensionType[] = [

View File

@@ -1,5 +1,6 @@
import { AttachmentBlockSpec } from '@blocksuite/affine-block-attachment';
import { BookmarkBlockSpec } from '@blocksuite/affine-block-bookmark';
import { CodeBlockSpec } from '@blocksuite/affine-block-code';
import { DividerBlockSpec } from '@blocksuite/affine-block-divider';
import { EmbedExtensions } from '@blocksuite/affine-block-embed';
import { ImageBlockSpec } from '@blocksuite/affine-block-image';
@@ -14,7 +15,6 @@ import { EditPropsStore } from '@blocksuite/affine-shared/services';
import type { ExtensionType } from '@blocksuite/block-std';
import { AdapterFactoryExtensions } from '../_common/adapters/extension.js';
import { CodeBlockSpec } from '../code-block/code-block-spec.js';
import { DataViewBlockSpec } from '../data-view-block/data-view-spec.js';
import { DatabaseBlockSpec } from '../database-block/database-spec.js';

View File

@@ -1,5 +1,6 @@
import { AttachmentBlockSpec } from '@blocksuite/affine-block-attachment';
import { BookmarkBlockSpec } from '@blocksuite/affine-block-bookmark';
import { CodeBlockSpec } from '@blocksuite/affine-block-code';
import { DividerBlockSpec } from '@blocksuite/affine-block-divider';
import {
EmbedFigmaBlockSpec,
@@ -18,7 +19,6 @@ import {
} from '@blocksuite/affine-block-note';
import { ParagraphBlockSpec } from '@blocksuite/affine-block-paragraph';
import { CodeBlockSpec } from '../../code-block/code-block-spec.js';
import { DataViewBlockSpec } from '../../data-view-block/data-view-spec.js';
import { DatabaseBlockSpec } from '../../database-block/database-spec.js';

View File

@@ -1,3 +1,4 @@
import type { CodeBlockConfig } from '@blocksuite/affine-block-code';
import {
type ReferenceNodeConfig,
ReferenceNodeConfigIdentifier,
@@ -11,7 +12,6 @@ import {
} from '@blocksuite/block-std';
import type { Container } from '@blocksuite/global/di';
import type { CodeBlockConfig } from '../../code-block/code-block-config.js';
import { AFFINE_EMBED_CARD_TOOLBAR_WIDGET } from '../../root-block/widgets/embed-card-toolbar/embed-card-toolbar.js';
import { AFFINE_FORMAT_BAR_WIDGET } from '../../root-block/widgets/format-bar/format-bar.js';
import { AFFINE_SLASH_MENU_WIDGET } from '../../root-block/widgets/slash-menu/index.js';

View File

@@ -1,13 +0,0 @@
import type { ExtensionType } from '@blocksuite/block-std';
import { CodeBlockHtmlAdapterExtension } from './html.js';
import { CodeBlockMarkdownAdapterExtension } from './markdown.js';
import { CodeBlockNotionHtmlAdapterExtension } from './notion-html.js';
import { CodeBlockPlainTextAdapterExtension } from './plain-text.js';
export const CodeBlockAdapterExtensions: ExtensionType[] = [
CodeBlockHtmlAdapterExtension,
CodeBlockMarkdownAdapterExtension,
CodeBlockPlainTextAdapterExtension,
CodeBlockNotionHtmlAdapterExtension,
];

View File

@@ -1,93 +0,0 @@
import { CodeBlockSchema } from '@blocksuite/affine-model';
import {
BlockHtmlAdapterExtension,
type BlockHtmlAdapterMatcher,
HastUtils,
} from '@blocksuite/affine-shared/adapters';
import type { DeltaInsert } from '@blocksuite/inline';
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';
const text = (o.node.props.text as Record<string, unknown>)
.delta as DeltaInsert[];
const code = text.map(delta => delta.insert).join('');
const hast = await codeToHast(code, {
lang: matchedLang,
theme: 'light-plus',
});
walkerContext.openNode(hast as never, 'children').closeNode();
},
},
};
export const CodeBlockHtmlAdapterExtension = BlockHtmlAdapterExtension(
codeBlockHtmlAdapterMatcher
);

View File

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

View File

@@ -1,70 +0,0 @@
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
);

View File

@@ -1,56 +0,0 @@
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);

View File

@@ -1,26 +0,0 @@
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);

View File

@@ -1,102 +0,0 @@
import {
HtmlAdapter,
pasteMiddleware,
PlainTextAdapter,
} from '@blocksuite/affine-shared/adapters';
import {
type BlockComponent,
Clipboard,
type UIEventHandler,
} from '@blocksuite/block-std';
import { assertExists, DisposableGroup } from '@blocksuite/global/utils';
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();
}
}

View File

@@ -1,15 +0,0 @@
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;
}

View File

@@ -1,41 +0,0 @@
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,
],
});

View File

@@ -1,75 +0,0 @@
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
);
}

View File

@@ -1,27 +0,0 @@
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 { CodeBlockAdapterExtensions } from './adapters/extension.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,
CodeBlockAdapterExtensions,
].flat();

View File

@@ -1,441 +0,0 @@
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 {
DocModeProvider,
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 { 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.std.get(DocModeProvider).getEditorMode() === 'edgeless') {
return this.closest<BlockComponent>(NOTE_SELECTOR);
}
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;
}
}

View File

@@ -1,100 +0,0 @@
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;
}
}

View File

@@ -1,6 +0,0 @@
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'
);

View File

@@ -1,3 +0,0 @@
export * from './adapters/markdown.js';
export * from './code-block.js';
export * from './code-block-config.js';

View File

@@ -1,49 +0,0 @@
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;
}
`;

View File

@@ -1,5 +1,6 @@
import { effects as blockAttachmentEffects } from '@blocksuite/affine-block-attachment/effects';
import { effects as blockBookmarkEffects } from '@blocksuite/affine-block-bookmark/effects';
import { effects as blockCodeEffects } from '@blocksuite/affine-block-code/effects';
import { effects as blockDividerEffects } from '@blocksuite/affine-block-divider/effects';
import { effects as blockEdgelessTextEffects } from '@blocksuite/affine-block-edgeless-text/effects';
import { effects as blockEmbedEffects } from '@blocksuite/affine-block-embed/effects';
@@ -36,11 +37,6 @@ import { EmbedCardEditCaptionEditModal } from './_common/components/embed-card/m
import { EmbedCardCreateModal } from './_common/components/embed-card/modal/embed-card-create-modal.js';
import { EmbedCardEditModal } from './_common/components/embed-card/modal/embed-card-edit-modal.js';
import { registerSpecs } from './_specs/register-specs.js';
import { AffineCodeUnit } from './code-block/highlight/affine-code-unit.js';
import {
CodeBlockComponent,
type CodeBlockConfig,
} from './code-block/index.js';
import { DataViewBlockComponent } from './data-view-block/index.js';
import { CenterPeek } from './database-block/components/layout.js';
import { DatabaseTitle } from './database-block/components/title/index.js';
@@ -184,7 +180,6 @@ import {
AIPanelGenerating,
AIPanelInput,
} from './root-block/widgets/ai-panel/components/index.js';
import { effects as widgetCodeToolbarEffects } from './root-block/widgets/code-toolbar/effects.js';
import { AFFINE_DOC_REMOTE_SELECTION_WIDGET } from './root-block/widgets/doc-remote-selection/index.js';
import { DragPreview } from './root-block/widgets/drag-handle/components/drag-preview.js';
import { DropIndicator } from './root-block/widgets/drag-handle/components/drop-indicator.js';
@@ -252,6 +247,7 @@ export function effects() {
blockLatexEffects();
blockEdgelessTextEffects();
blockDividerEffects();
blockCodeEffects();
componentCaptionEffects();
componentContextMenuEffects();
@@ -268,7 +264,6 @@ export function effects() {
widgetLinkedDocEffects();
widgetFrameTitleEffects();
widgetEdgelessElementToolbarEffects();
widgetCodeToolbarEffects();
dataViewEffects();
@@ -281,7 +276,6 @@ export function effects() {
'data-view-header-area-text-editing',
HeaderAreaTextCellEditing
);
customElements.define('affine-code-unit', AffineCodeUnit);
customElements.define('affine-database-rich-text-cell', RichTextCell);
customElements.define(
'affine-database-rich-text-cell-editing',
@@ -292,7 +286,6 @@ export function effects() {
customElements.define('database-datasource-block-renderer', BlockRenderer);
customElements.define('affine-page-root', PageRootBlockComponent);
customElements.define('affine-preview-root', PreviewRootBlockComponent);
customElements.define('affine-code', CodeBlockComponent);
customElements.define('mini-mindmap-preview', MiniMindmapPreview);
customElements.define('mini-mindmap-surface-block', MindmapSurfaceBlock);
customElements.define('affine-data-view', DataViewBlockComponent);
@@ -519,7 +512,6 @@ declare global {
updatedBlocks?: BlockModel[];
}
interface BlockConfigs {
'affine:code': CodeBlockConfig;
'affine:page': RootBlockConfig;
}
interface BlockServices {

View File

@@ -16,7 +16,6 @@ export * from './_common/test-utils/test-utils.js';
export * from './_common/transformers/index.js';
export { type AbstractEditor } from './_common/types.js';
export * from './_specs/index.js';
export * from './code-block/index.js';
export * from './data-view-block/index.js';
export * from './database-block/index.js';
export { EdgelessTemplatePanel } from './root-block/edgeless/components/toolbar/template/template-panel.js';
@@ -44,6 +43,7 @@ export {
export * from './surface-ref-block/index.js';
export * from '@blocksuite/affine-block-attachment';
export * from '@blocksuite/affine-block-bookmark';
export * from '@blocksuite/affine-block-code';
export * from '@blocksuite/affine-block-divider';
export * from '@blocksuite/affine-block-edgeless-text';
export * from '@blocksuite/affine-block-embed';

View File

@@ -1,154 +0,0 @@
import { MoreVerticalIcon } from '@blocksuite/affine-components/icons';
import { createLitPortal } from '@blocksuite/affine-components/portal';
import type {
EditorIconButton,
MenuItemGroup,
} from '@blocksuite/affine-components/toolbar';
import { renderGroups } from '@blocksuite/affine-components/toolbar';
import { noop, WithDisposable } from '@blocksuite/global/utils';
import { flip, offset } from '@floating-ui/dom';
import { css, html, LitElement } from 'lit';
import { property, query, state } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import type { CodeBlockToolbarContext } from '../context.js';
export class AffineCodeToolbar extends WithDisposable(LitElement) {
static override styles = css`
:host {
position: absolute;
top: 0;
right: 0;
}
.code-toolbar-container {
height: 24px;
gap: 4px;
padding: 4px;
margin: 0;
}
.code-toolbar-button {
color: var(--affine-icon-color);
background-color: var(--affine-background-primary-color);
box-shadow: var(--affine-shadow-1);
border-radius: 4px;
}
`;
private _currentOpenMenu: AbortController | null = null;
private _popMenuAbortController: AbortController | null = null;
closeCurrentMenu = () => {
if (this._currentOpenMenu && !this._currentOpenMenu.signal.aborted) {
this._currentOpenMenu.abort();
this._currentOpenMenu = null;
}
};
private _toggleMoreMenu() {
if (
this._currentOpenMenu &&
!this._currentOpenMenu.signal.aborted &&
this._currentOpenMenu === this._popMenuAbortController
) {
this.closeCurrentMenu();
this._moreMenuOpen = false;
return;
}
this.closeCurrentMenu();
this._popMenuAbortController = new AbortController();
this._popMenuAbortController.signal.addEventListener('abort', () => {
this._moreMenuOpen = false;
this.onActiveStatusChange(false);
});
this.onActiveStatusChange(true);
this._currentOpenMenu = this._popMenuAbortController;
if (!this._moreButton) {
console.error(
'Failed to open more menu in code toolbar! Unexpected missing more button'
);
return;
}
createLitPortal({
template: html`
<editor-menu-content
data-show
class="more-popup-menu"
style=${styleMap({
'--content-padding': '8px',
'--packed-height': '4px',
})}
>
<div data-size="large" data-orientation="vertical">
${renderGroups(this.moreGroups, this.context)}
</div>
</editor-menu-content>
`,
// should be greater than block-selection z-index as selection and popover wil share the same stacking context(editor-host)
portalStyles: {
zIndex: 'var(--affine-z-index-popover)',
},
container: this.context.host,
computePosition: {
referenceElement: this._moreButton,
placement: 'bottom-start',
middleware: [flip(), offset(4)],
autoUpdate: { animationFrame: true },
},
abortController: this._popMenuAbortController,
closeOnClickAway: true,
});
this._moreMenuOpen = true;
}
override disconnectedCallback() {
super.disconnectedCallback();
this.closeCurrentMenu();
}
override render() {
return html`
<editor-toolbar class="code-toolbar-container" data-without-bg>
${renderGroups(this.primaryGroups, this.context)}
<editor-icon-button
class="code-toolbar-button more"
data-testid="more"
aria-label="More"
.tooltip=${'More'}
.tooltipOffset=${4}
.iconSize=${'16px'}
.iconContainerPadding=${4}
.showTooltip=${!this._moreMenuOpen}
?disabled=${this.context.doc.readonly}
@click=${() => this._toggleMoreMenu()}
>
${MoreVerticalIcon}
</editor-icon-button>
</editor-toolbar>
`;
}
@query('.code-toolbar-button.more')
private accessor _moreButton!: EditorIconButton;
@state()
private accessor _moreMenuOpen = false;
@property({ attribute: false })
accessor context!: CodeBlockToolbarContext;
@property({ attribute: false })
accessor moreGroups!: MenuItemGroup<CodeBlockToolbarContext>[];
@property({ attribute: false })
accessor onActiveStatusChange: (active: boolean) => void = noop;
@property({ attribute: false })
accessor primaryGroups!: MenuItemGroup<CodeBlockToolbarContext>[];
}

View File

@@ -1,154 +0,0 @@
import {
type FilterableListItem,
type FilterableListOptions,
showPopFilterableList,
} from '@blocksuite/affine-components/filterable-list';
import { ArrowDownIcon } from '@blocksuite/affine-components/icons';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { noop, SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import { css, LitElement, nothing } from 'lit';
import { property, query } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import { html } from 'lit/static-html.js';
import type { CodeBlockComponent } from '../../../../code-block/code-block.js';
export class LanguageListButton extends WithDisposable(
SignalWatcher(LitElement)
) {
static override styles = css`
.lang-button {
background-color: var(--affine-background-primary-color);
box-shadow: var(--affine-shadow-1);
display: flex;
gap: 4px;
padding: 2px 4px;
}
.lang-button:hover {
background: var(--affine-hover-color-filled);
}
.lang-button[hover] {
background: var(--affine-hover-color-filled);
}
.lang-button-icon {
display: flex;
align-items: center;
color: ${unsafeCSSVarV2('icon/primary')};
svg {
height: 16px;
width: 16px;
}
}
`;
private _abortController?: AbortController;
private readonly _clickLangBtn = () => {
if (this.blockComponent.doc.readonly) return;
if (this._abortController) {
// Close the language list if it's already opened.
this._abortController.abort();
return;
}
this._abortController = new AbortController();
this._abortController.signal.addEventListener('abort', () => {
this.onActiveStatusChange(false);
this._abortController = undefined;
});
this.onActiveStatusChange(true);
const options: FilterableListOptions = {
placeholder: 'Search for a language',
onSelect: item => {
const sortedBundledLanguages = this._sortedBundledLanguages;
const index = sortedBundledLanguages.indexOf(item);
if (index !== -1) {
sortedBundledLanguages.splice(index, 1);
sortedBundledLanguages.unshift(item);
}
this.blockComponent.doc.transact(() => {
this.blockComponent.model.language$.value = item.name;
});
},
active: item => item.name === this.blockComponent.model.language,
items: this._sortedBundledLanguages,
};
showPopFilterableList({
options,
referenceElement: this._langButton,
container: this.blockComponent.host,
abortController: this._abortController,
// stacking-context(editor-host)
portalStyles: {
zIndex: 'var(--affine-z-index-popover)',
},
});
};
private _sortedBundledLanguages: FilterableListItem[] = [];
override connectedCallback(): void {
super.connectedCallback();
const langList = localStorage.getItem('blocksuite:code-block:lang-list');
if (langList) {
this._sortedBundledLanguages = JSON.parse(langList);
} else {
this._sortedBundledLanguages = this.blockComponent.service.langs.map(
lang => ({
label: lang.name,
name: lang.id,
aliases: lang.aliases,
})
);
}
this.disposables.add(() => {
localStorage.setItem(
'blocksuite:code-block:lang-list',
JSON.stringify(this._sortedBundledLanguages)
);
});
}
override render() {
const textStyles = styleMap({
fontFamily: 'Inter',
fontSize: 'var(--affine-font-xs)',
fontStyle: 'normal',
fontWeight: '500',
lineHeight: '20px',
padding: '0 4px',
});
return html`<icon-button
class="lang-button"
data-testid="lang-button"
width="auto"
.text=${html`<div style=${textStyles}>
${this.blockComponent.languageName$.value}
</div>`}
height="24px"
@click=${this._clickLangBtn}
?disabled=${this.blockComponent.doc.readonly}
>
<span class="lang-button-icon" slot="suffix">
${!this.blockComponent.doc.readonly ? ArrowDownIcon : nothing}
</span>
</icon-button> `;
}
@query('.lang-button')
private accessor _langButton!: HTMLElement;
@property({ attribute: false })
accessor blockComponent!: CodeBlockComponent;
@property({ attribute: false })
accessor onActiveStatusChange: (active: boolean) => void = noop;
}

View File

@@ -1,177 +0,0 @@
import {
CancelWrapIcon,
CaptionIcon,
CopyIcon,
DeleteIcon,
DuplicateIcon,
WrapIcon,
} from '@blocksuite/affine-components/icons';
import type { MenuItemGroup } from '@blocksuite/affine-components/toolbar';
import { isInsidePageEditor } from '@blocksuite/affine-shared/utils';
import { noop, sleep } from '@blocksuite/global/utils';
import { html } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.js';
import type { CodeBlockToolbarContext } from './context.js';
import { duplicateCodeBlock } from './utils.js';
export const PRIMARY_GROUPS: MenuItemGroup<CodeBlockToolbarContext>[] = [
{
type: 'primary',
items: [
{
type: 'change-lang',
generate: ({ blockComponent, setActive }) => {
const state = { active: false };
return {
action: noop,
render: () =>
html`<language-list-button
.blockComponent=${blockComponent}
.onActiveStatusChange=${async (active: boolean) => {
state.active = active;
if (!active) {
await sleep(1000);
if (state.active) return;
}
setActive(active);
}}
>
</language-list-button>`,
};
},
},
{
type: 'copy-code',
label: 'Copy code',
icon: CopyIcon,
generate: ({ blockComponent }) => {
return {
action: () => {
blockComponent.copyCode();
},
render: item => html`
<editor-icon-button
class="code-toolbar-button copy-code"
aria-label=${ifDefined(item.label)}
.tooltip=${item.label}
.tooltipOffset=${4}
.iconSize=${'16px'}
.iconContainerPadding=${4}
@click=${(e: MouseEvent) => {
e.stopPropagation();
item.action();
}}
>
${item.icon}
</editor-icon-button>
`,
};
},
},
{
type: 'caption',
label: 'Caption',
icon: CaptionIcon,
when: ({ doc }) => !doc.readonly,
generate: ({ blockComponent }) => {
return {
action: () => {
blockComponent.captionEditor?.show();
},
render: item => html`
<editor-icon-button
class="code-toolbar-button caption"
aria-label=${ifDefined(item.label)}
.tooltip=${item.label}
.tooltipOffset=${4}
.iconSize=${'16px'}
.iconContainerPadding=${4}
@click=${(e: MouseEvent) => {
e.stopPropagation();
item.action();
}}
>
${item.icon}
</editor-icon-button>
`,
};
},
},
],
},
];
// Clipboard Group
export const clipboardGroup: MenuItemGroup<CodeBlockToolbarContext> = {
type: 'clipboard',
items: [
{
type: 'wrap',
generate: ({ blockComponent, close }) => {
const wrapped = blockComponent.model.wrap;
const label = wrapped ? 'Cancel wrap' : 'Wrap';
const icon = wrapped ? CancelWrapIcon : WrapIcon;
return {
label,
icon,
action: () => {
blockComponent.setWrap(!wrapped);
close();
},
};
},
},
{
type: 'duplicate',
label: 'Duplicate',
icon: DuplicateIcon,
when: ({ doc }) => !doc.readonly,
action: ({ host, blockComponent, close }) => {
const codeId = duplicateCodeBlock(blockComponent.model);
host.updateComplete
.then(() => {
host.selection.setGroup('note', [
host.selection.create('block', {
blockId: codeId,
}),
]);
if (isInsidePageEditor(host)) {
const duplicateElement = host.view.getBlock(codeId);
if (duplicateElement) {
duplicateElement.scrollIntoView({ block: 'nearest' });
}
}
})
.catch(console.error);
close();
},
},
],
};
// Delete Group
export const deleteGroup: MenuItemGroup<CodeBlockToolbarContext> = {
type: 'delete',
items: [
{
type: 'delete',
label: 'Delete',
icon: DeleteIcon,
when: ({ doc }) => !doc.readonly,
action: ({ doc, blockComponent, close }) => {
doc.deleteBlock(blockComponent.model);
close();
},
},
],
};
export const MORE_GROUPS: MenuItemGroup<CodeBlockToolbarContext>[] = [
clipboardGroup,
deleteGroup,
];

View File

@@ -1,46 +0,0 @@
import { MenuContext } from '@blocksuite/affine-components/toolbar';
import type { CodeBlockComponent } from '../../../code-block/code-block.js';
export class CodeBlockToolbarContext extends MenuContext {
override close = () => {
this.abortController.abort();
};
get doc() {
return this.blockComponent.doc;
}
get host() {
return this.blockComponent.host;
}
get selectedBlockModels() {
if (this.blockComponent.model) return [this.blockComponent.model];
return [];
}
get std() {
return this.blockComponent.std;
}
constructor(
public blockComponent: CodeBlockComponent,
public abortController: AbortController,
public setActive: (active: boolean) => void
) {
super();
}
isEmpty() {
return false;
}
isMultiple() {
return false;
}
isSingle() {
return true;
}
}

View File

@@ -1,20 +0,0 @@
import { AffineCodeToolbar } from './components/code-toolbar.js';
import { LanguageListButton } from './components/lang-button.js';
import {
AFFINE_CODE_TOOLBAR_WIDGET,
AffineCodeToolbarWidget,
} from './index.js';
export function effects() {
customElements.define('language-list-button', LanguageListButton);
customElements.define('affine-code-toolbar', AffineCodeToolbar);
customElements.define(AFFINE_CODE_TOOLBAR_WIDGET, AffineCodeToolbarWidget);
}
declare global {
interface HTMLElementTagNameMap {
'language-list-button': LanguageListButton;
'affine-code-toolbar': AffineCodeToolbar;
[AFFINE_CODE_TOOLBAR_WIDGET]: AffineCodeToolbarWidget;
}
}

View File

@@ -1,159 +0,0 @@
import { HoverController } from '@blocksuite/affine-components/hover';
import type {
AdvancedMenuItem,
MenuItemGroup,
} from '@blocksuite/affine-components/toolbar';
import {
cloneGroups,
getMoreMenuConfig,
} from '@blocksuite/affine-components/toolbar';
import type { CodeBlockModel } from '@blocksuite/affine-model';
import { PAGE_HEADER_HEIGHT } from '@blocksuite/affine-shared/consts';
import { WidgetComponent } from '@blocksuite/block-std';
import { limitShift, shift } from '@floating-ui/dom';
import { html } from 'lit';
import type { CodeBlockComponent } from '../../../code-block/code-block.js';
import { MORE_GROUPS, PRIMARY_GROUPS } from './config.js';
import { CodeBlockToolbarContext } from './context.js';
export const AFFINE_CODE_TOOLBAR_WIDGET = 'affine-code-toolbar-widget';
export class AffineCodeToolbarWidget extends WidgetComponent<
CodeBlockModel,
CodeBlockComponent
> {
private _hoverController: HoverController | null = null;
private _isActivated = false;
private readonly _setHoverController = () => {
this._hoverController = null;
this._hoverController = new HoverController(
this,
({ abortController }) => {
const codeBlock = this.block;
const selection = this.host.selection;
const textSelection = selection.find('text');
if (
!!textSelection &&
(!!textSelection.to || !!textSelection.from.length)
) {
return null;
}
const blockSelections = selection.filter('block');
if (
blockSelections.length > 1 ||
(blockSelections.length === 1 &&
blockSelections[0].blockId !== codeBlock.blockId)
) {
return null;
}
const setActive = (active: boolean) => {
this._isActivated = active;
if (!active && !this._hoverController?.isHovering) {
this._hoverController?.abort();
}
};
const context = new CodeBlockToolbarContext(
codeBlock,
abortController,
setActive
);
return {
template: html`<affine-code-toolbar
.context=${context}
.primaryGroups=${this.primaryGroups}
.moreGroups=${this.moreGroups}
.onActiveStatusChange=${setActive}
></affine-code-toolbar>`,
container: this.block,
// stacking-context(editor-host)
portalStyles: {
zIndex: 'var(--affine-z-index-popover)',
},
computePosition: {
referenceElement: codeBlock,
placement: 'right-start',
middleware: [
shift({
crossAxis: true,
padding: {
top: PAGE_HEADER_HEIGHT + 12,
bottom: 12,
right: 12,
},
limiter: limitShift(),
}),
],
autoUpdate: true,
},
};
},
{ allowMultiple: true }
);
const codeBlock = this.block;
this._hoverController.setReference(codeBlock);
this._hoverController.onAbort = () => {
// If the more menu is opened, don't close it.
if (this._isActivated) return;
this._hoverController?.abort();
return;
};
};
addMoretems = (
items: AdvancedMenuItem<CodeBlockToolbarContext>[],
index?: number,
type?: string
) => {
let group;
if (type) {
group = this.moreGroups.find(g => g.type === type);
}
if (!group) {
group = this.moreGroups[0];
}
if (index === undefined) {
group.items.push(...items);
return this;
}
group.items.splice(index, 0, ...items);
return this;
};
addPrimaryItems = (
items: AdvancedMenuItem<CodeBlockToolbarContext>[],
index?: number
) => {
if (index === undefined) {
this.primaryGroups[0].items.push(...items);
return this;
}
this.primaryGroups[0].items.splice(index, 0, ...items);
return this;
};
/*
* Caches the more menu items.
* Currently only supports configuring more menu.
*/
protected moreGroups: MenuItemGroup<CodeBlockToolbarContext>[] =
cloneGroups(MORE_GROUPS);
protected primaryGroups: MenuItemGroup<CodeBlockToolbarContext>[] =
cloneGroups(PRIMARY_GROUPS);
override firstUpdated() {
this.moreGroups = getMoreMenuConfig(this.std).configure(this.moreGroups);
this._setHoverController();
}
}

View File

@@ -1,16 +0,0 @@
import type { CodeBlockModel } from '@blocksuite/affine-model';
export const duplicateCodeBlock = (model: CodeBlockModel) => {
const keys = model.keys as (keyof typeof model)[];
const values = keys.map(key => model[key]);
const blockProps = Object.fromEntries(keys.map((key, i) => [key, values[i]]));
const { text: _text, ...duplicateProps } = blockProps;
const newProps = {
flavour: model.flavour,
text: model.text.clone(),
...duplicateProps,
};
return model.doc.addSiblingBlocks(model, [newProps])[0];
};

View File

@@ -6,7 +6,6 @@ export {
type AffineAIPanelState,
type AffineAIPanelWidgetConfig,
} from './ai-panel/type.js';
export { AffineCodeToolbarWidget } from './code-toolbar/index.js';
export { AffineDocRemoteSelectionWidget } from './doc-remote-selection/doc-remote-selection.js';
export { AffineDragHandleWidget } from './drag-handle/drag-handle.js';
export {