snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 4s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Analyze (javascript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Some checks failed
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 4s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Analyze (javascript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
This commit is contained in:
48
blocksuite/affine/blocks/code/package.json
Normal file
48
blocksuite/affine/blocks/code/package.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "@blocksuite/affine-block-code",
|
||||
"description": "Code block for BlockSuite.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "yarn run -T tsc"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"keywords": [],
|
||||
"author": "toeverything",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@blocksuite/affine-components": "workspace:*",
|
||||
"@blocksuite/affine-ext-loader": "workspace:*",
|
||||
"@blocksuite/affine-gfx-turbo-renderer": "workspace:*",
|
||||
"@blocksuite/affine-inline-comment": "workspace:*",
|
||||
"@blocksuite/affine-inline-latex": "workspace:*",
|
||||
"@blocksuite/affine-inline-link": "workspace:*",
|
||||
"@blocksuite/affine-inline-preset": "workspace:*",
|
||||
"@blocksuite/affine-model": "workspace:*",
|
||||
"@blocksuite/affine-rich-text": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/affine-widget-slash-menu": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@types/mdast": "^4.0.4",
|
||||
"lit": "^3.2.0",
|
||||
"shiki": "^3.19.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./turbo-painter": "./src/turbo/code-painter.worker.ts",
|
||||
"./view": "./src/view.ts",
|
||||
"./store": "./src/store.ts"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.27.5"
|
||||
}
|
||||
13
blocksuite/affine/blocks/code/src/adapters/extension.ts
Normal file
13
blocksuite/affine/blocks/code/src/adapters/extension.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
|
||||
import { CodeBlockHtmlAdapterExtension } from './html.js';
|
||||
import { CodeBlockMarkdownAdapterExtensions } from './markdown/index.js';
|
||||
import { CodeBlockNotionHtmlAdapterExtension } from './notion-html.js';
|
||||
import { CodeBlockPlainTextAdapterExtension } from './plain-text.js';
|
||||
|
||||
export const CodeBlockAdapterExtensions: ExtensionType[] = [
|
||||
CodeBlockHtmlAdapterExtension,
|
||||
CodeBlockMarkdownAdapterExtensions,
|
||||
CodeBlockPlainTextAdapterExtension,
|
||||
CodeBlockNotionHtmlAdapterExtension,
|
||||
].flat();
|
||||
109
blocksuite/affine/blocks/code/src/adapters/html.ts
Normal file
109
blocksuite/affine/blocks/code/src/adapters/html.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { CodeBlockSchema } from '@blocksuite/affine-model';
|
||||
import {
|
||||
BlockHtmlAdapterExtension,
|
||||
type BlockHtmlAdapterMatcher,
|
||||
CODE_BLOCK_WRAP_KEY,
|
||||
HastUtils,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import type { DeltaInsert } from '@blocksuite/store';
|
||||
import { nanoid } from '@blocksuite/store';
|
||||
|
||||
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, configs } = context;
|
||||
const wrap = configs.get(CODE_BLOCK_WRAP_KEY) === 'true';
|
||||
walkerContext
|
||||
.openNode(
|
||||
{
|
||||
type: 'block',
|
||||
id: nanoid(),
|
||||
flavour: 'affine:code',
|
||||
props: {
|
||||
language: codeLang ?? 'Plain Text',
|
||||
wrap,
|
||||
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 text = (o.node.props.text as Record<string, unknown>)
|
||||
.delta as DeltaInsert[];
|
||||
const code = text.map(delta => delta.insert).join('');
|
||||
|
||||
walkerContext
|
||||
.openNode(
|
||||
{
|
||||
type: 'element',
|
||||
tagName: 'pre',
|
||||
properties: {},
|
||||
children: [],
|
||||
},
|
||||
'children'
|
||||
)
|
||||
.openNode(
|
||||
{
|
||||
type: 'element',
|
||||
tagName: 'code',
|
||||
properties: {
|
||||
className: [`code-${rawLang ?? 'text'}`],
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
value: code,
|
||||
},
|
||||
],
|
||||
},
|
||||
'children'
|
||||
)
|
||||
.closeNode()
|
||||
.closeNode();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const CodeBlockHtmlAdapterExtension = BlockHtmlAdapterExtension(
|
||||
codeBlockHtmlAdapterMatcher
|
||||
);
|
||||
4
blocksuite/affine/blocks/code/src/adapters/index.ts
Normal file
4
blocksuite/affine/blocks/code/src/adapters/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './html.js';
|
||||
export * from './markdown/index.js';
|
||||
export * from './notion-html.js';
|
||||
export * from './plain-text.js';
|
||||
12
blocksuite/affine/blocks/code/src/adapters/markdown/index.ts
Normal file
12
blocksuite/affine/blocks/code/src/adapters/markdown/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
|
||||
import { CodeBlockMarkdownAdapterExtension } from './markdown.js';
|
||||
import { CodeMarkdownPreprocessorExtension } from './preprocessor.js';
|
||||
|
||||
export * from './markdown.js';
|
||||
export * from './preprocessor.js';
|
||||
|
||||
export const CodeBlockMarkdownAdapterExtensions: ExtensionType[] = [
|
||||
CodeMarkdownPreprocessorExtension,
|
||||
CodeBlockMarkdownAdapterExtension,
|
||||
];
|
||||
@@ -0,0 +1,99 @@
|
||||
import { CodeBlockSchema } from '@blocksuite/affine-model';
|
||||
import {
|
||||
BlockMarkdownAdapterExtension,
|
||||
type BlockMarkdownAdapterMatcher,
|
||||
CODE_BLOCK_WRAP_KEY,
|
||||
IN_PARAGRAPH_NODE_CONTEXT_KEY,
|
||||
type MarkdownAST,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import type { DeltaInsert } from '@blocksuite/store';
|
||||
import { nanoid } from '@blocksuite/store';
|
||||
import type { Code, Html } from 'mdast';
|
||||
|
||||
const isCodeNode = (node: MarkdownAST): node is Code => node.type === 'code';
|
||||
const isHtmlNode = (node: MarkdownAST): node is Html => node.type === 'html';
|
||||
|
||||
const isCodeOrHtmlNode = (node: MarkdownAST): node is Code | Html =>
|
||||
isCodeNode(node) || isHtmlNode(node);
|
||||
|
||||
export const codeBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher = {
|
||||
flavour: CodeBlockSchema.model.flavour,
|
||||
toMatch: o => isCodeOrHtmlNode(o.node),
|
||||
fromMatch: o => o.node.flavour === 'affine:code',
|
||||
toBlockSnapshot: {
|
||||
enter: (o, context) => {
|
||||
if (!isCodeOrHtmlNode(o.node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { walkerContext, configs } = context;
|
||||
const wrap = configs.get(CODE_BLOCK_WRAP_KEY) === 'true';
|
||||
let language = 'plain text';
|
||||
switch (o.node.type) {
|
||||
case 'code': {
|
||||
if (o.node.lang) {
|
||||
language = o.node.lang;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'html': {
|
||||
const inParagraphNode = !!walkerContext.getGlobalContext(
|
||||
IN_PARAGRAPH_NODE_CONTEXT_KEY
|
||||
);
|
||||
// only handle top level html node
|
||||
if (inParagraphNode) {
|
||||
return;
|
||||
}
|
||||
language = 'html';
|
||||
break;
|
||||
}
|
||||
}
|
||||
walkerContext
|
||||
.openNode(
|
||||
{
|
||||
type: 'block',
|
||||
id: nanoid(),
|
||||
flavour: 'affine:code',
|
||||
props: {
|
||||
language,
|
||||
wrap,
|
||||
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,76 @@
|
||||
import {
|
||||
type MarkdownAdapterPreprocessor,
|
||||
MarkdownPreprocessorExtension,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import { isValidUrl } from '@blocksuite/affine-shared/utils';
|
||||
|
||||
const codePreprocessor: MarkdownAdapterPreprocessor = {
|
||||
name: 'code',
|
||||
levels: ['slice'],
|
||||
preprocess: content => {
|
||||
let codeFence = '';
|
||||
const lines = content
|
||||
.split('\n')
|
||||
.map(line => {
|
||||
if (line.trimStart().startsWith('-')) {
|
||||
return line;
|
||||
}
|
||||
let trimmedLine = line.trimStart();
|
||||
if (!codeFence && trimmedLine.startsWith('```')) {
|
||||
codeFence = trimmedLine.substring(
|
||||
0,
|
||||
trimmedLine.lastIndexOf('```') + 3
|
||||
);
|
||||
if (codeFence.split('').every(c => c === '`')) {
|
||||
return line;
|
||||
}
|
||||
codeFence = '';
|
||||
}
|
||||
if (!codeFence && trimmedLine.startsWith('~~~')) {
|
||||
codeFence = trimmedLine.substring(
|
||||
0,
|
||||
trimmedLine.lastIndexOf('~~~') + 3
|
||||
);
|
||||
if (codeFence.split('').every(c => c === '~')) {
|
||||
return line;
|
||||
}
|
||||
codeFence = '';
|
||||
}
|
||||
if (
|
||||
!!codeFence &&
|
||||
trimmedLine.startsWith(codeFence) &&
|
||||
trimmedLine.lastIndexOf(codeFence) === 0
|
||||
) {
|
||||
codeFence = '';
|
||||
}
|
||||
if (codeFence) {
|
||||
return line;
|
||||
}
|
||||
|
||||
trimmedLine = trimmedLine.trimEnd();
|
||||
if (
|
||||
!trimmedLine.startsWith('<') &&
|
||||
!trimmedLine.endsWith('>') &&
|
||||
!trimmedLine.includes(' ')
|
||||
) {
|
||||
// check if it is a url link and wrap it with the angle brackets
|
||||
// sometimes the url includes emphasis `_` that will break URL parsing
|
||||
//
|
||||
// eg. /MuawcBMT1Mzvoar09-_66?mode=page&blockIds=rL2_GXbtLU2SsJVfCSmh_
|
||||
// https://www.markdownguide.org/basic-syntax/#urls-and-email-addresses
|
||||
const valid = isValidUrl(trimmedLine);
|
||||
if (valid) {
|
||||
return `<${trimmedLine}>`;
|
||||
}
|
||||
}
|
||||
|
||||
return line.replace(/^ /, ' ');
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return lines;
|
||||
},
|
||||
};
|
||||
|
||||
export const CodeMarkdownPreprocessorExtension =
|
||||
MarkdownPreprocessorExtension(codePreprocessor);
|
||||
59
blocksuite/affine/blocks/code/src/adapters/notion-html.ts
Normal file
59
blocksuite/affine/blocks/code/src/adapters/notion-html.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { CodeBlockSchema } from '@blocksuite/affine-model';
|
||||
import {
|
||||
BlockNotionHtmlAdapterExtension,
|
||||
type BlockNotionHtmlAdapterMatcher,
|
||||
CODE_BLOCK_WRAP_KEY,
|
||||
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, configs } = context;
|
||||
const wrap = configs.get(CODE_BLOCK_WRAP_KEY) === 'true';
|
||||
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',
|
||||
wrap,
|
||||
text: {
|
||||
'$blocksuite:internal:text$': true,
|
||||
delta: deltaConverter.astToDelta(codeText, {
|
||||
trim: false,
|
||||
pre: true,
|
||||
}),
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
'children'
|
||||
)
|
||||
.closeNode();
|
||||
walkerContext.skipAllChildren();
|
||||
},
|
||||
},
|
||||
fromBlockSnapshot: {},
|
||||
};
|
||||
|
||||
export const CodeBlockNotionHtmlAdapterExtension =
|
||||
BlockNotionHtmlAdapterExtension(codeBlockNotionHtmlAdapterMatcher);
|
||||
26
blocksuite/affine/blocks/code/src/adapters/plain-text.ts
Normal file
26
blocksuite/affine/blocks/code/src/adapters/plain-text.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { CodeBlockSchema } from '@blocksuite/affine-model';
|
||||
import {
|
||||
BlockPlainTextAdapterExtension,
|
||||
type BlockPlainTextAdapterMatcher,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import type { DeltaInsert } from '@blocksuite/store';
|
||||
|
||||
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);
|
||||
199
blocksuite/affine/blocks/code/src/clipboard/index.ts
Normal file
199
blocksuite/affine/blocks/code/src/clipboard/index.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { deleteTextCommand } from '@blocksuite/affine-inline-preset';
|
||||
import type { RichText } from '@blocksuite/affine-rich-text';
|
||||
import {
|
||||
HtmlAdapter,
|
||||
pasteMiddleware,
|
||||
PlainTextAdapter,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import {
|
||||
getBlockIndexCommand,
|
||||
getBlockSelectionsCommand,
|
||||
getTextSelectionCommand,
|
||||
} from '@blocksuite/affine-shared/commands';
|
||||
import { type Container, createIdentifier } from '@blocksuite/global/di';
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import {
|
||||
type BlockStdScope,
|
||||
Clipboard,
|
||||
type ClipboardAdapterConfig,
|
||||
LifeCycleWatcher,
|
||||
LifeCycleWatcherIdentifier,
|
||||
StdIdentifier,
|
||||
TextSelection,
|
||||
type UIEventHandler,
|
||||
} from '@blocksuite/std';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
|
||||
export const CodeClipboardAdapterConfigIdentifier =
|
||||
createIdentifier<ClipboardAdapterConfig>('code-clipboard-adapter-config');
|
||||
|
||||
export function CodeClipboardAdapterConfigExtension(
|
||||
config: ClipboardAdapterConfig
|
||||
): ExtensionType {
|
||||
return {
|
||||
setup: di => {
|
||||
di.addImpl(
|
||||
CodeClipboardAdapterConfigIdentifier(config.mimeType),
|
||||
() => config
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const PlainTextClipboardConfig = CodeClipboardAdapterConfigExtension({
|
||||
mimeType: 'text/plain',
|
||||
adapter: PlainTextAdapter,
|
||||
priority: 90,
|
||||
});
|
||||
|
||||
const HtmlClipboardConfig = CodeClipboardAdapterConfigExtension({
|
||||
mimeType: 'text/html',
|
||||
adapter: HtmlAdapter,
|
||||
priority: 80,
|
||||
});
|
||||
|
||||
export class CodeBlockClipboard extends Clipboard {
|
||||
static override readonly key = 'code-block-clipboard';
|
||||
|
||||
override get _adapters() {
|
||||
const adapterConfigs = this.std.provider.getAll(
|
||||
CodeClipboardAdapterConfigIdentifier
|
||||
);
|
||||
return Array.from(adapterConfigs.values());
|
||||
}
|
||||
}
|
||||
|
||||
export class CodeBlockClipboardController extends LifeCycleWatcher {
|
||||
static override key = 'code-block-clipboard-controller';
|
||||
|
||||
private readonly _disposables = new DisposableGroup();
|
||||
|
||||
constructor(
|
||||
std: BlockStdScope,
|
||||
readonly clipboard: CodeBlockClipboard
|
||||
) {
|
||||
super(std);
|
||||
}
|
||||
|
||||
static override setup(di: Container) {
|
||||
di.add(
|
||||
this as unknown as {
|
||||
new (
|
||||
std: BlockStdScope,
|
||||
clipboard: CodeBlockClipboard
|
||||
): CodeBlockClipboardController;
|
||||
},
|
||||
[StdIdentifier, CodeBlockClipboard]
|
||||
);
|
||||
di.addImpl(LifeCycleWatcherIdentifier(this.key), provider =>
|
||||
provider.get(this)
|
||||
);
|
||||
}
|
||||
|
||||
protected _init = () => {
|
||||
const paste = pasteMiddleware(this.std);
|
||||
this.clipboard.use(paste);
|
||||
|
||||
this._disposables.add({
|
||||
dispose: () => {
|
||||
this.clipboard.unuse(paste);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
onPaste: UIEventHandler = ctx => {
|
||||
const e = ctx.get('clipboardState').raw;
|
||||
e.preventDefault();
|
||||
|
||||
const textSelection = this.std.selection.find(TextSelection);
|
||||
const plainText = e.clipboardData
|
||||
?.getData('text/plain')
|
||||
?.replace(/\r?\n|\r/g, '\n');
|
||||
const selectedBlockId = textSelection?.from.blockId;
|
||||
const codeBlock = selectedBlockId
|
||||
? this.std.store.getBlock(selectedBlockId)?.model
|
||||
: null;
|
||||
if (plainText && codeBlock?.flavour === 'affine:code' && selectedBlockId) {
|
||||
const richText = this.std.view
|
||||
.getBlock(selectedBlockId)
|
||||
?.querySelector<RichText>('rich-text');
|
||||
const inlineEditor = richText?.inlineEditor;
|
||||
const inlineRange = inlineEditor?.getInlineRange();
|
||||
if (inlineEditor && inlineRange) {
|
||||
inlineEditor.insertText(inlineRange, plainText);
|
||||
inlineEditor.setInlineRange({
|
||||
index: inlineRange.index + plainText.length,
|
||||
length: 0,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
this.std.store.captureSync();
|
||||
this.std.command
|
||||
.chain()
|
||||
.try(cmd => [
|
||||
cmd.pipe(getTextSelectionCommand).pipe((ctx, next) => {
|
||||
const textSelection = ctx.currentTextSelection;
|
||||
if (!textSelection) return;
|
||||
const end = textSelection.to ?? textSelection.from;
|
||||
next({ currentSelectionPath: end.blockId });
|
||||
}),
|
||||
cmd.pipe(getBlockSelectionsCommand).pipe((ctx, next) => {
|
||||
const currentBlockSelections = ctx.currentBlockSelections;
|
||||
if (!currentBlockSelections) return;
|
||||
const blockSelection = currentBlockSelections.at(-1);
|
||||
if (!blockSelection) return;
|
||||
next({ currentSelectionPath: blockSelection.blockId });
|
||||
}),
|
||||
])
|
||||
.pipe(getBlockIndexCommand)
|
||||
.try(cmd => [cmd.pipe(getTextSelectionCommand).pipe(deleteTextCommand)])
|
||||
.pipe((ctx, next) => {
|
||||
if (!ctx.parentBlock) {
|
||||
return;
|
||||
}
|
||||
this.clipboard
|
||||
.paste(
|
||||
e,
|
||||
this.std.store,
|
||||
ctx.parentBlock.model.id,
|
||||
ctx.blockIndex ? ctx.blockIndex + 1 : 1
|
||||
)
|
||||
.catch(console.error);
|
||||
|
||||
return next();
|
||||
})
|
||||
.run();
|
||||
return true;
|
||||
};
|
||||
|
||||
override mounted() {
|
||||
this._init();
|
||||
|
||||
// add paste event listener for code block
|
||||
const subscription = this.std.view.viewUpdated.subscribe(
|
||||
({ type, method, view }) => {
|
||||
if (type !== 'block' || view.model.flavour !== 'affine:code') return;
|
||||
|
||||
if (method === 'add') {
|
||||
view.handleEvent('paste', this.onPaste);
|
||||
}
|
||||
}
|
||||
);
|
||||
this._disposables.add(subscription);
|
||||
}
|
||||
|
||||
override unmounted() {
|
||||
this._disposables.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
export function getCodeClipboardExtensions(): ExtensionType[] {
|
||||
return [
|
||||
PlainTextClipboardConfig,
|
||||
HtmlClipboardConfig,
|
||||
CodeBlockClipboard,
|
||||
CodeBlockClipboardController,
|
||||
];
|
||||
}
|
||||
19
blocksuite/affine/blocks/code/src/code-block-config.ts
Normal file
19
blocksuite/affine/blocks/code/src/code-block-config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { ConfigExtensionFactory } from '@blocksuite/std';
|
||||
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;
|
||||
}
|
||||
|
||||
export const CodeBlockConfigExtension =
|
||||
ConfigExtensionFactory<CodeBlockConfig>('affine:code');
|
||||
50
blocksuite/affine/blocks/code/src/code-block-inline.ts
Normal file
50
blocksuite/affine/blocks/code/src/code-block-inline.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { CommentInlineSpecExtension } from '@blocksuite/affine-inline-comment';
|
||||
import { LatexInlineSpecExtension } from '@blocksuite/affine-inline-latex';
|
||||
import { LinkInlineSpecExtension } from '@blocksuite/affine-inline-link';
|
||||
import {
|
||||
BackgroundInlineSpecExtension,
|
||||
BoldInlineSpecExtension,
|
||||
CodeInlineSpecExtension,
|
||||
ColorInlineSpecExtension,
|
||||
ItalicInlineSpecExtension,
|
||||
StrikeInlineSpecExtension,
|
||||
UnderlineInlineSpecExtension,
|
||||
} from '@blocksuite/affine-inline-preset';
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import {
|
||||
InlineManagerExtension,
|
||||
InlineSpecExtension,
|
||||
} from '@blocksuite/std/inline';
|
||||
import { html } from 'lit';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const CodeBlockUnitSpecExtension =
|
||||
InlineSpecExtension<AffineTextAttributes>({
|
||||
name: 'code-block-unit',
|
||||
schema: z.object({
|
||||
'code-block-uint': z.undefined(),
|
||||
}),
|
||||
match: () => true,
|
||||
renderer: ({ delta }) => {
|
||||
return html`<affine-code-unit .delta=${delta}></affine-code-unit>`;
|
||||
},
|
||||
});
|
||||
|
||||
export const CodeBlockInlineManagerExtension =
|
||||
InlineManagerExtension<AffineTextAttributes>({
|
||||
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,
|
||||
CommentInlineSpecExtension.identifier,
|
||||
],
|
||||
});
|
||||
115
blocksuite/affine/blocks/code/src/code-block-service.ts
Normal file
115
blocksuite/affine/blocks/code/src/code-block-service.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { ColorScheme } from '@blocksuite/affine-model';
|
||||
import { ThemeProvider } from '@blocksuite/affine-shared/services';
|
||||
import { LifeCycleWatcher } from '@blocksuite/std';
|
||||
import { type Signal, signal } from '@preact/signals-core';
|
||||
import {
|
||||
createHighlighterCore,
|
||||
createOnigurumaEngine,
|
||||
type HighlighterCore,
|
||||
type MaybeGetter,
|
||||
} from 'shiki';
|
||||
import getWasm from 'shiki/wasm';
|
||||
|
||||
import { CodeBlockConfigExtension } from './code-block-config.js';
|
||||
import {
|
||||
CODE_BLOCK_DEFAULT_DARK_THEME,
|
||||
CODE_BLOCK_DEFAULT_LIGHT_THEME,
|
||||
} from './highlight/const.js';
|
||||
|
||||
export class CodeBlockHighlighter extends LifeCycleWatcher {
|
||||
static override key = 'code-block-highlighter';
|
||||
|
||||
// Singleton highlighter instance
|
||||
private static _sharedHighlighter: HighlighterCore | null = null;
|
||||
private static _highlighterPromise: Promise<HighlighterCore> | null = null;
|
||||
private static _refCount = 0;
|
||||
|
||||
private _darkThemeKey: string | undefined;
|
||||
private _lightThemeKey: string | undefined;
|
||||
|
||||
highlighter$: Signal<HighlighterCore | null> = signal(null);
|
||||
|
||||
get themeKey() {
|
||||
const theme = this.std.get(ThemeProvider).theme$.value;
|
||||
return theme === ColorScheme.Dark
|
||||
? this._darkThemeKey
|
||||
: this._lightThemeKey;
|
||||
}
|
||||
|
||||
private readonly _loadTheme = async (
|
||||
highlighter: HighlighterCore
|
||||
): Promise<void> => {
|
||||
if (!CodeBlockHighlighter._isHighlighterInUse(highlighter)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = this.std.getOptional(CodeBlockConfigExtension.identifier);
|
||||
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;
|
||||
|
||||
if (!CodeBlockHighlighter._isHighlighterInUse(highlighter)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await highlighter.loadTheme(darkTheme, lightTheme);
|
||||
|
||||
if (!CodeBlockHighlighter._isHighlighterInUse(highlighter)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.highlighter$.value = highlighter;
|
||||
};
|
||||
|
||||
private static async _getOrCreateHighlighter(): Promise<HighlighterCore> {
|
||||
if (CodeBlockHighlighter._sharedHighlighter) {
|
||||
return CodeBlockHighlighter._sharedHighlighter;
|
||||
}
|
||||
|
||||
if (!CodeBlockHighlighter._highlighterPromise) {
|
||||
CodeBlockHighlighter._highlighterPromise = createHighlighterCore({
|
||||
engine: createOnigurumaEngine(() => getWasm),
|
||||
}).then(highlighter => {
|
||||
CodeBlockHighlighter._sharedHighlighter = highlighter;
|
||||
return highlighter;
|
||||
});
|
||||
}
|
||||
|
||||
return CodeBlockHighlighter._highlighterPromise;
|
||||
}
|
||||
|
||||
override mounted(): void {
|
||||
super.mounted();
|
||||
|
||||
CodeBlockHighlighter._refCount++;
|
||||
|
||||
CodeBlockHighlighter._getOrCreateHighlighter()
|
||||
.then(this._loadTheme)
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
override unmounted(): void {
|
||||
CodeBlockHighlighter._refCount = Math.max(
|
||||
0,
|
||||
CodeBlockHighlighter._refCount - 1
|
||||
);
|
||||
this.highlighter$.value = null;
|
||||
}
|
||||
|
||||
private static _isHighlighterInUse(highlighter: HighlighterCore) {
|
||||
return (
|
||||
CodeBlockHighlighter._refCount > 0 &&
|
||||
CodeBlockHighlighter._sharedHighlighter === highlighter
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
);
|
||||
}
|
||||
543
blocksuite/affine/blocks/code/src/code-block.ts
Normal file
543
blocksuite/affine/blocks/code/src/code-block.ts
Normal file
@@ -0,0 +1,543 @@
|
||||
import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption';
|
||||
import type { CodeBlockModel } from '@blocksuite/affine-model';
|
||||
import { focusTextModel, type RichText } from '@blocksuite/affine-rich-text';
|
||||
import {
|
||||
BRACKET_PAIRS,
|
||||
EDGELESS_TOP_CONTENTEDITABLE_SELECTOR,
|
||||
} from '@blocksuite/affine-shared/consts';
|
||||
import {
|
||||
BlockElementCommentManager,
|
||||
DocModeProvider,
|
||||
EditorSettingProvider,
|
||||
NotificationProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { getViewportElement } from '@blocksuite/affine-shared/utils';
|
||||
import { IS_MAC, IS_MOBILE } from '@blocksuite/global/env';
|
||||
import { noop } from '@blocksuite/global/utils';
|
||||
import type { BlockComponent } from '@blocksuite/std';
|
||||
import { BlockSelection, TextSelection } from '@blocksuite/std';
|
||||
import {
|
||||
getInlineRangeProvider,
|
||||
INLINE_ROOT_ATTR,
|
||||
type InlineRangeProvider,
|
||||
type InlineRootElement,
|
||||
type VLine,
|
||||
} from '@blocksuite/std/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 { styleMap } from 'lit/directives/style-map.js';
|
||||
import { bundledLanguagesInfo, type ThemedToken } from 'shiki';
|
||||
|
||||
import { CodeBlockConfigExtension } from './code-block-config.js';
|
||||
import { CodeBlockInlineManagerExtension } from './code-block-inline.js';
|
||||
import { CodeBlockHighlighter } from './code-block-service.js';
|
||||
import { CodeBlockPreviewIdentifier } from './code-preview-extension.js';
|
||||
import { codeBlockStyles } from './styles.js';
|
||||
|
||||
export class CodeBlockComponent extends CaptionedBlockComponent<CodeBlockModel> {
|
||||
static override styles = codeBlockStyles;
|
||||
|
||||
private _inlineRangeProvider: InlineRangeProvider | null = null;
|
||||
|
||||
private readonly _localPreview$ = signal<boolean | null>(null);
|
||||
|
||||
preview$: Signal<boolean> = computed(() => {
|
||||
const modelPreview = !!this.model.props.preview$.value;
|
||||
if (this.store.readonly) {
|
||||
return this._localPreview$.value ?? modelPreview;
|
||||
}
|
||||
return modelPreview;
|
||||
});
|
||||
|
||||
collapsed$: Signal<boolean> = computed(
|
||||
() => !!this.model.props.collapsed$.value
|
||||
);
|
||||
|
||||
highlightTokens$: Signal<ThemedToken[][]> = signal([]);
|
||||
|
||||
languageName$: Signal<string> = computed(() => {
|
||||
const lang = this.model.props.language$.value;
|
||||
if (lang === null) {
|
||||
return 'Plain Text';
|
||||
}
|
||||
|
||||
const matchedInfo = this.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.store.readonly;
|
||||
}
|
||||
|
||||
get langs() {
|
||||
return (
|
||||
this.std.getOptional(CodeBlockConfigExtension.identifier)?.langs ??
|
||||
bundledLanguagesInfo
|
||||
);
|
||||
}
|
||||
|
||||
get highlighter() {
|
||||
return this.std.get(CodeBlockHighlighter);
|
||||
}
|
||||
|
||||
/** Whether line numbers are currently shown for this block, accounting for the global setting and feature flags. */
|
||||
get showLineNumbers(): boolean {
|
||||
// Feature flag: mobile (or any other consumer) can hard-disable via CodeBlockConfigExtension.
|
||||
const featureEnabled =
|
||||
this.std.getOptional(CodeBlockConfigExtension.identifier)
|
||||
?.showLineNumbers ?? true;
|
||||
if (!featureEnabled) return false;
|
||||
// Per-block prop overrides the global default; global defaults to true.
|
||||
return this.model.props.lineNumber ?? this._showLineNumbersGlobal$.value;
|
||||
}
|
||||
override get topContenteditableElement() {
|
||||
if (this.std.get(DocModeProvider).getEditorMode() === 'edgeless') {
|
||||
return this.closest<BlockComponent>(
|
||||
EDGELESS_TOP_CONTENTEDITABLE_SELECTOR
|
||||
);
|
||||
}
|
||||
return this.rootComponent;
|
||||
}
|
||||
|
||||
private _updateHighlightTokens() {
|
||||
const modelLang = this.model.props.language$.value;
|
||||
if (modelLang === null) {
|
||||
this.highlightTokens$.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const matchedInfo = this.langs.find(
|
||||
info =>
|
||||
info.id === modelLang ||
|
||||
info.name === modelLang ||
|
||||
info.aliases?.includes(modelLang)
|
||||
);
|
||||
|
||||
if (matchedInfo) {
|
||||
this.model.props.language$.value = matchedInfo.id;
|
||||
const langImport = matchedInfo.import;
|
||||
const lang = matchedInfo.id;
|
||||
|
||||
const highlighter = this.highlighter.highlighter$.value;
|
||||
const theme = this.highlighter.themeKey;
|
||||
if (!theme || !highlighter) {
|
||||
this.highlightTokens$.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
noop(this.model.props.text.deltas$.value);
|
||||
const code = this.model.props.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.props.language$.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable signal: true when the global editor setting enables line numbers.
|
||||
* Defaults to true. Updated reactively by an effect in connectedCallback.
|
||||
* Using a writable signal (not reassigned to computed) keeps the reference
|
||||
* stable so any downstream computed/effect that captures it stays correct.
|
||||
*/
|
||||
private readonly _showLineNumbersGlobal$: Signal<boolean> = signal(true);
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
// Reactively sync the global line-number preference from EditorSettingProvider
|
||||
// into the stable _showLineNumbersGlobal$ signal. Using effect() keeps the
|
||||
// signal reference constant (no identity change) while still tracking updates.
|
||||
const editorSetting = this.std.getOptional(EditorSettingProvider);
|
||||
if (editorSetting) {
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
const val = (
|
||||
editorSetting.setting$.value as Record<string, unknown>
|
||||
)?.['codeBlockLineNumbers'];
|
||||
this._showLineNumbersGlobal$.value = val !== false;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// set highlight options getter used by "exportToHtml"
|
||||
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 event = ctx.get('defaultState').event;
|
||||
const textSelection = selectionManager.find(TextSelection);
|
||||
if (!textSelection) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
const from = textSelection.from;
|
||||
|
||||
if (from.index === 0 && from.length === 0) {
|
||||
event.preventDefault();
|
||||
selectionManager.setGroup('note', [
|
||||
selectionManager.create(BlockSelection, { 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,
|
||||
});
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
|
||||
return;
|
||||
},
|
||||
Tab: ctx => {
|
||||
if (this.store.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;
|
||||
},
|
||||
Enter: () => {
|
||||
this.store.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.props.text.length === inlineRange.index;
|
||||
if (!isEnd) return;
|
||||
const parent = this.store.getParent(model);
|
||||
if (!parent) return;
|
||||
const index = parent.children.indexOf(model);
|
||||
if (index === -1) return;
|
||||
const id = this.store.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.store, [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);
|
||||
});
|
||||
}
|
||||
|
||||
get isCommentHighlighted() {
|
||||
return (
|
||||
this.std
|
||||
.getOptional(BlockElementCommentManager)
|
||||
?.isBlockCommentHighlighted(this.model) ?? false
|
||||
);
|
||||
}
|
||||
|
||||
override async getUpdateComplete() {
|
||||
const result = await super.getUpdateComplete();
|
||||
await this._richTextElement?.updateComplete;
|
||||
return result;
|
||||
}
|
||||
|
||||
override renderBlock(): TemplateResult<1> {
|
||||
const showLineNumbers = this.showLineNumbers;
|
||||
|
||||
const preview = this.preview$.value;
|
||||
const previewContext = this.std.getOptional(
|
||||
CodeBlockPreviewIdentifier(this.model.props.language ?? '')
|
||||
);
|
||||
const shouldRenderPreview = preview && previewContext;
|
||||
const collapsed = this.collapsed$.value;
|
||||
|
||||
return html`
|
||||
<div
|
||||
class=${classMap({
|
||||
'affine-code-block-container': true,
|
||||
'highlight-comment': this.isCommentHighlighted,
|
||||
mobile: IS_MOBILE,
|
||||
wrap: this.model.props.wrap,
|
||||
'disable-line-numbers': !showLineNumbers,
|
||||
collapsed,
|
||||
})}
|
||||
>
|
||||
<rich-text
|
||||
style=${styleMap({
|
||||
display: shouldRenderPreview ? 'none' : undefined,
|
||||
})}
|
||||
.yText=${this.model.props.text.yText}
|
||||
.inlineEventSource=${this.topContenteditableElement ?? nothing}
|
||||
.undoManager=${this.store.history.undoManager}
|
||||
.attributesSchema=${this.inlineManager.getSchema()}
|
||||
.attributeRenderer=${this.inlineManager.getRenderer()}
|
||||
.readonly=${this.store.readonly}
|
||||
.inlineRangeProvider=${this._inlineRangeProvider}
|
||||
.enableClipboard=${false}
|
||||
.enableUndoRedo=${false}
|
||||
.wrapText=${this.model.props.wrap}
|
||||
.verticalScrollContainerGetter=${() => getViewportElement(this.host)}
|
||||
.vLineRenderer=${(vLine: VLine) => {
|
||||
return html`
|
||||
<span contenteditable="false" class="line-number"
|
||||
>${vLine.index + 1}</span
|
||||
>
|
||||
${vLine.renderVElements()}
|
||||
`;
|
||||
}}
|
||||
>
|
||||
</rich-text>
|
||||
${
|
||||
collapsed
|
||||
? html`<div class="code-collapsed-fade" aria-hidden="true"></div>`
|
||||
: nothing
|
||||
}
|
||||
<div
|
||||
style=${styleMap({
|
||||
display: shouldRenderPreview && !collapsed ? undefined : 'none',
|
||||
})}
|
||||
contenteditable="false"
|
||||
class="affine-code-block-preview"
|
||||
>
|
||||
${shouldRenderPreview && previewContext?.renderer(this.model)}
|
||||
</div>
|
||||
${this.renderChildren(this.model)} ${Object.values(this.widgets)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
setWrap(wrap: boolean) {
|
||||
this.store.updateBlock(this.model, { wrap });
|
||||
}
|
||||
|
||||
setCollapsed(collapsed: boolean) {
|
||||
this.store.updateBlock(this.model, { collapsed });
|
||||
}
|
||||
|
||||
@query('rich-text')
|
||||
private accessor _richTextElement: RichText | null = null;
|
||||
|
||||
override accessor blockContainerStyles = {
|
||||
margin: '18px 0',
|
||||
};
|
||||
|
||||
override accessor useCaptionEditor = true;
|
||||
|
||||
override accessor useZeroWidth = true;
|
||||
|
||||
setPreviewState(preview: boolean) {
|
||||
if (this.store.readonly) {
|
||||
this._localPreview$.value = preview;
|
||||
} else {
|
||||
this.store.updateBlock(this.model, { preview });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-code': CodeBlockComponent;
|
||||
}
|
||||
}
|
||||
7
blocksuite/affine/blocks/code/src/code-keymap.ts
Normal file
7
blocksuite/affine/blocks/code/src/code-keymap.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { textKeymap } from '@blocksuite/affine-inline-preset';
|
||||
import { CodeBlockSchema } from '@blocksuite/affine-model';
|
||||
import { KeymapExtension } from '@blocksuite/std';
|
||||
|
||||
export const CodeKeymapExtension = KeymapExtension(textKeymap, {
|
||||
flavour: CodeBlockSchema.model.flavour,
|
||||
});
|
||||
27
blocksuite/affine/blocks/code/src/code-preview-extension.ts
Normal file
27
blocksuite/affine/blocks/code/src/code-preview-extension.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { CodeBlockModel } from '@blocksuite/affine-model';
|
||||
import { createIdentifier } from '@blocksuite/global/di';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
import type { HTMLTemplateResult } from 'lit';
|
||||
|
||||
export type CodeBlockPreviewRenderer = (
|
||||
model: CodeBlockModel
|
||||
) => HTMLTemplateResult | null;
|
||||
|
||||
export type CodeBlockPreviewContext = {
|
||||
renderer: CodeBlockPreviewRenderer;
|
||||
lang: string;
|
||||
};
|
||||
|
||||
export const CodeBlockPreviewIdentifier =
|
||||
createIdentifier<CodeBlockPreviewContext>('CodeBlockPreview');
|
||||
|
||||
export function CodeBlockPreviewExtension(
|
||||
lang: string,
|
||||
renderer: CodeBlockPreviewRenderer
|
||||
): ExtensionType {
|
||||
return {
|
||||
setup: di => {
|
||||
di.addImpl(CodeBlockPreviewIdentifier(lang), { renderer, lang });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { createLitPortal } from '@blocksuite/affine-components/portal';
|
||||
import type {
|
||||
EditorIconButton,
|
||||
MenuItemGroup,
|
||||
} from '@blocksuite/affine-components/toolbar';
|
||||
import { renderGroups } from '@blocksuite/affine-components/toolbar';
|
||||
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import { noop } from '@blocksuite/global/utils';
|
||||
import { MoreVerticalIcon } from '@blocksuite/icons/lit';
|
||||
import { flip, offset } from '@floating-ui/dom';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
|
||||
import type { CodeBlockToolbarContext } from '../context.js';
|
||||
|
||||
export class AffineCodeToolbar extends WithDisposable(LitElement) {
|
||||
static override styles = css`
|
||||
:host {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.code-toolbar-container {
|
||||
width: auto;
|
||||
height: 24px;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.code-toolbar-button {
|
||||
color: ${unsafeCSSVarV2('icon/primary')};
|
||||
background-color: ${unsafeCSSVarV2('button/secondary')};
|
||||
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`
|
||||
<affine-code-more-menu
|
||||
.context=${this.context}
|
||||
.moreGroups=${this.moreGroups}
|
||||
></affine-code-more-menu>
|
||||
`,
|
||||
// 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 connectedCallback() {
|
||||
super.connectedCallback();
|
||||
// Mirror the collapsed$ signal from the block component into local @state
|
||||
// so this LitElement re-renders when it changes.
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
this._collapsed = this.context.blockComponent.collapsed$.value;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@state()
|
||||
private accessor _collapsed = 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>[];
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import {
|
||||
type FilterableListItem,
|
||||
type FilterableListOptions,
|
||||
showPopFilterableList,
|
||||
} from '@blocksuite/affine-components/filterable-list';
|
||||
import { ArrowDownIcon } from '@blocksuite/affine-components/icons';
|
||||
import {
|
||||
DocModeProvider,
|
||||
TelemetryProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
import { noop } 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 '../..';
|
||||
|
||||
export class LanguageListButton extends WithDisposable(
|
||||
SignalWatcher(LitElement)
|
||||
) {
|
||||
static override styles = css`
|
||||
.lang-button {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 2px 4px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.lang-button:hover {
|
||||
background: ${unsafeCSSVarV2('layer/background/hoverOverlay')};
|
||||
}
|
||||
|
||||
.lang-button[hover] {
|
||||
background: ${unsafeCSSVarV2('layer/background/hoverOverlay')};
|
||||
}
|
||||
|
||||
.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.store.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.store.transact(() => {
|
||||
this.blockComponent.model.props.language$.value = item.name;
|
||||
});
|
||||
|
||||
const std = this.blockComponent.std;
|
||||
const mode =
|
||||
std.getOptional(DocModeProvider)?.getEditorMode() ?? 'page';
|
||||
const telemetryService = std.getOptional(TelemetryProvider);
|
||||
if (!telemetryService) return;
|
||||
telemetryService.track('codeBlockLanguageSelect', {
|
||||
page: mode,
|
||||
segment: 'code block',
|
||||
module: 'language selector',
|
||||
control: item.name,
|
||||
});
|
||||
},
|
||||
active: item => item.name === this.blockComponent.model.props.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.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.store.readonly}
|
||||
>
|
||||
<span class="lang-button-icon" slot="suffix">
|
||||
${!this.blockComponent.store.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;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { MenuItemGroup } from '@blocksuite/affine-components/toolbar';
|
||||
import { renderGroups } from '@blocksuite/affine-components/toolbar';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
import { ShadowlessElement } from '@blocksuite/std';
|
||||
import { html } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import type { CodeBlockToolbarContext } from '../context.js';
|
||||
|
||||
export class AffineCodeMoreMenu extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
override firstUpdated() {
|
||||
this.disposables.add(
|
||||
this.context.blockComponent.model.propsUpdated.subscribe(({ key }) => {
|
||||
if (key === 'wrap' || key === 'lineNumber') {
|
||||
this.requestUpdate();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override render() {
|
||||
return 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>
|
||||
`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor context!: CodeBlockToolbarContext;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor moreGroups!: MenuItemGroup<CodeBlockToolbarContext>[];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-code-more-menu': AffineCodeMoreMenu;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
DocModeProvider,
|
||||
TelemetryProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
import { css, html, LitElement, nothing } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
|
||||
import type { CodeBlockComponent } from '../../code-block';
|
||||
import { CodeBlockPreviewIdentifier } from '../../code-preview-extension';
|
||||
|
||||
export class PreviewButton extends WithDisposable(SignalWatcher(LitElement)) {
|
||||
static override styles = css`
|
||||
:host {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.preview-toggle-container {
|
||||
display: flex;
|
||||
padding: 2px;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
border-radius: 4px;
|
||||
background: ${unsafeCSSVarV2('segment/background')};
|
||||
}
|
||||
|
||||
.toggle-button {
|
||||
display: flex;
|
||||
padding: 0px 4px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-radius: 4px;
|
||||
color: ${unsafeCSSVarV2('text/primary')};
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.toggle-button:hover {
|
||||
background: ${unsafeCSSVarV2('layer/background/hoverOverlay')};
|
||||
}
|
||||
|
||||
.toggle-button.active {
|
||||
background: ${unsafeCSSVarV2('segment/button')};
|
||||
box-shadow:
|
||||
var(--Shadow-buttonShadow-1-x, 0px) var(--Shadow-buttonShadow-1-y, 0px)
|
||||
var(--Shadow-buttonShadow-1-blur, 1px) 0px
|
||||
var(--Shadow-buttonShadow-1-color, rgba(0, 0, 0, 0.12)),
|
||||
var(--Shadow-buttonShadow-2-x, 0px) var(--Shadow-buttonShadow-2-y, 1px)
|
||||
var(--Shadow-buttonShadow-2-blur, 5px) 0px
|
||||
var(--Shadow-buttonShadow-2-color, rgba(0, 0, 0, 0.12));
|
||||
}
|
||||
`;
|
||||
|
||||
private readonly _toggle = (value: boolean) => {
|
||||
this.blockComponent.setPreviewState(value);
|
||||
|
||||
const std = this.blockComponent.std;
|
||||
const mode = std.getOptional(DocModeProvider)?.getEditorMode() ?? 'page';
|
||||
const telemetryService = std.getOptional(TelemetryProvider);
|
||||
if (!telemetryService) return;
|
||||
telemetryService.track('htmlBlockTogglePreview', {
|
||||
page: mode,
|
||||
segment: 'code block',
|
||||
module: 'code toolbar container',
|
||||
control: 'preview toggle button',
|
||||
});
|
||||
};
|
||||
|
||||
get preview() {
|
||||
return this.blockComponent.preview$.value;
|
||||
}
|
||||
|
||||
override render() {
|
||||
const lang = this.blockComponent.model.props.language$.value ?? '';
|
||||
const previewContext = this.blockComponent.std.getOptional(
|
||||
CodeBlockPreviewIdentifier(lang)
|
||||
);
|
||||
if (!previewContext) return nothing;
|
||||
|
||||
return html`
|
||||
<div class="preview-toggle-container">
|
||||
<div
|
||||
class=${classMap({
|
||||
'toggle-button': true,
|
||||
active: !this.preview,
|
||||
})}
|
||||
@click=${() => this._toggle(false)}
|
||||
>
|
||||
Code
|
||||
</div>
|
||||
<div
|
||||
class=${classMap({
|
||||
'toggle-button': true,
|
||||
active: this.preview,
|
||||
})}
|
||||
@click=${() => this._toggle(true)}
|
||||
>
|
||||
Preview
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor blockComponent!: CodeBlockComponent;
|
||||
}
|
||||
319
blocksuite/affine/blocks/code/src/code-toolbar/config.ts
Normal file
319
blocksuite/affine/blocks/code/src/code-toolbar/config.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
import {
|
||||
CancelWrapIcon,
|
||||
CaptionIcon,
|
||||
CollapseCodeIcon,
|
||||
CopyIcon,
|
||||
DeleteIcon,
|
||||
DuplicateIcon,
|
||||
ExpandCodeIcon,
|
||||
WrapIcon,
|
||||
} from '@blocksuite/affine-components/icons';
|
||||
import type { MenuItemGroup } from '@blocksuite/affine-components/toolbar';
|
||||
import { CommentProviderIdentifier } from '@blocksuite/affine-shared/services';
|
||||
import { isInsidePageEditor } from '@blocksuite/affine-shared/utils';
|
||||
import { noop, sleep } from '@blocksuite/global/utils';
|
||||
import { CommentIcon, NumberedListIcon } from '@blocksuite/icons/lit';
|
||||
import { BlockSelection } from '@blocksuite/std';
|
||||
import { html } from 'lit';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
|
||||
import { CodeBlockConfigExtension } from '../code-block-config.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: 'preview',
|
||||
generate: ({ blockComponent }) => {
|
||||
return {
|
||||
action: noop,
|
||||
render: () => html`
|
||||
<preview-button .blockComponent=${blockComponent}>
|
||||
</preview-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: 'collapse',
|
||||
when: ({ doc }) => !doc.readonly,
|
||||
generate: ({ blockComponent }) => {
|
||||
return {
|
||||
action: () => {
|
||||
blockComponent.setCollapsed(!blockComponent.collapsed$.value);
|
||||
},
|
||||
render: item => {
|
||||
const collapsed = blockComponent.collapsed$.value;
|
||||
const icon = collapsed ? ExpandCodeIcon : CollapseCodeIcon;
|
||||
const label = collapsed ? 'Expand code' : 'Collapse code';
|
||||
return html`
|
||||
<editor-icon-button
|
||||
class="code-toolbar-button collapse"
|
||||
aria-label=${label}
|
||||
.tooltip=${label}
|
||||
.tooltipOffset=${4}
|
||||
.iconSize=${'16px'}
|
||||
.iconContainerPadding=${4}
|
||||
@click=${(e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
item.action();
|
||||
}}
|
||||
>
|
||||
${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>
|
||||
`,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'comment',
|
||||
label: 'Comment',
|
||||
tooltip: 'Comment',
|
||||
icon: CommentIcon({
|
||||
width: '20',
|
||||
height: '20',
|
||||
}),
|
||||
when: ({ std }) => !!std.getOptional(CommentProviderIdentifier),
|
||||
generate: ({ blockComponent }) => {
|
||||
return {
|
||||
action: () => {
|
||||
const commentProvider = blockComponent.std.getOptional(
|
||||
CommentProviderIdentifier
|
||||
);
|
||||
if (!commentProvider) return;
|
||||
|
||||
commentProvider.addComment([
|
||||
new BlockSelection({
|
||||
blockId: blockComponent.model.id,
|
||||
}),
|
||||
]);
|
||||
},
|
||||
render: item =>
|
||||
html`<editor-icon-button
|
||||
class="code-toolbar-button comment"
|
||||
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>`,
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const toggleGroup: MenuItemGroup<CodeBlockToolbarContext> = {
|
||||
type: 'toggle',
|
||||
items: [
|
||||
{
|
||||
type: 'wrap',
|
||||
generate: ({ blockComponent }) => {
|
||||
return {
|
||||
action: () => {},
|
||||
render: () => {
|
||||
const wrapped = blockComponent.model.props.wrap;
|
||||
const label = wrapped ? 'Cancel wrap' : 'Wrap';
|
||||
const icon = wrapped ? CancelWrapIcon : WrapIcon;
|
||||
return html`
|
||||
<editor-menu-action
|
||||
@click=${() => {
|
||||
const currentWrap = blockComponent.model.props.wrap;
|
||||
blockComponent.setWrap(!currentWrap);
|
||||
}}
|
||||
aria-label=${label}
|
||||
>
|
||||
${icon}
|
||||
<span class="label">${label}</span>
|
||||
<toggle-switch
|
||||
style="margin-left: auto;"
|
||||
.on="${wrapped}"
|
||||
></toggle-switch>
|
||||
</editor-menu-action>
|
||||
`;
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'line-number',
|
||||
when: ({ std }) =>
|
||||
std.getOptional(CodeBlockConfigExtension.identifier)?.showLineNumbers ??
|
||||
true,
|
||||
generate: ({ blockComponent }) => {
|
||||
return {
|
||||
action: () => {},
|
||||
render: () => {
|
||||
const lineNumber = blockComponent.showLineNumbers;
|
||||
const label = lineNumber ? 'Cancel line number' : 'Line number';
|
||||
return html`
|
||||
<editor-menu-action
|
||||
@click=${() => {
|
||||
blockComponent.store.updateBlock(blockComponent.model, {
|
||||
lineNumber: !blockComponent.showLineNumbers,
|
||||
});
|
||||
}}
|
||||
aria-label=${label}
|
||||
>
|
||||
${NumberedListIcon()}
|
||||
<span class="label">${label}</span>
|
||||
<toggle-switch
|
||||
style="margin-left: auto;"
|
||||
.on="${lineNumber}"
|
||||
></toggle-switch>
|
||||
</editor-menu-action>
|
||||
`;
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Clipboard Group
|
||||
export const clipboardGroup: MenuItemGroup<CodeBlockToolbarContext> = {
|
||||
type: 'clipboard',
|
||||
items: [
|
||||
{
|
||||
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(BlockSelection, {
|
||||
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>[] = [
|
||||
toggleGroup,
|
||||
clipboardGroup,
|
||||
deleteGroup,
|
||||
];
|
||||
46
blocksuite/affine/blocks/code/src/code-toolbar/context.ts
Normal file
46
blocksuite/affine/blocks/code/src/code-toolbar/context.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { MenuContext } from '@blocksuite/affine-components/toolbar';
|
||||
|
||||
import type { CodeBlockComponent } from '../code-block';
|
||||
|
||||
export class CodeBlockToolbarContext extends MenuContext {
|
||||
override close = () => {
|
||||
this.abortController.abort();
|
||||
};
|
||||
|
||||
get doc() {
|
||||
return this.blockComponent.store;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
173
blocksuite/affine/blocks/code/src/code-toolbar/index.ts
Normal file
173
blocksuite/affine/blocks/code/src/code-toolbar/index.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
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 {
|
||||
BlockSelection,
|
||||
TextSelection,
|
||||
WidgetComponent,
|
||||
} from '@blocksuite/std';
|
||||
import { limitShift, shift, size } from '@floating-ui/dom';
|
||||
import { html } from 'lit';
|
||||
|
||||
import type { CodeBlockComponent } from '../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;
|
||||
if (!codeBlock) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selection = this.host.selection;
|
||||
|
||||
const textSelection = selection.find(TextSelection);
|
||||
if (
|
||||
!!textSelection &&
|
||||
(!!textSelection.to || !!textSelection.from.length)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const blockSelections = selection.filter(BlockSelection);
|
||||
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: 'top',
|
||||
middleware: [
|
||||
size({
|
||||
apply({ rects, elements }) {
|
||||
elements.floating.style.width = `${rects.reference.width}px`;
|
||||
},
|
||||
}),
|
||||
shift({
|
||||
crossAxis: true,
|
||||
padding: {
|
||||
bottom: 12,
|
||||
right: 12,
|
||||
},
|
||||
limiter: limitShift(),
|
||||
}),
|
||||
],
|
||||
autoUpdate: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
{ allowMultiple: true }
|
||||
);
|
||||
|
||||
const codeBlock = this.block;
|
||||
if (!codeBlock) {
|
||||
return;
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
16
blocksuite/affine/blocks/code/src/code-toolbar/utils.ts
Normal file
16
blocksuite/affine/blocks/code/src/code-toolbar/utils.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { CodeBlockModel } from '@blocksuite/affine-model';
|
||||
|
||||
export const duplicateCodeBlock = (model: CodeBlockModel) => {
|
||||
const keys = model.keys as (keyof (typeof model)['props'])[];
|
||||
const values = keys.map(key => model.props[key]);
|
||||
const blockProps = Object.fromEntries(keys.map((key, i) => [key, values[i]]));
|
||||
const { text: _text, ...duplicateProps } = blockProps;
|
||||
|
||||
const newProps = {
|
||||
flavour: model.flavour,
|
||||
text: model.props.text.clone(),
|
||||
...duplicateProps,
|
||||
};
|
||||
|
||||
return model.store.addSiblingBlocks(model, [newProps])[0];
|
||||
};
|
||||
8
blocksuite/affine/blocks/code/src/configs/slash-menu.ts
Normal file
8
blocksuite/affine/blocks/code/src/configs/slash-menu.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { type SlashMenuConfig } from '@blocksuite/affine-widget-slash-menu';
|
||||
|
||||
export const codeSlashMenuConfig: SlashMenuConfig = {
|
||||
disableWhen: ({ model }) => {
|
||||
return model.flavour === 'affine:code';
|
||||
},
|
||||
items: [],
|
||||
};
|
||||
30
blocksuite/affine/blocks/code/src/effects.ts
Normal file
30
blocksuite/affine/blocks/code/src/effects.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { CodeBlockComponent } from './code-block';
|
||||
import {
|
||||
AFFINE_CODE_TOOLBAR_WIDGET,
|
||||
AffineCodeToolbarWidget,
|
||||
} from './code-toolbar';
|
||||
import { AffineCodeToolbar } from './code-toolbar/components/code-toolbar';
|
||||
import { LanguageListButton } from './code-toolbar/components/lang-button';
|
||||
import { AffineCodeMoreMenu } from './code-toolbar/components/more-menu';
|
||||
import { PreviewButton } from './code-toolbar/components/preview-button';
|
||||
import { AffineCodeUnit } from './highlight/affine-code-unit';
|
||||
|
||||
export function effects() {
|
||||
customElements.define('language-list-button', LanguageListButton);
|
||||
customElements.define('affine-code-toolbar', AffineCodeToolbar);
|
||||
customElements.define('affine-code-more-menu', AffineCodeMoreMenu);
|
||||
customElements.define(AFFINE_CODE_TOOLBAR_WIDGET, AffineCodeToolbarWidget);
|
||||
customElements.define('affine-code-unit', AffineCodeUnit);
|
||||
customElements.define('affine-code', CodeBlockComponent);
|
||||
customElements.define('preview-button', PreviewButton);
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'language-list-button': LanguageListButton;
|
||||
'affine-code-toolbar': AffineCodeToolbar;
|
||||
'affine-code-more-menu': AffineCodeMoreMenu;
|
||||
'preview-button': PreviewButton;
|
||||
[AFFINE_CODE_TOOLBAR_WIDGET]: AffineCodeToolbarWidget;
|
||||
}
|
||||
}
|
||||
124
blocksuite/affine/blocks/code/src/highlight/affine-code-unit.ts
Normal file
124
blocksuite/affine/blocks/code/src/highlight/affine-code-unit.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { affineTextStyles } from '@blocksuite/affine-shared/styles';
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import { ShadowlessElement } from '@blocksuite/std';
|
||||
import { ZERO_WIDTH_FOR_EMPTY_LINE } from '@blocksuite/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';
|
||||
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() {
|
||||
if (this.delta.attributes?.link && this.codeBlock) {
|
||||
return html`<affine-link
|
||||
.std=${this.codeBlock.std}
|
||||
.delta=${this.delta}
|
||||
></affine-link>`;
|
||||
}
|
||||
|
||||
let style = this.delta.attributes
|
||||
? affineTextStyles(this.delta.attributes)
|
||||
: {};
|
||||
if (this.delta.attributes?.code) {
|
||||
style = {
|
||||
...style,
|
||||
'font-size': 'calc(var(--affine-font-base) - 3px)',
|
||||
padding: '0px 4px 2px',
|
||||
};
|
||||
}
|
||||
|
||||
const plainContent = html`<span style=${styleMap(style)}
|
||||
><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;
|
||||
const line = tokens[vElement.lineIndex];
|
||||
if (!line) return plainContent;
|
||||
// copy the tokens to avoid modifying the original tokens
|
||||
const lineTokens = structuredClone(line);
|
||||
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`<span
|
||||
style=${styleMap({
|
||||
color: token.color,
|
||||
...style,
|
||||
})}
|
||||
><v-text .str=${content}></v-text
|
||||
></span>`;
|
||||
} 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,
|
||||
...style,
|
||||
})}
|
||||
></v-text>`;
|
||||
});
|
||||
|
||||
return html`<span>${vTexts}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
@property({ type: Object })
|
||||
accessor delta: DeltaInsert<AffineTextAttributes> = {
|
||||
insert: ZERO_WIDTH_FOR_EMPTY_LINE,
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-code-unit': AffineCodeUnit;
|
||||
}
|
||||
}
|
||||
4
blocksuite/affine/blocks/code/src/highlight/const.ts
Normal file
4
blocksuite/affine/blocks/code/src/highlight/const.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
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');
|
||||
10
blocksuite/affine/blocks/code/src/index.ts
Normal file
10
blocksuite/affine/blocks/code/src/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export * from './adapters';
|
||||
export * from './clipboard';
|
||||
export * from './code-block';
|
||||
export * from './code-block-config';
|
||||
export * from './code-block-service';
|
||||
export * from './code-preview-extension';
|
||||
export * from './code-toolbar';
|
||||
export * from './highlight/const';
|
||||
export * from './turbo/code-layout-handler';
|
||||
export * from './turbo/code-painter.worker';
|
||||
61
blocksuite/affine/blocks/code/src/markdown.ts
Normal file
61
blocksuite/affine/blocks/code/src/markdown.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
type CodeBlockModel,
|
||||
CodeBlockSchema,
|
||||
ParagraphBlockModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { focusTextModel } from '@blocksuite/affine-rich-text';
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import { matchModels } from '@blocksuite/affine-shared/utils';
|
||||
import type { BlockComponent } from '@blocksuite/std';
|
||||
import { InlineMarkdownExtension } from '@blocksuite/std/inline';
|
||||
|
||||
export const CodeBlockMarkdownExtension =
|
||||
InlineMarkdownExtension<AffineTextAttributes>({
|
||||
name: 'code-block',
|
||||
pattern: /^```([a-zA-Z0-9]*)\s$/,
|
||||
action: ({ inlineEditor, inlineRange, prefixText, pattern }) => {
|
||||
if (inlineEditor.yTextString.slice(0, inlineRange.index).includes('\n')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const match = prefixText.match(pattern);
|
||||
if (!match) return;
|
||||
|
||||
const language = match[1];
|
||||
|
||||
if (!inlineEditor.rootElement) return;
|
||||
const blockComponent =
|
||||
inlineEditor.rootElement.closest<BlockComponent>('[data-block-id]');
|
||||
if (!blockComponent) return;
|
||||
|
||||
const { model, std, store } = blockComponent;
|
||||
|
||||
if (
|
||||
matchModels(model, [ParagraphBlockModel]) &&
|
||||
model.props.type === 'quote'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = store.getParent(model);
|
||||
if (!parent) return;
|
||||
const index = parent.children.indexOf(model);
|
||||
|
||||
store.captureSync();
|
||||
const codeId = store.addBlock<CodeBlockModel>(
|
||||
CodeBlockSchema.model.flavour,
|
||||
{ language },
|
||||
parent,
|
||||
index
|
||||
);
|
||||
|
||||
if (model.text && model.text.length > prefixText.length) {
|
||||
const text = model.text.clone();
|
||||
store.addBlock('affine:paragraph', { text }, parent, index + 1);
|
||||
text.delete(0, prefixText.length);
|
||||
}
|
||||
store.deleteBlock(model, { bringChildrenTo: parent });
|
||||
|
||||
focusTextModel(std, codeId);
|
||||
},
|
||||
});
|
||||
19
blocksuite/affine/blocks/code/src/store.ts
Normal file
19
blocksuite/affine/blocks/code/src/store.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import {
|
||||
type StoreExtensionContext,
|
||||
StoreExtensionProvider,
|
||||
} from '@blocksuite/affine-ext-loader';
|
||||
import { CodeBlockSchemaExtension } from '@blocksuite/affine-model';
|
||||
|
||||
import { CodeBlockAdapterExtensions } from './adapters/extension';
|
||||
import { CodeMarkdownPreprocessorExtension } from './adapters/markdown/preprocessor';
|
||||
|
||||
export class CodeStoreExtension extends StoreExtensionProvider {
|
||||
override name = 'affine-code-block';
|
||||
|
||||
override setup(context: StoreExtensionContext) {
|
||||
super.setup(context);
|
||||
context.register(CodeBlockSchemaExtension);
|
||||
context.register(CodeBlockAdapterExtensions);
|
||||
context.register(CodeMarkdownPreprocessorExtension);
|
||||
}
|
||||
}
|
||||
114
blocksuite/affine/blocks/code/src/styles.ts
Normal file
114
blocksuite/affine/blocks/code/src/styles.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { scrollbarStyle } from '@blocksuite/affine-shared/styles';
|
||||
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
|
||||
import { css } from 'lit';
|
||||
|
||||
export const codeBlockStyles = css`
|
||||
affine-code {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.affine-code-block-container {
|
||||
font-size: var(--affine-font-xs);
|
||||
line-height: var(--affine-line-height);
|
||||
position: relative;
|
||||
padding: 32px 20px;
|
||||
background: var(--affine-background-code-block);
|
||||
border-radius: 10px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.affine-code-block-container.mobile {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.affine-code-block-container.highlight-comment {
|
||||
outline: 2px solid ${unsafeCSSVarV2('block/comment/highlightUnderline')};
|
||||
}
|
||||
|
||||
${scrollbarStyle('.affine-code-block-container rich-text')}
|
||||
|
||||
/* In Chromium 121+, non-auto scrollbar-width/color override ::-webkit-scrollbar styles. */
|
||||
@supports not selector(::-webkit-scrollbar) {
|
||||
.affine-code-block-container rich-text {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: ${unsafeCSSVarV2('icon/secondary', '#b1b1b1')}
|
||||
transparent;
|
||||
scrollbar-gutter: stable both-edges;
|
||||
}
|
||||
}
|
||||
|
||||
.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.disable-line-numbers v-line {
|
||||
grid-template-columns: unset;
|
||||
}
|
||||
|
||||
.affine-code-block-container div:has(> v-line) {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.affine-code-block-container .line-number {
|
||||
position: sticky;
|
||||
text-align: left;
|
||||
padding-right: 12px;
|
||||
width: 32px;
|
||||
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;
|
||||
}
|
||||
|
||||
.affine-code-block-container.disable-line-numbers .line-number {
|
||||
display: none;
|
||||
}
|
||||
|
||||
affine-code .affine-code-block-preview {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* ── Collapsed state ──────────────────────────────────────────────── */
|
||||
|
||||
/* Clamp the rich-text to the first 8 lines */
|
||||
.affine-code-block-container.collapsed rich-text {
|
||||
display: block;
|
||||
max-height: calc(8 * var(--affine-line-height));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Reduce bottom padding so the fade sits flush with the border */
|
||||
.affine-code-block-container.collapsed {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
/* Gradient overlay that fades to the block background */
|
||||
.affine-code-block-container .code-collapsed-fade {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 80px;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
var(--affine-background-code-block)
|
||||
);
|
||||
border-radius: 0 0 10px 10px;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { Rect } from '@blocksuite/affine-gfx-turbo-renderer';
|
||||
import {
|
||||
BlockLayoutHandlerExtension,
|
||||
BlockLayoutHandlersIdentifier,
|
||||
} from '@blocksuite/affine-gfx-turbo-renderer';
|
||||
import type { Container } from '@blocksuite/global/di';
|
||||
import type { EditorHost, GfxBlockComponent } from '@blocksuite/std';
|
||||
import { clientToModelCoord, type ViewportRecord } from '@blocksuite/std/gfx';
|
||||
import type { BlockModel } from '@blocksuite/store';
|
||||
|
||||
import type { CodeLayout } from './code-painter.worker';
|
||||
|
||||
export class CodeLayoutHandlerExtension extends BlockLayoutHandlerExtension<CodeLayout> {
|
||||
readonly blockType = 'affine:code';
|
||||
|
||||
static override setup(di: Container) {
|
||||
di.addImpl(
|
||||
BlockLayoutHandlersIdentifier('code'),
|
||||
CodeLayoutHandlerExtension
|
||||
);
|
||||
}
|
||||
|
||||
override queryLayout(
|
||||
model: BlockModel,
|
||||
host: EditorHost,
|
||||
viewportRecord: ViewportRecord
|
||||
): CodeLayout | null {
|
||||
const component = host.std.view.getBlock(
|
||||
model.id
|
||||
) as GfxBlockComponent | null;
|
||||
if (!component) return null;
|
||||
|
||||
const codeBlockElement = component.querySelector(
|
||||
'.affine-code-block-container'
|
||||
);
|
||||
if (!codeBlockElement) return null;
|
||||
|
||||
const { zoom, viewScale } = viewportRecord;
|
||||
const codeLayout: CodeLayout = {
|
||||
type: 'affine:code',
|
||||
blockId: model.id,
|
||||
rect: { x: 0, y: 0, w: 0, h: 0 },
|
||||
};
|
||||
|
||||
// Get the bounding rect of the code block
|
||||
const clientRect = codeBlockElement.getBoundingClientRect();
|
||||
if (!clientRect) return null;
|
||||
|
||||
// Convert client coordinates to model coordinates
|
||||
const [modelX, modelY] = clientToModelCoord(viewportRecord, [
|
||||
clientRect.x,
|
||||
clientRect.y,
|
||||
]);
|
||||
|
||||
codeLayout.rect = {
|
||||
x: modelX,
|
||||
y: modelY,
|
||||
w: clientRect.width / zoom / viewScale,
|
||||
h: clientRect.height / zoom / viewScale,
|
||||
};
|
||||
|
||||
return codeLayout;
|
||||
}
|
||||
|
||||
calculateBound(layout: CodeLayout) {
|
||||
const rect: Rect = layout.rect;
|
||||
|
||||
return {
|
||||
rect,
|
||||
subRects: [rect],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type {
|
||||
BlockLayout,
|
||||
BlockLayoutPainter,
|
||||
WorkerToHostMessage,
|
||||
} from '@blocksuite/affine-gfx-turbo-renderer';
|
||||
import { BlockLayoutPainterExtension } from '@blocksuite/affine-gfx-turbo-renderer/painter';
|
||||
|
||||
export interface CodeLayout extends BlockLayout {
|
||||
type: 'affine:code';
|
||||
}
|
||||
|
||||
function isCodeLayout(layout: BlockLayout): layout is CodeLayout {
|
||||
return layout.type === 'affine:code';
|
||||
}
|
||||
|
||||
class CodeLayoutPainter implements BlockLayoutPainter {
|
||||
paint(
|
||||
ctx: OffscreenCanvasRenderingContext2D,
|
||||
layout: BlockLayout,
|
||||
layoutBaseX: number,
|
||||
layoutBaseY: number
|
||||
): void {
|
||||
if (!isCodeLayout(layout)) {
|
||||
const message: WorkerToHostMessage = {
|
||||
type: 'paintError',
|
||||
error: 'Invalid layout format',
|
||||
blockType: 'affine:code',
|
||||
};
|
||||
self.postMessage(message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the layout dimensions
|
||||
const x = layout.rect.x - layoutBaseX;
|
||||
const y = layout.rect.y - layoutBaseY;
|
||||
const width = layout.rect.w;
|
||||
const height = layout.rect.h;
|
||||
|
||||
// Simple white rectangle for now
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.fillRect(x, y, width, height);
|
||||
|
||||
// Add a border to visualize the code block
|
||||
ctx.strokeStyle = 'rgba(0, 0, 0, 0.1)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(x, y, width, height);
|
||||
}
|
||||
}
|
||||
|
||||
export const CodeLayoutPainterExtension = BlockLayoutPainterExtension(
|
||||
'affine:code',
|
||||
CodeLayoutPainter
|
||||
);
|
||||
65
blocksuite/affine/blocks/code/src/view.ts
Normal file
65
blocksuite/affine/blocks/code/src/view.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
type ViewExtensionContext,
|
||||
ViewExtensionProvider,
|
||||
} from '@blocksuite/affine-ext-loader';
|
||||
import { SlashMenuConfigExtension } from '@blocksuite/affine-widget-slash-menu';
|
||||
import {
|
||||
BlockViewExtension,
|
||||
FlavourExtension,
|
||||
WidgetViewExtension,
|
||||
} from '@blocksuite/std';
|
||||
import { literal, unsafeStatic } from 'lit/static-html.js';
|
||||
|
||||
import { getCodeClipboardExtensions } from './clipboard/index.js';
|
||||
import { CodeBlockConfigExtension } from './code-block-config';
|
||||
import {
|
||||
CodeBlockInlineManagerExtension,
|
||||
CodeBlockUnitSpecExtension,
|
||||
} from './code-block-inline.js';
|
||||
import { CodeBlockHighlighter } from './code-block-service.js';
|
||||
import { CodeKeymapExtension } from './code-keymap.js';
|
||||
import { AFFINE_CODE_TOOLBAR_WIDGET } from './code-toolbar/index.js';
|
||||
import { codeSlashMenuConfig } from './configs/slash-menu.js';
|
||||
import { effects } from './effects.js';
|
||||
import { CodeBlockMarkdownExtension } from './markdown.js';
|
||||
|
||||
const codeToolbarWidget = WidgetViewExtension(
|
||||
'affine:code',
|
||||
AFFINE_CODE_TOOLBAR_WIDGET,
|
||||
literal`${unsafeStatic(AFFINE_CODE_TOOLBAR_WIDGET)}`
|
||||
);
|
||||
|
||||
export class CodeBlockViewExtension extends ViewExtensionProvider {
|
||||
override name = 'affine-code-block';
|
||||
|
||||
override effect() {
|
||||
super.effect();
|
||||
effects();
|
||||
}
|
||||
|
||||
override setup(context: ViewExtensionContext) {
|
||||
super.setup(context);
|
||||
context.register([
|
||||
FlavourExtension('affine:code'),
|
||||
CodeBlockHighlighter,
|
||||
BlockViewExtension('affine:code', literal`affine-code`),
|
||||
SlashMenuConfigExtension('affine:code', codeSlashMenuConfig),
|
||||
CodeKeymapExtension,
|
||||
CodeBlockMarkdownExtension,
|
||||
...getCodeClipboardExtensions(),
|
||||
]);
|
||||
context.register([
|
||||
CodeBlockInlineManagerExtension,
|
||||
CodeBlockUnitSpecExtension,
|
||||
]);
|
||||
if (!this.isMobile(context.scope)) {
|
||||
context.register(codeToolbarWidget);
|
||||
} else {
|
||||
context.register(
|
||||
CodeBlockConfigExtension({
|
||||
showLineNumbers: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
25
blocksuite/affine/blocks/code/tsconfig.json
Normal file
25
blocksuite/affine/blocks/code/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"include": ["./src"],
|
||||
"references": [
|
||||
{ "path": "../../components" },
|
||||
{ "path": "../../ext-loader" },
|
||||
{ "path": "../../gfx/turbo-renderer" },
|
||||
{ "path": "../../inlines/comment" },
|
||||
{ "path": "../../inlines/latex" },
|
||||
{ "path": "../../inlines/link" },
|
||||
{ "path": "../../inlines/preset" },
|
||||
{ "path": "../../model" },
|
||||
{ "path": "../../rich-text" },
|
||||
{ "path": "../../shared" },
|
||||
{ "path": "../../widgets/slash-menu" },
|
||||
{ "path": "../../../framework/global" },
|
||||
{ "path": "../../../framework/std" },
|
||||
{ "path": "../../../framework/store" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user