snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (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 / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
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 / 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 / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (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 3s
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 / 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 / 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 / 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 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 (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (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 (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
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 / 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 (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
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-16 04:01:20 +05:00
commit 702cc698a7
9990 changed files with 1073618 additions and 0 deletions

View File

@@ -0,0 +1,45 @@
{
"name": "@blocksuite/affine-inline-preset",
"description": "Inline preset 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-inline-comment": "workspace:*",
"@blocksuite/affine-inline-footnote": "workspace:*",
"@blocksuite/affine-inline-latex": "workspace:*",
"@blocksuite/affine-inline-link": "workspace:*",
"@blocksuite/affine-inline-mention": "workspace:*",
"@blocksuite/affine-inline-reference": "workspace:*",
"@blocksuite/affine-model": "workspace:*",
"@blocksuite/affine-rich-text": "workspace:*",
"@blocksuite/affine-shared": "workspace:*",
"@blocksuite/std": "workspace:*",
"@blocksuite/store": "workspace:*",
"@ctrl/tinycolor": "^4.1.0",
"@toeverything/theme": "^1.1.23",
"@types/hast": "^3.0.4",
"collapse-white-space": "^2.1.0",
"lit": "^3.2.0",
"zod": "^3.25.76"
},
"exports": {
".": "./src/index.ts",
"./store": "./src/store.ts",
"./view": "./src/view.ts"
},
"files": [
"src",
"dist",
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.27.5"
}

View File

@@ -0,0 +1,21 @@
import type { ExtensionType } from '@blocksuite/store';
import { HtmlInlineToDeltaAdapterExtensions } from './html/html-inline';
import { InlineDeltaToHtmlAdapterExtensions } from './html/inline-delta';
import { InlineDeltaToMarkdownAdapterExtensions } from './markdown/inline-delta';
import { MarkdownInlineToDeltaAdapterExtensions } from './markdown/markdown-inline';
import { NotionHtmlInlineToDeltaAdapterExtensions } from './notion-html/html-inline';
export const InlineAdapterExtensions: ExtensionType[] = [
HtmlInlineToDeltaAdapterExtensions,
InlineDeltaToHtmlAdapterExtensions,
NotionHtmlInlineToDeltaAdapterExtensions,
InlineDeltaToMarkdownAdapterExtensions,
MarkdownInlineToDeltaAdapterExtensions,
].flat();
export * from './html/html-inline';
export * from './html/inline-delta';
export * from './markdown/inline-delta';
export * from './markdown/markdown-inline';
export * from './notion-html/html-inline';

View File

@@ -0,0 +1,146 @@
import { TinyColor } from '@ctrl/tinycolor';
import { cssVarV2, darkThemeV2, lightThemeV2 } from '@toeverything/theme/v2';
type Rgb = { r: number; g: number; b: number };
type Oklab = { l: number; a: number; b: number; chroma: number; hue: number };
const ACHROMATIC_CHROMA_THRESHOLD = 0.02;
const DEFAULT_TEXT_LIGHTNESS_MIN = 0.4;
const DEFAULT_TEXT_LIGHTNESS_MAX = 0.9;
const MAX_COLOR_DISTANCE = 0.18;
const MAX_CHROMA_DISTANCE = 0.12;
const MAX_HUE_DISTANCE = 45;
const supportedTextColorNames = [
'red',
'orange',
'yellow',
'green',
'teal',
'blue',
'purple',
'grey',
] as const;
export const parseCssColor = (value: string) => {
const parsed = new TinyColor(value);
if (!parsed.isValid) return null;
const { r, g, b, a } = parsed.toRgb();
return { r, g, b, alpha: a };
};
const srgbToLinear = (channel: number) => {
const value = channel / 255;
return value <= 0.04045
? value / 12.92
: Math.pow((value + 0.055) / 1.055, 2.4);
};
const rgbToOklab = ({ r, g, b }: Rgb): Oklab => {
const red = srgbToLinear(r);
const green = srgbToLinear(g);
const blue = srgbToLinear(b);
const l = Math.cbrt(
0.4122214708 * red + 0.5363325363 * green + 0.0514459929 * blue
);
const m = Math.cbrt(
0.2119034982 * red + 0.6806995451 * green + 0.1073969566 * blue
);
const s = Math.cbrt(
0.0883024619 * red + 0.2817188376 * green + 0.6299787005 * blue
);
const result = {
l: 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
a: 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
b: 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
};
return {
...result,
chroma: Math.hypot(result.a, result.b),
hue: (Math.atan2(result.b, result.a) * 180) / Math.PI,
};
};
const supportedTextColors = supportedTextColorNames.map(name => ({
name,
cssVar: cssVarV2(`text/highlight/fg/${name}`),
references: [
lightThemeV2[`text/highlight/fg/${name}`],
darkThemeV2[`text/highlight/fg/${name}`],
].flatMap(color => {
const parsed = parseCssColor(color);
return parsed ? [rgbToOklab(parsed)] : [];
}),
}));
const colorDistance = (a: Oklab, b: Oklab) =>
Math.hypot(a.l - b.l, a.a - b.a, a.b - b.b);
const hueDistance = (a: number, b: number) => {
const distance = Math.abs(a - b) % 360;
return Math.min(distance, 360 - distance);
};
export const resolveNearestSupportedColor = (color: string): string | null => {
const parsed = parseCssColor(color);
if (!parsed || parsed.alpha < 1) {
return null;
}
const target = rgbToOklab(parsed);
const achromatic = target.chroma < ACHROMATIC_CHROMA_THRESHOLD;
if (
achromatic &&
(target.l < DEFAULT_TEXT_LIGHTNESS_MIN ||
target.l > DEFAULT_TEXT_LIGHTNESS_MAX)
) {
return null;
}
let nearest:
| {
cssVar: string;
distance: number;
}
| undefined;
for (const supported of supportedTextColors) {
if (achromatic !== (supported.name === 'grey')) {
continue;
}
for (const reference of supported.references) {
const distance = colorDistance(target, reference);
if (
distance > MAX_COLOR_DISTANCE ||
(!achromatic &&
(Math.abs(target.chroma - reference.chroma) > MAX_CHROMA_DISTANCE ||
hueDistance(target.hue, reference.hue) > MAX_HUE_DISTANCE))
) {
continue;
}
if (!nearest || distance < nearest.distance) {
nearest = { cssVar: supported.cssVar, distance };
}
}
}
return nearest?.cssVar ?? null;
};
export const extractColorFromStyle = (
style: string | undefined
): string | null => {
if (typeof style !== 'string') {
return null;
}
const declarations = style.split(';');
for (const declaration of declarations) {
const colon = declaration.indexOf(':');
if (colon === -1) continue;
const key = declaration.slice(0, colon).trim().toLowerCase();
if (key === 'color') {
return declaration.slice(colon + 1).trim();
}
}
return null;
};

View File

@@ -0,0 +1,363 @@
import {
type HtmlAST,
HtmlASTToDeltaExtension,
} from '@blocksuite/affine-shared/adapters';
import { collapseWhiteSpace } from 'collapse-white-space';
import type { Element } from 'hast';
import {
extractColorFromStyle,
resolveNearestSupportedColor,
} from './color-utils.js';
/**
* Handle empty text nodes created by HTML parser for styling purposes.
* These nodes typically contain only whitespace/newlines, for example:
* ```json
* {
* "type": "text",
* "value": "\n\n \n \n "
* }
* ```
* We collapse and trim the whitespace to check if the node is truly empty,
* and return an empty array in that case.
*/
const isEmptyText = (ast: HtmlAST): boolean => {
return (
ast.type === 'text' && collapseWhiteSpace(ast.value, { trim: true }) === ''
);
};
const isElement = (ast: HtmlAST): ast is Element => {
return ast.type === 'element';
};
const textLikeElementTags = new Set(['span', 'bdi', 'bdo', 'ins']);
const listElementTags = new Set(['ol', 'ul']);
const strongElementTags = new Set(['strong', 'b']);
const italicElementTags = new Set(['i', 'em']);
/**
* Check if the element is a strong element through style or tag
* If the element tag is <strong>, <b> or the style is `font-weight: bold;`, or the font-weight is 500 or above,
* we consider it as a strong element
* @param ast - The HTML AST node to check
* @returns `true` if the element is a strong element, `false` otherwise
* @example
* ```html
* <strong>Hello</strong>
* <b>Hello</b>
* <span style="font-weight: bold;">Hello</span>
* <span style="font-weight: 700;">Hello</span>
* ```
*/
const isStrongElement = (ast: HtmlAST) => {
if (!isElement(ast)) {
return false;
}
const style =
typeof ast.properties.style === 'string' ? ast.properties.style : '';
const isStrongTag = strongElementTags.has(ast.tagName);
// Should exclude the case like <b style="font-weight: normal;">
const isNotNormalFontWeight = !/font-weight:\s*normal/.test(style);
const isBoldFontWeight = /font-weight:\s*(([5-9]\d{2})|bold)/.test(style);
return (isStrongTag && isNotNormalFontWeight) || isBoldFontWeight;
};
/**
* Check if the element is an italic element through style or tag
* If the element tag is <i>, <em> or the style is `font-style: italic;`,
* we consider it as an italic element
* @param ast - The HTML AST node to check
* @returns `true` if the element is an italic element, `false` otherwise
* @example
* ```html
* <i>Hello</i>
* <em>Hello</em>
* <span style="font-style: italic;">Hello</span>
* ```
*/
const isItalicElement = (ast: HtmlAST) => {
if (!isElement(ast)) {
return false;
}
const style =
typeof ast.properties.style === 'string' ? ast.properties.style : '';
const isItalicTag = italicElementTags.has(ast.tagName);
const isItalicStyle = /font-style:\s*italic/.test(style);
return isItalicTag || isItalicStyle;
};
/**
* Check if the element is an underline element through style or tag
* If the element tag is <u> or the style is `text-decoration: underline;`,
* we consider it as an underline element
* @param ast - The HTML AST node to check
* @returns `true` if the element is an underline element, `false` otherwise
* @example
* ```html
* <u>Hello</u>
* <span style="text-decoration: underline;">Hello</span>
* ```
*/
const isUnderlineElement = (ast: HtmlAST) => {
if (!isElement(ast)) {
return false;
}
const style =
typeof ast.properties.style === 'string' ? ast.properties.style : '';
const isUnderlineTag = ast.tagName === 'u';
const isUnderlineStyle = /text-decoration:\s*underline/.test(style);
return isUnderlineTag || isUnderlineStyle;
};
/**
* Check if the element is a line-through element through style or tag
* If the element tag is <del> or the style is `text-decoration: line-through;`,
* we consider it as a line-through element
* @param ast - The HTML AST node to check
* @returns `true` if the element is a line-through element, `false` otherwise
* @example
* ```html
* <del>Hello</del>
* <span style="text-decoration: line-through;">Hello</span>
* ```
*/
const isLineThroughElement = (ast: HtmlAST) => {
if (!isElement(ast)) {
return false;
}
const style =
typeof ast.properties.style === 'string' ? ast.properties.style : '';
const isLineThroughTag = ast.tagName === 'del';
const isLineThroughStyle = /text-decoration:\s*line-through/.test(style);
return isLineThroughTag || isLineThroughStyle;
};
/**
* Handle the case like <span>Hello</span>
* @param ast
* @returns
*/
const isTextLikeElement = (ast: HtmlAST) => {
if (!isElement(ast)) {
return false;
}
return (
textLikeElementTags.has(ast.tagName) &&
!isStrongElement(ast) &&
!isItalicElement(ast) &&
!isUnderlineElement(ast) &&
!isLineThroughElement(ast)
);
};
export const htmlTextToDeltaMatcher = HtmlASTToDeltaExtension({
name: 'text',
match: ast => ast.type === 'text',
toDelta: (ast, context) => {
if (!('value' in ast)) {
return [];
}
const { options } = context;
options.trim ??= false;
if (options.pre) {
return [{ insert: ast.value }];
}
if (isEmptyText(ast)) {
return [];
}
const value = options.trim
? collapseWhiteSpace(ast.value, { trim: options.trim })
: collapseWhiteSpace(ast.value);
return value ? [{ insert: value }] : [];
},
});
export const htmlColorStyleElementToDeltaMatcher = HtmlASTToDeltaExtension({
name: 'color-style-element',
match: ast =>
isElement(ast) &&
ast.tagName === 'span' &&
typeof ast.properties?.style === 'string' &&
/color\s*:/i.test(ast.properties.style),
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
}
const baseOptions = { ...context.options, trim: false };
// In preformatted contexts (e.g. code blocks) we don't keep inline colors.
if (baseOptions.pre) {
return ast.children.flatMap(child => context.toDelta(child, baseOptions));
}
const colorValue = extractColorFromStyle(
typeof ast.properties?.style === 'string' ? ast.properties.style : ''
);
const mappedColor = colorValue
? resolveNearestSupportedColor(colorValue)
: null;
const deltas = ast.children.flatMap(child =>
context.toDelta(child, baseOptions).map(delta => {
if (mappedColor) {
delta.attributes = { ...delta.attributes, color: mappedColor };
}
return delta;
})
);
return deltas;
},
});
export const htmlTextLikeElementToDeltaMatcher = HtmlASTToDeltaExtension({
name: 'text-like-element',
match: ast => isTextLikeElement(ast),
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
}
return ast.children.flatMap(child =>
context.toDelta(child, { trim: false })
);
},
});
export const htmlListToDeltaMatcher = HtmlASTToDeltaExtension({
name: 'list-element',
match: ast => isElement(ast) && listElementTags.has(ast.tagName),
toDelta: () => {
return [];
},
});
export const htmlStrongElementToDeltaMatcher = HtmlASTToDeltaExtension({
name: 'strong-element',
match: ast => isStrongElement(ast),
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
}
return ast.children.flatMap(child =>
context.toDelta(child, { trim: false }).map(delta => {
delta.attributes = { ...delta.attributes, bold: true };
return delta;
})
);
},
});
export const htmlItalicElementToDeltaMatcher = HtmlASTToDeltaExtension({
name: 'italic-element',
match: ast => isItalicElement(ast),
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
}
return ast.children.flatMap(child =>
context.toDelta(child, { trim: false }).map(delta => {
delta.attributes = { ...delta.attributes, italic: true };
return delta;
})
);
},
});
export const htmlCodeElementToDeltaMatcher = HtmlASTToDeltaExtension({
name: 'code-element',
match: ast => isElement(ast) && ast.tagName === 'code',
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
}
return ast.children.flatMap(child =>
context.toDelta(child, { trim: false }).map(delta => {
delta.attributes = { ...delta.attributes, code: true };
return delta;
})
);
},
});
export const htmlDelElementToDeltaMatcher = HtmlASTToDeltaExtension({
name: 'del-element',
match: ast => isLineThroughElement(ast),
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
}
return ast.children.flatMap(child =>
context.toDelta(child, { trim: false }).map(delta => {
delta.attributes = { ...delta.attributes, strike: true };
return delta;
})
);
},
});
export const htmlUnderlineElementToDeltaMatcher = HtmlASTToDeltaExtension({
name: 'underline-element',
match: ast => isUnderlineElement(ast),
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
}
return ast.children.flatMap(child =>
context.toDelta(child, { trim: false }).map(delta => {
delta.attributes = { ...delta.attributes, underline: true };
return delta;
})
);
},
});
export const htmlMarkElementToDeltaMatcher = HtmlASTToDeltaExtension({
name: 'mark-element',
match: ast => isElement(ast) && ast.tagName === 'mark',
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
}
const dataColor =
typeof ast.properties?.dataColor === 'string'
? ast.properties.dataColor
: '';
const colorName =
dataColor &&
/^(red|orange|yellow|green|teal|blue|purple|grey)$/.test(dataColor)
? dataColor
: 'yellow';
return ast.children.flatMap(child =>
context.toDelta(child, { trim: false }).map(delta => {
delta.attributes = {
...delta.attributes,
background: `var(--affine-text-highlight-${colorName})`,
};
return delta;
})
);
},
});
export const htmlBrElementToDeltaMatcher = HtmlASTToDeltaExtension({
name: 'br-element',
match: ast => isElement(ast) && ast.tagName === 'br',
toDelta: () => {
return [{ insert: '\n' }];
},
});
export const HtmlInlineToDeltaAdapterExtensions = [
htmlTextToDeltaMatcher,
htmlColorStyleElementToDeltaMatcher,
htmlTextLikeElementToDeltaMatcher,
htmlStrongElementToDeltaMatcher,
htmlItalicElementToDeltaMatcher,
htmlCodeElementToDeltaMatcher,
htmlDelElementToDeltaMatcher,
htmlUnderlineElementToDeltaMatcher,
htmlMarkElementToDeltaMatcher,
htmlBrElementToDeltaMatcher,
];

View File

@@ -0,0 +1,154 @@
import type { InlineHtmlAST } from '@blocksuite/affine-shared/adapters';
import { InlineDeltaToHtmlAdapterExtension } from '@blocksuite/affine-shared/adapters';
import { ThemeProvider } from '@blocksuite/affine-shared/services';
export const boldDeltaToHtmlAdapterMatcher = InlineDeltaToHtmlAdapterExtension({
name: 'bold',
match: delta => !!delta.attributes?.bold,
toAST: (_, context) => {
return {
type: 'element',
tagName: 'strong',
properties: {},
children: [context.current],
};
},
});
export const italicDeltaToHtmlAdapterMatcher =
InlineDeltaToHtmlAdapterExtension({
name: 'italic',
match: delta => !!delta.attributes?.italic,
toAST: (_, context) => {
return {
type: 'element',
tagName: 'em',
properties: {},
children: [context.current],
};
},
});
export const strikeDeltaToHtmlAdapterMatcher =
InlineDeltaToHtmlAdapterExtension({
name: 'strike',
match: delta => !!delta.attributes?.strike,
toAST: (_, context) => {
return {
type: 'element',
tagName: 'del',
properties: {},
children: [context.current],
};
},
});
export const inlineCodeDeltaToHtmlAdapterMatcher =
InlineDeltaToHtmlAdapterExtension({
name: 'inlineCode',
match: delta => !!delta.attributes?.code,
toAST: (_, context) => {
return {
type: 'element',
tagName: 'code',
properties: {},
children: [context.current],
};
},
});
export const underlineDeltaToHtmlAdapterMatcher =
InlineDeltaToHtmlAdapterExtension({
name: 'underline',
match: delta => !!delta.attributes?.underline,
toAST: (_, context) => {
return {
type: 'element',
tagName: 'u',
properties: {},
children: [context.current],
};
},
});
export const highlightBackgroundDeltaToHtmlAdapterMatcher =
InlineDeltaToHtmlAdapterExtension({
name: 'highlight-background',
match: delta => !!delta.attributes?.background,
toAST: (delta, context, provider) => {
const hast: InlineHtmlAST = {
type: 'element',
tagName: 'span',
properties: {},
children: [context.current],
};
if (!provider || !delta.attributes?.background) {
return hast;
}
const theme = provider.getOptional(ThemeProvider);
if (!theme) {
return hast;
}
const backgroundVar = delta.attributes?.background.substring(
'var('.length,
delta.attributes?.background.indexOf(')')
);
const background = theme.getCssVariableColor(backgroundVar);
return {
type: 'element',
tagName: 'mark',
properties: {
style: `background-color: ${background};`,
},
children: [context.current],
};
},
});
export const highlightColorDeltaToHtmlAdapterMatcher =
InlineDeltaToHtmlAdapterExtension({
name: 'highlight-color',
match: delta => !!delta.attributes?.color,
toAST: (delta, context, provider) => {
const hast: InlineHtmlAST = {
type: 'element',
tagName: 'span',
properties: {},
children: [context.current],
};
if (!provider || !delta.attributes?.color) {
return hast;
}
const theme = provider.getOptional(ThemeProvider);
if (!theme) {
return hast;
}
const colorVar = delta.attributes?.color.substring(
'var('.length,
delta.attributes?.color.indexOf(')')
);
const color = theme.getCssVariableColor(colorVar);
return {
type: 'element',
tagName: 'mark',
properties: {
style: `color: ${color};background-color: transparent`,
},
children: [context.current],
};
},
});
export const InlineDeltaToHtmlAdapterExtensions = [
boldDeltaToHtmlAdapterMatcher,
italicDeltaToHtmlAdapterMatcher,
strikeDeltaToHtmlAdapterMatcher,
underlineDeltaToHtmlAdapterMatcher,
highlightBackgroundDeltaToHtmlAdapterMatcher,
highlightColorDeltaToHtmlAdapterMatcher,
inlineCodeDeltaToHtmlAdapterMatcher,
];

View File

@@ -0,0 +1,57 @@
import { InlineDeltaToMarkdownAdapterExtension } from '@blocksuite/affine-shared/adapters';
export const boldDeltaToMarkdownAdapterMatcher =
InlineDeltaToMarkdownAdapterExtension({
name: 'bold',
match: delta => !!delta.attributes?.bold,
toAST: (_, context) => {
const { current: currentMdast } = context;
return {
type: 'strong',
children: [currentMdast],
};
},
});
export const italicDeltaToMarkdownAdapterMatcher =
InlineDeltaToMarkdownAdapterExtension({
name: 'italic',
match: delta => !!delta.attributes?.italic,
toAST: (_, context) => {
const { current: currentMdast } = context;
return {
type: 'emphasis',
children: [currentMdast],
};
},
});
export const strikeDeltaToMarkdownAdapterMatcher =
InlineDeltaToMarkdownAdapterExtension({
name: 'strike',
match: delta => !!delta.attributes?.strike,
toAST: (_, context) => {
const { current: currentMdast } = context;
return {
type: 'delete',
children: [currentMdast],
};
},
});
export const inlineCodeDeltaToMarkdownAdapterMatcher =
InlineDeltaToMarkdownAdapterExtension({
name: 'inlineCode',
match: delta => !!delta.attributes?.code,
toAST: delta => ({
type: 'inlineCode',
value: delta.insert,
}),
});
export const InlineDeltaToMarkdownAdapterExtensions = [
inlineCodeDeltaToMarkdownAdapterMatcher,
boldDeltaToMarkdownAdapterMatcher,
italicDeltaToMarkdownAdapterMatcher,
strikeDeltaToMarkdownAdapterMatcher,
];

View File

@@ -0,0 +1,98 @@
import { MarkdownASTToDeltaExtension } from '@blocksuite/affine-shared/adapters';
export const markdownTextToDeltaMatcher = MarkdownASTToDeltaExtension({
name: 'text',
match: ast => ast.type === 'text',
toDelta: ast => {
if (!('value' in ast)) {
return [];
}
return [{ insert: ast.value }];
},
});
export const markdownInlineCodeToDeltaMatcher = MarkdownASTToDeltaExtension({
name: 'inlineCode',
match: ast => ast.type === 'inlineCode',
toDelta: ast => {
if (!('value' in ast)) {
return [];
}
return [{ insert: ast.value, attributes: { code: true } }];
},
});
export const markdownStrongToDeltaMatcher = MarkdownASTToDeltaExtension({
name: 'strong',
match: ast => ast.type === 'strong',
toDelta: (ast, context) => {
if (!('children' in ast)) {
return [];
}
return ast.children.flatMap(child =>
context.toDelta(child).map(delta => {
delta.attributes = { ...delta.attributes, bold: true };
return delta;
})
);
},
});
export const markdownEmphasisToDeltaMatcher = MarkdownASTToDeltaExtension({
name: 'emphasis',
match: ast => ast.type === 'emphasis',
toDelta: (ast, context) => {
if (!('children' in ast)) {
return [];
}
return ast.children.flatMap(child =>
context.toDelta(child).map(delta => {
delta.attributes = { ...delta.attributes, italic: true };
return delta;
})
);
},
});
export const markdownDeleteToDeltaMatcher = MarkdownASTToDeltaExtension({
name: 'delete',
match: ast => ast.type === 'delete',
toDelta: (ast, context) => {
if (!('children' in ast)) {
return [];
}
return ast.children.flatMap(child =>
context.toDelta(child).map(delta => {
delta.attributes = { ...delta.attributes, strike: true };
return delta;
})
);
},
});
export const markdownListToDeltaMatcher = MarkdownASTToDeltaExtension({
name: 'list',
match: ast => ast.type === 'list',
toDelta: () => [],
});
export const markdownHtmlToDeltaMatcher = MarkdownASTToDeltaExtension({
name: 'html',
match: ast => ast.type === 'html',
toDelta: (ast, context) => {
if (!('value' in ast)) {
return [];
}
return context?.htmlToDelta?.(ast.value) ?? [{ insert: ast.value }];
},
});
export const MarkdownInlineToDeltaAdapterExtensions = [
markdownTextToDeltaMatcher,
markdownInlineCodeToDeltaMatcher,
markdownStrongToDeltaMatcher,
markdownEmphasisToDeltaMatcher,
markdownDeleteToDeltaMatcher,
markdownListToDeltaMatcher,
markdownHtmlToDeltaMatcher,
];

View File

@@ -0,0 +1,256 @@
import {
HastUtils,
type HtmlAST,
NotionHtmlASTToDeltaExtension,
} from '@blocksuite/affine-shared/adapters';
import type { ExtensionType } from '@blocksuite/store';
import { collapseWhiteSpace } from 'collapse-white-space';
import type { Element, Text } from 'hast';
const isElement = (ast: HtmlAST): ast is Element => {
return ast.type === 'element';
};
const isText = (ast: HtmlAST): ast is Text => {
return ast.type === 'text';
};
const listElementTags = new Set(['ol', 'ul']);
const strongElementTags = new Set(['strong', 'b']);
const italicElementTags = new Set(['i', 'em']);
const NotionInlineEquationToken = 'notion-text-equation-token';
const NotionUnderlineStyleToken = 'border-bottom:0.05em solid';
export const notionHtmlTextToDeltaMatcher = NotionHtmlASTToDeltaExtension({
name: 'text',
match: ast => isText(ast),
toDelta: (ast, context) => {
if (!isText(ast)) {
return [];
}
const { options } = context;
options.trim ??= true;
if (options.pre || ast.value === ' ') {
return [{ insert: ast.value }];
}
if (options.trim) {
const value = collapseWhiteSpace(ast.value, { trim: options.trim });
if (value) {
return [{ insert: value }];
}
return [];
}
if (ast.value) {
return [{ insert: collapseWhiteSpace(ast.value) }];
}
return [];
},
});
export const notionHtmlSpanElementToDeltaMatcher =
NotionHtmlASTToDeltaExtension({
name: 'span-element',
match: ast => isElement(ast) && ast.tagName === 'span',
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
}
const { toDelta, options } = context;
if (
Array.isArray(ast.properties?.className) &&
ast.properties?.className.includes(NotionInlineEquationToken)
) {
const latex = HastUtils.getTextContent(
HastUtils.querySelector(ast, 'annotation')
);
return [{ insert: ' ', attributes: { latex } }];
}
// Add underline style detection
if (
typeof ast.properties?.style === 'string' &&
ast.properties?.style?.includes(NotionUnderlineStyleToken)
) {
return ast.children.flatMap(child =>
context.toDelta(child, options).map(delta => {
delta.attributes = { ...delta.attributes, underline: true };
return delta;
})
);
}
return ast.children.flatMap(child => toDelta(child, options));
},
});
export const notionHtmlListToDeltaMatcher = NotionHtmlASTToDeltaExtension({
name: 'list-element',
match: ast => isElement(ast) && listElementTags.has(ast.tagName),
toDelta: () => {
return [];
},
});
export const notionHtmlStrongElementToDeltaMatcher =
NotionHtmlASTToDeltaExtension({
name: 'strong-element',
match: ast => isElement(ast) && strongElementTags.has(ast.tagName),
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
}
const { toDelta, options } = context;
return ast.children.flatMap(child =>
toDelta(child, options).map(delta => {
delta.attributes = { ...delta.attributes, bold: true };
return delta;
})
);
},
});
export const notionHtmlItalicElementToDeltaMatcher =
NotionHtmlASTToDeltaExtension({
name: 'italic-element',
match: ast => isElement(ast) && italicElementTags.has(ast.tagName),
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
}
const { toDelta, options } = context;
return ast.children.flatMap(child =>
toDelta(child, options).map(delta => {
delta.attributes = { ...delta.attributes, italic: true };
return delta;
})
);
},
});
export const notionHtmlCodeElementToDeltaMatcher =
NotionHtmlASTToDeltaExtension({
name: 'code-element',
match: ast => isElement(ast) && ast.tagName === 'code',
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
}
const { toDelta, options } = context;
return ast.children.flatMap(child =>
toDelta(child, options).map(delta => {
delta.attributes = { ...delta.attributes, code: true };
return delta;
})
);
},
});
export const notionHtmlDelElementToDeltaMatcher = NotionHtmlASTToDeltaExtension(
{
name: 'del-element',
match: ast => isElement(ast) && ast.tagName === 'del',
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
}
const { toDelta, options } = context;
return ast.children.flatMap(child =>
toDelta(child, options).map(delta => {
delta.attributes = { ...delta.attributes, strike: true };
return delta;
})
);
},
}
);
export const notionHtmlUnderlineElementToDeltaMatcher =
NotionHtmlASTToDeltaExtension({
name: 'underline-element',
match: ast => isElement(ast) && ast.tagName === 'u',
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
}
const { toDelta, options } = context;
return ast.children.flatMap(child =>
toDelta(child, options).map(delta => {
delta.attributes = { ...delta.attributes, underline: true };
return delta;
})
);
},
});
export const notionHtmlMarkElementToDeltaMatcher =
NotionHtmlASTToDeltaExtension({
name: 'mark-element',
match: ast => isElement(ast) && ast.tagName === 'mark',
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
}
const { toDelta, options } = context;
return ast.children.flatMap(child =>
toDelta(child, options).map(delta => {
delta.attributes = { ...delta.attributes };
return delta;
})
);
},
});
export const notionHtmlLiElementToDeltaMatcher = NotionHtmlASTToDeltaExtension({
name: 'li-element',
match: ast =>
isElement(ast) &&
ast.tagName === 'li' &&
!!HastUtils.querySelector(ast, '.checkbox'),
toDelta: (ast, context) => {
if (!isElement(ast) || !HastUtils.querySelector(ast, '.checkbox')) {
return [];
}
const { toDelta, options } = context;
// Should ignore the children of to do list which is the checkbox and the space following it
const checkBox = HastUtils.querySelector(ast, '.checkbox');
const checkBoxIndex = ast.children.findIndex(child => child === checkBox);
return ast.children
.slice(checkBoxIndex + 2)
.flatMap(child => toDelta(child, options));
},
});
export const notionHtmlBrElementToDeltaMatcher = NotionHtmlASTToDeltaExtension({
name: 'br-element',
match: ast => isElement(ast) && ast.tagName === 'br',
toDelta: () => {
return [{ insert: '\n' }];
},
});
export const notionHtmlStyleElementToDeltaMatcher =
NotionHtmlASTToDeltaExtension({
name: 'style-element',
match: ast => isElement(ast) && ast.tagName === 'style',
toDelta: () => {
return [];
},
});
export const NotionHtmlInlineToDeltaAdapterExtensions: ExtensionType[] = [
notionHtmlTextToDeltaMatcher,
notionHtmlSpanElementToDeltaMatcher,
notionHtmlStrongElementToDeltaMatcher,
notionHtmlItalicElementToDeltaMatcher,
notionHtmlCodeElementToDeltaMatcher,
notionHtmlDelElementToDeltaMatcher,
notionHtmlUnderlineElementToDeltaMatcher,
notionHtmlMarkElementToDeltaMatcher,
notionHtmlListToDeltaMatcher,
notionHtmlLiElementToDeltaMatcher,
notionHtmlBrElementToDeltaMatcher,
notionHtmlStyleElementToDeltaMatcher,
];

View File

@@ -0,0 +1,138 @@
import {
BoldIcon,
CodeIcon,
ItalicIcon,
LinkIcon,
StrikethroughIcon,
UnderlineIcon,
} from '@blocksuite/affine-components/icons';
import { toggleLink } from '@blocksuite/affine-inline-link';
import { type EditorHost, TextSelection } from '@blocksuite/std';
import type { TemplateResult } from 'lit';
import {
isTextAttributeActive,
toggleBold,
toggleCode,
toggleItalic,
toggleStrike,
toggleUnderline,
} from './text-style.js';
export interface TextFormatConfig {
id: string;
name: string;
icon: TemplateResult<1>;
hotkey?: string;
activeWhen: (host: EditorHost) => boolean;
action: (host: EditorHost) => void;
textChecker?: (host: EditorHost) => boolean;
}
export const textFormatConfigs: TextFormatConfig[] = [
{
id: 'bold',
name: 'Bold',
icon: BoldIcon,
hotkey: 'Mod-b',
activeWhen: host => {
const [result] = host.std.command
.chain()
.pipe(isTextAttributeActive, { key: 'bold' })
.run();
return result;
},
action: host => {
host.std.command.chain().pipe(toggleBold).run();
},
},
{
id: 'italic',
name: 'Italic',
icon: ItalicIcon,
hotkey: 'Mod-i',
activeWhen: host => {
const [result] = host.std.command
.chain()
.pipe(isTextAttributeActive, { key: 'italic' })
.run();
return result;
},
action: host => {
host.std.command.chain().pipe(toggleItalic).run();
},
},
{
id: 'underline',
name: 'Underline',
icon: UnderlineIcon,
hotkey: 'Mod-u',
activeWhen: host => {
const [result] = host.std.command
.chain()
.pipe(isTextAttributeActive, { key: 'underline' })
.run();
return result;
},
action: host => {
host.std.command.chain().pipe(toggleUnderline).run();
},
},
{
id: 'strike',
name: 'Strikethrough',
icon: StrikethroughIcon,
hotkey: 'Mod-shift-s',
activeWhen: host => {
const [result] = host.std.command
.chain()
.pipe(isTextAttributeActive, { key: 'strike' })
.run();
return result;
},
action: host => {
host.std.command.chain().pipe(toggleStrike).run();
},
},
{
id: 'code',
name: 'Code',
icon: CodeIcon,
hotkey: 'Mod-e',
activeWhen: host => {
const [result] = host.std.command
.chain()
.pipe(isTextAttributeActive, { key: 'code' })
.run();
return result;
},
action: host => {
host.std.command.chain().pipe(toggleCode).run();
},
},
{
id: 'link',
name: 'Link',
icon: LinkIcon,
hotkey: 'Mod-k',
activeWhen: host => {
const [result] = host.std.command
.chain()
.pipe(isTextAttributeActive, { key: 'link' })
.run();
return result;
},
action: host => {
host.std.command.chain().pipe(toggleLink).run();
},
// should check text length
textChecker: host => {
const textSelection = host.std.selection.find(TextSelection);
if (!textSelection || textSelection.isCollapsed()) return false;
return Boolean(
textSelection.from.length + (textSelection.to?.length ?? 0)
);
},
},
];

View File

@@ -0,0 +1,19 @@
// corresponding to `formatText` command
import { TableModelFlavour } from '@blocksuite/affine-model';
export const FORMAT_TEXT_SUPPORT_FLAVOURS = [
'affine:paragraph',
'affine:list',
'affine:code',
];
// corresponding to `formatBlock` command
export const FORMAT_BLOCK_SUPPORT_FLAVOURS = [
'affine:paragraph',
'affine:list',
'affine:code',
];
// corresponding to `formatNative` command
export const FORMAT_NATIVE_SUPPORT_FLAVOURS = [
'affine:database',
TableModelFlavour,
];

View File

@@ -0,0 +1,81 @@
import { RootBlockModel } from '@blocksuite/affine-model';
import { matchModels } from '@blocksuite/affine-shared/utils';
import { type Command, TextSelection } from '@blocksuite/std';
import type { Text } from '@blocksuite/store';
export const deleteTextCommand: Command<{
currentTextSelection?: TextSelection;
textSelection?: TextSelection;
}> = (ctx, next) => {
const textSelection = ctx.textSelection ?? ctx.currentTextSelection;
if (!textSelection) return;
const range = ctx.std.range.textSelectionToRange(textSelection);
if (!range) return;
const selectedElements = ctx.std.range.getSelectedBlockComponentsByRange(
range,
{
mode: 'flat',
}
);
const { from, to } = textSelection;
const fromElement = selectedElements.find(el => from.blockId === el.blockId);
if (!fromElement) return;
let fromText: Text | undefined;
if (matchModels(fromElement.model, [RootBlockModel])) {
fromText = fromElement.model.props.title;
} else {
fromText = fromElement.model.text;
}
if (!fromText) return;
if (!to) {
fromText.delete(from.index, from.length);
ctx.std.selection.setGroup('note', [
ctx.std.selection.create(TextSelection, {
from: {
blockId: from.blockId,
index: from.index,
length: 0,
},
to: null,
}),
]);
return next();
}
const toElement = selectedElements.find(el => to.blockId === el.blockId);
if (!toElement) return;
const toText = toElement.model.text;
if (!toText) return;
fromText.delete(from.index, from.length);
toText.delete(0, to.length);
fromText.join(toText);
selectedElements
.filter(el => el.model.id !== fromElement.model.id)
.forEach(el => {
ctx.std.store.deleteBlock(el.model, {
bringChildrenTo:
el.model.id === toElement.model.id ? fromElement.model : undefined,
});
});
ctx.std.selection.setGroup('note', [
ctx.std.selection.create(TextSelection, {
from: {
blockId: from.blockId,
index: from.index,
length: 0,
},
to: null,
}),
]);
next();
};

View File

@@ -0,0 +1,75 @@
import { getSelectedBlocksCommand } from '@blocksuite/affine-shared/commands';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import type { BlockSelection, Command } from '@blocksuite/std';
import {
INLINE_ROOT_ATTR,
type InlineRootElement,
} from '@blocksuite/std/inline';
import { FORMAT_BLOCK_SUPPORT_FLAVOURS } from './consts.js';
// for block selection
export const formatBlockCommand: Command<{
currentBlockSelections?: BlockSelection[];
blockSelections?: BlockSelection[];
styles: AffineTextAttributes;
mode?: 'replace' | 'merge';
}> = (ctx, next) => {
const blockSelections = ctx.blockSelections ?? ctx.currentBlockSelections;
if (!blockSelections) {
console.error(
'`blockSelections` is required, you need to pass it in args or use `getBlockSelections` command before adding this command to the pipeline.'
);
return;
}
if (blockSelections.length === 0) return;
const styles = ctx.styles;
const mode = ctx.mode ?? 'merge';
const success = ctx.std.command
.chain()
.pipe(getSelectedBlocksCommand, {
blockSelections,
filter: el => FORMAT_BLOCK_SUPPORT_FLAVOURS.includes(el.model.flavour),
types: ['block'],
})
.pipe((ctx, next) => {
const { selectedBlocks } = ctx;
if (!selectedBlocks) {
console.error(
'`selectedBlocks` is required, you need to pass it in args or use `getSelectedBlocksCommand` command before adding this command to the pipeline.'
);
return;
}
const selectedInlineEditors = selectedBlocks.flatMap(el => {
const inlineRoot = el.querySelector<
InlineRootElement<AffineTextAttributes>
>(`[${INLINE_ROOT_ATTR}]`);
if (inlineRoot) {
return inlineRoot.inlineEditor;
}
return [];
});
selectedInlineEditors.forEach(inlineEditor => {
inlineEditor.formatText(
{
index: 0,
length: inlineEditor.yTextLength,
},
styles,
{
mode,
}
);
});
next();
})
.run();
if (success) next();
};

View File

@@ -0,0 +1,53 @@
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import {
BLOCK_ID_ATTR,
type BlockComponent,
type Command,
} from '@blocksuite/std';
import {
INLINE_ROOT_ATTR,
type InlineRootElement,
} from '@blocksuite/std/inline';
import { FORMAT_NATIVE_SUPPORT_FLAVOURS } from './consts.js';
// for native range
export const formatNativeCommand: Command<{
range?: Range;
styles: AffineTextAttributes;
mode?: 'replace' | 'merge';
}> = (ctx, next) => {
const { styles, mode = 'merge' } = ctx;
let range = ctx.range;
if (!range) {
const selection = document.getSelection();
if (!selection || selection.rangeCount === 0) return;
range = selection.getRangeAt(0);
}
if (!range) return;
const selectedInlineEditors = Array.from<InlineRootElement>(
ctx.std.host.querySelectorAll(`[${INLINE_ROOT_ATTR}]`)
)
.filter(el => range?.intersectsNode(el))
.filter(el => {
const block = el.closest<BlockComponent>(`[${BLOCK_ID_ATTR}]`);
if (block) {
return FORMAT_NATIVE_SUPPORT_FLAVOURS.includes(block.model.flavour);
}
return false;
})
.map(el => el.inlineEditor);
selectedInlineEditors.forEach(inlineEditor => {
const inlineRange = inlineEditor.getInlineRange();
if (!inlineRange) return;
inlineEditor.formatText(inlineRange, styles, {
mode,
});
});
next();
};

View File

@@ -0,0 +1,94 @@
import { clearMarksOnDiscontinuousInput } from '@blocksuite/affine-rich-text';
import { getSelectedBlocksCommand } from '@blocksuite/affine-shared/commands';
import type {
AffineTextAttributes,
AffineTextStyleAttributes,
} from '@blocksuite/affine-shared/types';
import type { Command, TextSelection } from '@blocksuite/std';
import {
INLINE_ROOT_ATTR,
type InlineRootElement,
} from '@blocksuite/std/inline';
import { FORMAT_TEXT_SUPPORT_FLAVOURS } from './consts.js';
// for text selection
export const formatTextCommand: Command<{
currentTextSelection?: TextSelection;
textSelection?: TextSelection;
styles: AffineTextStyleAttributes;
mode?: 'replace' | 'merge';
}> = (ctx, next) => {
const { styles, mode = 'merge' } = ctx;
const textSelection = ctx.textSelection ?? ctx.currentTextSelection;
if (!textSelection) return;
const success = ctx.std.command
.chain()
.pipe(getSelectedBlocksCommand, {
textSelection,
filter: el => FORMAT_TEXT_SUPPORT_FLAVOURS.includes(el.model.flavour),
types: ['text'],
})
.pipe((ctx, next) => {
const { selectedBlocks } = ctx;
if (!selectedBlocks) return;
const selectedInlineEditors = selectedBlocks.flatMap(el => {
const inlineRoot = el.querySelector<
InlineRootElement<AffineTextAttributes>
>(`[${INLINE_ROOT_ATTR}]`);
if (inlineRoot && inlineRoot.inlineEditor.getInlineRange()) {
return inlineRoot.inlineEditor;
}
return [];
});
selectedInlineEditors.forEach(inlineEditor => {
const inlineRange = inlineEditor.getInlineRange();
if (!inlineRange) return;
if (inlineRange.length === 0) {
const delta = inlineEditor.getDeltaByRangeIndex(inlineRange.index);
inlineEditor.setMarks({
...inlineEditor.marks,
...Object.fromEntries(
Object.entries(styles).map(([key, value]) => {
if (typeof value === 'boolean') {
return [
key,
(inlineEditor.marks &&
inlineEditor.marks[key as keyof AffineTextAttributes]) ||
(delta &&
delta.attributes &&
delta.attributes[key as keyof AffineTextAttributes])
? null
: value,
];
}
return [key, value];
})
),
});
clearMarksOnDiscontinuousInput(inlineEditor);
} else {
inlineEditor.formatText(inlineRange, styles, {
mode,
});
}
});
Promise.all(selectedBlocks.map(el => el.updateComplete))
.then(() => {
ctx.std.range.syncTextSelectionToRange(textSelection);
})
.catch(console.error);
next();
})
.run();
if (success) next();
};

View File

@@ -0,0 +1,22 @@
export type { TextFormatConfig } from './config.js';
export { textFormatConfigs } from './config.js';
export {
FORMAT_BLOCK_SUPPORT_FLAVOURS,
FORMAT_NATIVE_SUPPORT_FLAVOURS,
FORMAT_TEXT_SUPPORT_FLAVOURS,
} from './consts.js';
export { deleteTextCommand } from './delete-text.js';
export { formatBlockCommand } from './format-block.js';
export { formatNativeCommand } from './format-native.js';
export { formatTextCommand } from './format-text.js';
export {
getTextAttributes,
isTextAttributeActive,
toggleBold,
toggleCode,
toggleItalic,
toggleStrike,
toggleTextStyleCommand,
toggleUnderline,
} from './text-style.js';
export { isFormatSupported } from './utils.js';

View File

@@ -0,0 +1,110 @@
import {
getBlockSelectionsCommand,
getTextSelectionCommand,
} from '@blocksuite/affine-shared/commands';
import type {
AffineTextAttributes,
AffineTextStyleAttributes,
} from '@blocksuite/affine-shared/types';
import type { Command } from '@blocksuite/std';
import { formatBlockCommand } from './format-block.js';
import { formatNativeCommand } from './format-native.js';
import { formatTextCommand } from './format-text.js';
import { getCombinedTextAttributes } from './utils.js';
export const toggleTextStyleCommand: Command<{
key: Extract<
keyof AffineTextStyleAttributes,
'bold' | 'italic' | 'underline' | 'strike' | 'code'
>;
}> = (ctx, next) => {
const { std, key } = ctx;
const [active] = std.command
.chain()
.pipe(isTextAttributeActive, { key })
.run();
const payload: {
styles: AffineTextStyleAttributes;
mode?: 'replace' | 'merge';
} = {
styles: {
[key]: active ? null : true,
},
};
const [result] = std.command
.chain()
.try(chain => [
chain.pipe(getTextSelectionCommand).pipe(formatTextCommand, payload),
chain.pipe(getBlockSelectionsCommand).pipe(formatBlockCommand, payload),
chain.pipe(formatNativeCommand, payload),
])
.run();
if (result) {
return next();
}
return false;
};
const toggleTextStyleCommandWrapper = (
key: Extract<
keyof AffineTextStyleAttributes,
'bold' | 'italic' | 'underline' | 'strike' | 'code'
>
): Command => {
return (ctx, next) => {
const [success] = ctx.std.command
.chain()
.pipe(toggleTextStyleCommand, { key })
.run();
if (success) next();
return false;
};
};
export const toggleBold = toggleTextStyleCommandWrapper('bold');
export const toggleItalic = toggleTextStyleCommandWrapper('italic');
export const toggleUnderline = toggleTextStyleCommandWrapper('underline');
export const toggleStrike = toggleTextStyleCommandWrapper('strike');
export const toggleCode = toggleTextStyleCommandWrapper('code');
export const getTextAttributes: Command<
{},
{ textAttributes: AffineTextAttributes }
> = (ctx, next) => {
const [result, innerCtx] = getCombinedTextAttributes(
ctx.std.command.chain()
).run();
if (!result) {
return false;
}
return next({ textAttributes: innerCtx.textAttributes });
};
export const isTextAttributeActive: Command<{
key: keyof AffineTextAttributes;
}> = (ctx, next) => {
const key = ctx.key;
const [result] = getCombinedTextAttributes(ctx.std.command.chain())
.pipe((ctx, next) => {
const { textAttributes } = ctx;
if (textAttributes && key in textAttributes) {
return next();
}
return false;
})
.run();
if (!result) {
return false;
}
return next();
};

View File

@@ -0,0 +1,226 @@
import {
getBlockSelectionsCommand,
getSelectedBlocksCommand,
getTextSelectionCommand,
} from '@blocksuite/affine-shared/commands';
import type {
AffineInlineEditor,
AffineTextAttributes,
} from '@blocksuite/affine-shared/types';
import {
BLOCK_ID_ATTR,
type BlockComponent,
type Chain,
type InitCommandCtx,
} from '@blocksuite/std';
import {
INLINE_ROOT_ATTR,
type InlineEditor,
type InlineRange,
type InlineRootElement,
} from '@blocksuite/std/inline';
import {
FORMAT_BLOCK_SUPPORT_FLAVOURS,
FORMAT_NATIVE_SUPPORT_FLAVOURS,
FORMAT_TEXT_SUPPORT_FLAVOURS,
} from './consts.js';
function getCombinedFormatFromInlineEditors(
inlineEditors: [AffineInlineEditor, InlineRange | null][]
): AffineTextAttributes {
const formatArr: AffineTextAttributes[] = [];
inlineEditors.forEach(([inlineEditor, inlineRange]) => {
if (!inlineRange) return;
const format = inlineEditor.getFormat(inlineRange);
formatArr.push(format);
});
if (formatArr.length === 0) return {};
// format will be active only when all inline editors have the same format.
return formatArr.reduce((acc, cur) => {
const newFormat: AffineTextAttributes = {};
for (const key in acc) {
const typedKey = key as keyof AffineTextAttributes;
if (acc[typedKey] === cur[typedKey]) {
// This cast is secure because we have checked that the value of the key is the same.
newFormat[typedKey] = acc[typedKey] as any;
}
}
return newFormat;
});
}
function getSelectedInlineEditors(
blocks: BlockComponent[],
filter: (
inlineRoot: InlineRootElement<AffineTextAttributes>
) => InlineEditor<AffineTextAttributes> | []
) {
return blocks.flatMap(el => {
const inlineRoot = el.querySelector<
InlineRootElement<AffineTextAttributes>
>(`[${INLINE_ROOT_ATTR}]`);
if (inlineRoot) {
return filter(inlineRoot);
}
return [];
});
}
function handleCurrentSelection(
chain: Chain<InitCommandCtx>,
handler: (
type: 'text' | 'block' | 'native',
inlineEditors: InlineEditor<AffineTextAttributes>[]
) => { textAttributes: AffineTextAttributes } | boolean | void
): Chain<InitCommandCtx & { textAttributes: AffineTextAttributes }> {
return chain.try(chain => [
// text selection, corresponding to `formatText` command
chain
.pipe(getTextSelectionCommand)
.pipe(getSelectedBlocksCommand, {
types: ['text'],
filter: el => FORMAT_TEXT_SUPPORT_FLAVOURS.includes(el.model.flavour),
})
.pipe((ctx, next) => {
const { selectedBlocks } = ctx;
if (!selectedBlocks) {
console.error(
'`selectedBlocks` is required, you need to pass it in args or use `getSelectedBlocksCommand` command before adding this command to the pipeline.'
);
return false;
}
const selectedInlineEditors = getSelectedInlineEditors(
selectedBlocks,
inlineRoot => {
const inlineRange = inlineRoot.inlineEditor.getInlineRange();
if (!inlineRange) return [];
return inlineRoot.inlineEditor;
}
);
const result = handler('text', selectedInlineEditors);
if (!result) return false;
if (result === true) {
return next();
}
return next(result);
}),
// block selection, corresponding to `formatBlock` command
chain
.pipe(getBlockSelectionsCommand)
.pipe(getSelectedBlocksCommand, {
types: ['block'],
filter: el => FORMAT_BLOCK_SUPPORT_FLAVOURS.includes(el.model.flavour),
})
.pipe((ctx, next) => {
const { selectedBlocks } = ctx;
if (!selectedBlocks) {
console.error(
'`selectedBlocks` is required, you need to pass it in args or use `getSelectedBlocksCommand` command before adding this command to the pipeline.'
);
return false;
}
const selectedInlineEditors = getSelectedInlineEditors(
selectedBlocks,
inlineRoot =>
inlineRoot.inlineEditor.yTextLength > 0
? inlineRoot.inlineEditor
: []
);
const result = handler('block', selectedInlineEditors);
if (!result) return false;
if (result === true) {
return next();
}
return next(result);
}),
// native selection, corresponding to `formatNative` command
chain.pipe((ctx, next) => {
const selectedInlineEditors = Array.from<InlineRootElement>(
ctx.std.host.querySelectorAll(`[${INLINE_ROOT_ATTR}]`)
)
.filter(el => {
const selection = document.getSelection();
if (!selection || selection.rangeCount === 0) return false;
const range = selection.getRangeAt(0);
return range.intersectsNode(el);
})
.filter(el => {
const block = el.closest<BlockComponent>(`[${BLOCK_ID_ATTR}]`);
if (block) {
return FORMAT_NATIVE_SUPPORT_FLAVOURS.includes(block.model.flavour);
}
return false;
})
.map((el): AffineInlineEditor => el.inlineEditor);
const result = handler('native', selectedInlineEditors);
if (!result) return false;
if (result === true) {
return next();
}
return next(result);
}),
]);
}
export function getCombinedTextAttributes(chain: Chain<InitCommandCtx>) {
return handleCurrentSelection(chain, (type, inlineEditors) => {
if (type === 'text') {
return {
textAttributes: getCombinedFormatFromInlineEditors(
inlineEditors.map(e => [e, e.getInlineRange()])
),
};
}
if (type === 'block') {
return {
textAttributes: getCombinedFormatFromInlineEditors(
inlineEditors.map(e => [e, { index: 0, length: e.yTextLength }])
),
};
}
if (type === 'native') {
return {
textAttributes: getCombinedFormatFromInlineEditors(
inlineEditors.map(e => [e, e.getInlineRange()])
),
};
}
return false;
});
}
export function isFormatSupported(chain: Chain<InitCommandCtx>) {
return handleCurrentSelection(chain, (_type, inlineEditors) => {
if (inlineEditors.length === 1) {
const inlineEditor = inlineEditors[0];
const inlineRange = inlineEditor.getInlineRange();
// support block selection
if (!inlineRange) return true;
if (inlineRange.length !== 1) return true;
// skip embed node
const delta = inlineEditor.getDeltaByRangeIndex(inlineRange.index + 1);
if (!delta) return true;
const isEmbed = inlineEditor.isEmbed(delta);
if (isEmbed) return false;
return true;
}
return inlineEditors.length > 0;
});
}

View File

@@ -0,0 +1,38 @@
import { CommentInlineSpecExtension } from '@blocksuite/affine-inline-comment';
import { FootNoteInlineSpecExtension } from '@blocksuite/affine-inline-footnote';
import { LatexInlineSpecExtension } from '@blocksuite/affine-inline-latex';
import { LinkInlineSpecExtension } from '@blocksuite/affine-inline-link';
import { MentionInlineSpecExtension } from '@blocksuite/affine-inline-mention';
import { ReferenceInlineSpecExtension } from '@blocksuite/affine-inline-reference';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { InlineManagerExtension } from '@blocksuite/std/inline';
import {
BackgroundInlineSpecExtension,
BoldInlineSpecExtension,
CodeInlineSpecExtension,
ColorInlineSpecExtension,
ItalicInlineSpecExtension,
StrikeInlineSpecExtension,
UnderlineInlineSpecExtension,
} from './inline-spec';
export const DefaultInlineManagerExtension =
InlineManagerExtension<AffineTextAttributes>({
id: 'DefaultInlineManager',
specs: [
BoldInlineSpecExtension.identifier,
ItalicInlineSpecExtension.identifier,
UnderlineInlineSpecExtension.identifier,
StrikeInlineSpecExtension.identifier,
CodeInlineSpecExtension.identifier,
BackgroundInlineSpecExtension.identifier,
ColorInlineSpecExtension.identifier,
LatexInlineSpecExtension.identifier,
ReferenceInlineSpecExtension.identifier,
LinkInlineSpecExtension.identifier,
FootNoteInlineSpecExtension.identifier,
MentionInlineSpecExtension.identifier,
CommentInlineSpecExtension.identifier,
],
});

View File

@@ -0,0 +1,11 @@
import { AffineText } from './nodes/affine-text';
export function effects() {
customElements.define('affine-text', AffineText);
}
declare global {
interface HTMLElementTagNameMap {
'affine-text': AffineText;
}
}

View File

@@ -0,0 +1,10 @@
import type * as RichTextEffects from '@blocksuite/affine-rich-text/effects';
declare type _GLOBAL_ = typeof RichTextEffects;
export * from './adapters/extensions';
export * from './command';
export * from './default-inline-manager';
export * from './inline-spec';
export * from './keymap';
export * from './markdown';

View File

@@ -0,0 +1,118 @@
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import {
type InlineRootElement,
InlineSpecExtension,
} from '@blocksuite/std/inline';
import type { ExtensionType } from '@blocksuite/store';
import { html } from 'lit';
import { z } from 'zod';
export type AffineInlineRootElement = InlineRootElement<AffineTextAttributes>;
export const BoldInlineSpecExtension =
InlineSpecExtension<AffineTextAttributes>({
name: 'bold',
schema: z.object({
bold: z.literal(true).optional().nullable().catch(undefined),
}),
match: delta => {
return !!delta.attributes?.bold;
},
renderer: ({ delta }) => {
return html`<affine-text .delta=${delta}></affine-text>`;
},
});
export const ItalicInlineSpecExtension =
InlineSpecExtension<AffineTextAttributes>({
name: 'italic',
schema: z.object({
italic: z.literal(true).optional().nullable().catch(undefined),
}),
match: delta => {
return !!delta.attributes?.italic;
},
renderer: ({ delta }) => {
return html`<affine-text .delta=${delta}></affine-text>`;
},
});
export const UnderlineInlineSpecExtension =
InlineSpecExtension<AffineTextAttributes>({
name: 'underline',
schema: z.object({
underline: z.literal(true).optional().nullable().catch(undefined),
}),
match: delta => {
return !!delta.attributes?.underline;
},
renderer: ({ delta }) => {
return html`<affine-text .delta=${delta}></affine-text>`;
},
});
export const StrikeInlineSpecExtension =
InlineSpecExtension<AffineTextAttributes>({
name: 'strike',
schema: z.object({
strike: z.literal(true).optional().nullable().catch(undefined),
}),
match: delta => {
return !!delta.attributes?.strike;
},
renderer: ({ delta }) => {
return html`<affine-text .delta=${delta}></affine-text>`;
},
});
export const CodeInlineSpecExtension =
InlineSpecExtension<AffineTextAttributes>({
name: 'inline-code',
schema: z.object({
code: z.literal(true).optional().nullable().catch(undefined),
}),
match: delta => {
return !!delta.attributes?.code;
},
renderer: ({ delta }) => {
return html`<affine-text .delta=${delta}></affine-text>`;
},
});
export const BackgroundInlineSpecExtension =
InlineSpecExtension<AffineTextAttributes>({
name: 'background',
schema: z.object({
background: z.string().optional().nullable().catch(undefined),
}),
match: delta => {
return !!delta.attributes?.background;
},
renderer: ({ delta }) => {
return html`<affine-text .delta=${delta}></affine-text>`;
},
});
export const ColorInlineSpecExtension =
InlineSpecExtension<AffineTextAttributes>({
name: 'color',
schema: z.object({
color: z.string().optional().nullable().catch(undefined),
}),
match: delta => {
return !!delta.attributes?.color;
},
renderer: ({ delta }) => {
return html`<affine-text .delta=${delta}></affine-text>`;
},
});
export const InlineSpecExtensions: ExtensionType[] = [
BoldInlineSpecExtension,
ItalicInlineSpecExtension,
UnderlineInlineSpecExtension,
StrikeInlineSpecExtension,
CodeInlineSpecExtension,
BackgroundInlineSpecExtension,
ColorInlineSpecExtension,
];

View File

@@ -0,0 +1,78 @@
import {
focusTextModel,
getInlineEditorByModel,
selectTextModel,
} from '@blocksuite/affine-rich-text';
import {
BlockSelection,
type BlockStdScope,
TextSelection,
type UIEventHandler,
} from '@blocksuite/std';
export const textCommonKeymap = (
std: BlockStdScope
): Record<string, UIEventHandler> => {
return {
ArrowUp: () => {
const text = std.selection.find(TextSelection);
if (!text) return;
const inline = getInlineEditorByModel(std, text.from.blockId);
if (!inline) return;
return !inline.isFirstLine(inline.getInlineRange());
},
ArrowDown: () => {
const text = std.selection.find(TextSelection);
if (!text) return;
const inline = getInlineEditorByModel(std, text.from.blockId);
if (!inline) return;
return !inline.isLastLine(inline.getInlineRange());
},
Escape: ctx => {
const text = std.selection.find(TextSelection);
if (!text) return;
selectBlock(std, text.from.blockId);
ctx.get('keyboardState').raw.stopPropagation();
return true;
},
'Mod-a': ctx => {
const text = std.selection.find(TextSelection);
if (!text) return;
const model = std.store.getBlock(text.from.blockId)?.model;
if (!model || !model.text) return;
ctx.get('keyboardState').raw.preventDefault();
if (
text.from.index === 0 &&
text.from.length === model.text.yText.length
) {
selectBlock(std, text.from.blockId);
return true;
}
selectTextModel(std, text.from.blockId, 0, model.text.yText.length);
return true;
},
Enter: ctx => {
const blocks = std.selection.filter(BlockSelection);
const blockId = blocks.at(-1)?.blockId;
if (!blockId) return;
const model = std.store.getBlock(blockId)?.model;
if (!model || !model.text) return;
ctx.get('keyboardState').raw.preventDefault();
focusTextModel(std, blockId, model.text.yText.length);
return true;
},
};
};
function selectBlock(std: BlockStdScope, blockId: string) {
std.selection.setGroup('note', [
std.selection.create(BlockSelection, { blockId }),
]);
}

View File

@@ -0,0 +1,162 @@
import { insertLinkedNode } from '@blocksuite/affine-inline-reference';
import { CodeBlockModel } from '@blocksuite/affine-model';
import { getInlineEditorByModel } from '@blocksuite/affine-rich-text';
import { BRACKET_PAIRS } from '@blocksuite/affine-shared/consts';
import { createDefaultDoc, matchModels } from '@blocksuite/affine-shared/utils';
import {
type BlockStdScope,
TextSelection,
type UIEventHandler,
} from '@blocksuite/std';
import type { InlineEditor } from '@blocksuite/std/inline';
export const bracketKeymap = (
std: BlockStdScope
): Record<string, UIEventHandler> => {
const keymap = BRACKET_PAIRS.reduce(
(acc, pair) => {
return {
...acc,
[pair.right]: ctx => {
const { store: doc, selection } = std;
if (doc.readonly) return;
const textSelection = selection.find(TextSelection);
if (!textSelection) return;
const model = doc.getBlock(textSelection.from.blockId)?.model;
if (!model) return;
if (!matchModels(model, [CodeBlockModel])) return;
const inlineEditor = getInlineEditorByModel(
std,
textSelection.from.blockId
);
if (!inlineEditor) return;
const inlineRange = inlineEditor.getInlineRange();
if (!inlineRange) return;
const left = inlineEditor.yText.toString()[inlineRange.index - 1];
const right = inlineEditor.yText.toString()[inlineRange.index];
if (pair.left === left && pair.right === right) {
inlineEditor.setInlineRange({
index: inlineRange.index + 1,
length: 0,
});
ctx.get('keyboardState').raw.preventDefault();
}
},
[pair.left]: ctx => {
const { store: doc, selection } = std;
if (doc.readonly) return;
const textSelection = selection.find(TextSelection);
if (!textSelection) return;
const model = doc.getBlock(textSelection.from.blockId)?.model;
if (!model) return;
const isCodeBlock = matchModels(model, [CodeBlockModel]);
// When selection is collapsed, only trigger auto complete in code block
if (textSelection.isCollapsed() && !isCodeBlock) return;
if (!textSelection.isInSameBlock()) return;
ctx.get('keyboardState').raw.preventDefault();
const inlineEditor = getInlineEditorByModel(
std,
textSelection.from.blockId
);
if (!inlineEditor) return;
const inlineRange = inlineEditor.getInlineRange();
if (!inlineRange) return;
const selectedText = inlineEditor.yText
.toString()
.slice(inlineRange.index, inlineRange.index + inlineRange.length);
if (!isCodeBlock && pair.name === 'square bracket') {
// [[Selected text]] should automatically be converted to a Linked doc with the title "Selected text".
// See https://github.com/toeverything/blocksuite/issues/2730
const success = tryConvertToLinkedDoc(std, inlineEditor);
if (success) return true;
}
inlineEditor.insertText(
inlineRange,
pair.left + selectedText + pair.right
);
inlineEditor.setInlineRange({
index: inlineRange.index + 1,
length: inlineRange.length,
});
return true;
},
};
},
{} as Record<string, UIEventHandler>
);
return {
...keymap,
'`': ctx => {
const { store: doc, selection } = std;
if (doc.readonly) return;
const textSelection = selection.find(TextSelection);
if (!textSelection || textSelection.isCollapsed()) return;
if (!textSelection.isInSameBlock()) return;
const model = doc.getBlock(textSelection.from.blockId)?.model;
if (!model) return;
ctx.get('keyboardState').raw.preventDefault();
const inlineEditor = getInlineEditorByModel(
std,
textSelection.from.blockId
);
if (!inlineEditor) return;
const inlineRange = inlineEditor.getInlineRange();
if (!inlineRange) return;
inlineEditor.formatText(inlineRange, { code: true });
inlineEditor.setInlineRange({
index: inlineRange.index,
length: inlineRange.length,
});
return true;
},
};
};
function tryConvertToLinkedDoc(std: BlockStdScope, inlineEditor: InlineEditor) {
const root = std.store.root;
if (!root) return false;
const linkedDocWidgetEle = std.view.getWidget(
'affine-linked-doc-widget',
root.id
);
if (!linkedDocWidgetEle) return false;
const inlineRange = inlineEditor.getInlineRange();
if (!inlineRange) return false;
const text = inlineEditor.yText.toString();
const left = text[inlineRange.index - 1];
const right = text[inlineRange.index + inlineRange.length];
const needConvert = left === '[' && right === ']';
if (!needConvert) return false;
const docName = text.slice(
inlineRange.index,
inlineRange.index + inlineRange.length
);
inlineEditor.deleteText({
index: inlineRange.index - 1,
length: inlineRange.length + 2,
});
inlineEditor.setInlineRange({ index: inlineRange.index - 1, length: 0 });
const doc = createDefaultDoc(std.store.workspace, {
title: docName,
});
insertLinkedNode({
inlineEditor,
docId: doc.id,
});
return true;
}

View File

@@ -0,0 +1,29 @@
import { type BlockStdScope, type UIEventHandler } from '@blocksuite/std';
import { textFormatConfigs } from '../command/index.js';
export const textFormatKeymap = (std: BlockStdScope) =>
textFormatConfigs
.filter(config => config.hotkey)
.reduce(
(acc, config) => {
return {
...acc,
[config.hotkey as string]: ctx => {
const { store: doc } = std;
if (doc.readonly) return;
const allowed = config.textChecker?.(std.host) ?? true;
if (!allowed) return;
const event = ctx.get('keyboardState').raw;
event.stopPropagation();
event.preventDefault();
config.action(std.host);
return true;
},
};
},
{} as Record<string, UIEventHandler>
);

View File

@@ -0,0 +1,15 @@
import type { BlockStdScope, UIEventHandler } from '@blocksuite/std';
import { textCommonKeymap } from './basic.js';
import { bracketKeymap } from './bracket.js';
import { textFormatKeymap } from './format.js';
export const textKeymap = (
std: BlockStdScope
): Record<string, UIEventHandler> => {
return {
...textCommonKeymap(std),
...bracketKeymap(std),
...textFormatKeymap(std),
};
};

View File

@@ -0,0 +1,284 @@
import { LatexExtension } from '@blocksuite/affine-inline-latex';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { InlineMarkdownExtension } from '@blocksuite/std/inline';
import type { ExtensionType } from '@blocksuite/store';
// inline markdown match rules:
// covert: ***test*** + space
// covert: ***t est*** + space
// not convert: *** test*** + space
// not convert: ***test *** + space
// not convert: *** test *** + space
export const BoldItalicMarkdown = InlineMarkdownExtension<AffineTextAttributes>(
{
name: 'bolditalic',
pattern: /.*\*{3}([^\s*][^*]*[^\s*])\*{3}\s$|.*\*{3}([^\s*])\*{3}\s$/,
action: ({
inlineEditor,
prefixText,
inlineRange,
pattern,
undoManager,
}) => {
const match = prefixText.match(pattern);
if (!match) return;
const targetText = match[1] ?? match[2];
const annotatedText = match[0].slice(
-(targetText.length + 3 * 2 + 1),
-1
);
const startIndex = inlineRange.index - annotatedText.length - 1;
undoManager.stopCapturing();
inlineEditor.formatText(
{
index: startIndex,
length: annotatedText.length,
},
{
bold: true,
italic: true,
}
);
inlineEditor.deleteText({
index: inlineRange.index - 4,
length: 4,
});
inlineEditor.deleteText({
index: startIndex,
length: 3,
});
inlineEditor.setInlineRange({
index: startIndex + annotatedText.length - 6,
length: 0,
});
},
}
);
export const BoldMarkdown = InlineMarkdownExtension<AffineTextAttributes>({
name: 'bold',
pattern: /.*\*{2}([^\s][^*]*[^\s*])\*{2}\s$|.*\*{2}([^\s*])\*{2}\s$/,
action: ({ inlineEditor, prefixText, inlineRange, pattern, undoManager }) => {
const match = prefixText.match(pattern);
if (!match) return;
const targetText = match[1] ?? match[2];
const annotatedText = match[0].slice(-(targetText.length + 2 * 2 + 1), -1);
const startIndex = inlineRange.index - annotatedText.length - 1;
undoManager.stopCapturing();
inlineEditor.formatText(
{
index: startIndex,
length: annotatedText.length,
},
{
bold: true,
}
);
inlineEditor.deleteText({
index: inlineRange.index - 3,
length: 3,
});
inlineEditor.deleteText({
index: startIndex,
length: 2,
});
inlineEditor.setInlineRange({
index: startIndex + annotatedText.length - 4,
length: 0,
});
},
});
export const ItalicExtension = InlineMarkdownExtension<AffineTextAttributes>({
name: 'italic',
pattern: /.*\*{1}([^\s][^*]*[^\s*])\*{1}\s$|.*\*{1}([^\s*])\*{1}\s$/,
action: ({ inlineEditor, prefixText, inlineRange, pattern, undoManager }) => {
const match = prefixText.match(pattern);
if (!match) return;
const targetText = match[1] ?? match[2];
const annotatedText = match[0].slice(-(targetText.length + 1 * 2 + 1), -1);
const startIndex = inlineRange.index - annotatedText.length - 1;
undoManager.stopCapturing();
inlineEditor.formatText(
{
index: startIndex,
length: annotatedText.length,
},
{
italic: true,
}
);
inlineEditor.deleteText({
index: inlineRange.index - 2,
length: 2,
});
inlineEditor.deleteText({
index: startIndex,
length: 1,
});
inlineEditor.setInlineRange({
index: startIndex + annotatedText.length - 2,
length: 0,
});
},
});
export const StrikethroughExtension =
InlineMarkdownExtension<AffineTextAttributes>({
name: 'strikethrough',
pattern: /.*~{2}([^\s][^~]*[^\s])~{2}\s$|.*~{2}([^\s~])~{2}\s$/,
action: ({
inlineEditor,
prefixText,
inlineRange,
pattern,
undoManager,
}) => {
const match = prefixText.match(pattern);
if (!match) return;
const targetText = match[1] ?? match[2];
const annotatedText = match[0].slice(
-targetText.length - (2 * 2 + 1),
-1
);
const startIndex = inlineRange.index - annotatedText.length - 1;
undoManager.stopCapturing();
inlineEditor.formatText(
{
index: startIndex,
length: annotatedText.length,
},
{
strike: true,
}
);
inlineEditor.deleteText({
index: inlineRange.index - 3,
length: 3,
});
inlineEditor.deleteText({
index: startIndex,
length: 2,
});
inlineEditor.setInlineRange({
index: startIndex + annotatedText.length - 4,
length: 0,
});
},
});
export const UnderthroughExtension =
InlineMarkdownExtension<AffineTextAttributes>({
name: 'underthrough',
pattern: /.*~{1}([^\s][^~]*[^\s~])~{1}\s$|.*~{1}([^\s~])~{1}\s$/,
action: ({
inlineEditor,
prefixText,
inlineRange,
pattern,
undoManager,
}) => {
const match = prefixText.match(pattern);
if (!match) return;
const targetText = match[1] ?? match[2];
const annotatedText = match[0].slice(
-(targetText.length + 1 * 2 + 1),
-1
);
const startIndex = inlineRange.index - annotatedText.length - 1;
undoManager.stopCapturing();
inlineEditor.formatText(
{
index: startIndex,
length: annotatedText.length,
},
{
underline: true,
}
);
inlineEditor.deleteText({
index: inlineRange.index - 2,
length: 2,
});
inlineEditor.deleteText({
index: startIndex,
length: 1,
});
inlineEditor.setInlineRange({
index: startIndex + annotatedText.length - 2,
length: 0,
});
},
});
export const CodeExtension = InlineMarkdownExtension<AffineTextAttributes>({
name: 'code',
pattern: /.*`([^\s][^`]*[^\s])`\s$|.*`([^\s`])`\s$/,
action: ({ inlineEditor, prefixText, inlineRange, pattern, undoManager }) => {
const match = prefixText.match(pattern);
if (!match) return;
const targetText = match[1] ?? match[2];
const annotatedText = match[0].slice(-(targetText.length + 1 * 2 + 1), -1);
const startIndex = inlineRange.index - annotatedText.length - 1;
undoManager.stopCapturing();
inlineEditor.formatText(
{
index: startIndex,
length: annotatedText.length,
},
{
code: true,
}
);
inlineEditor.deleteText({
index: inlineRange.index - 2,
length: 2,
});
inlineEditor.deleteText({
index: startIndex,
length: 1,
});
inlineEditor.setInlineRange({
index: startIndex + annotatedText.length - 2,
length: 0,
});
},
});
export const MarkdownExtensions: ExtensionType[] = [
BoldItalicMarkdown,
BoldMarkdown,
ItalicExtension,
StrikethroughExtension,
UnderthroughExtension,
CodeExtension,
LatexExtension,
];

View File

@@ -0,0 +1,35 @@
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';
export class AffineText extends ShadowlessElement {
override render() {
const style = this.delta.attributes
? affineTextStyles(this.delta.attributes)
: {};
// we need to avoid \n appearing before and after the span element, which will
// cause the unexpected space
if (this.delta.attributes?.code) {
return html`<code style=${styleMap(style)}
><v-text .str=${this.delta.insert}></v-text
></code>`;
}
// we need to avoid \n appearing before and after the span element, which will
// cause the unexpected space
return html`<span style=${styleMap(style)}
><v-text .str=${this.delta.insert}></v-text
></span>`;
}
@property({ type: Object })
accessor delta: DeltaInsert<AffineTextAttributes> = {
insert: ZERO_WIDTH_FOR_EMPTY_LINE,
};
}

View File

@@ -0,0 +1,15 @@
import {
type StoreExtensionContext,
StoreExtensionProvider,
} from '@blocksuite/affine-ext-loader';
import { InlineAdapterExtensions } from './adapters/extensions';
export class InlinePresetStoreExtension extends StoreExtensionProvider {
override name = 'affine-inline-preset';
override setup(context: StoreExtensionContext) {
super.setup(context);
context.register(InlineAdapterExtensions);
}
}

View File

@@ -0,0 +1,25 @@
import {
type ViewExtensionContext,
ViewExtensionProvider,
} from '@blocksuite/affine-ext-loader';
import { DefaultInlineManagerExtension } from './default-inline-manager';
import { effects } from './effects';
import { InlineSpecExtensions } from './inline-spec';
import { MarkdownExtensions } from './markdown';
export class InlinePresetViewExtension extends ViewExtensionProvider {
override name = 'affine-inline-preset';
override effect() {
super.effect();
effects();
}
override setup(context: ViewExtensionContext) {
super.setup(context);
context.register(DefaultInlineManagerExtension);
context.register(InlineSpecExtensions);
context.register(MarkdownExtensions);
}
}

View File

@@ -0,0 +1,24 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src"],
"references": [
{ "path": "../../components" },
{ "path": "../../ext-loader" },
{ "path": "../comment" },
{ "path": "../footnote" },
{ "path": "../latex" },
{ "path": "../link" },
{ "path": "../mention" },
{ "path": "../reference" },
{ "path": "../../model" },
{ "path": "../../rich-text" },
{ "path": "../../shared" },
{ "path": "../../../framework/std" },
{ "path": "../../../framework/store" }
]
}