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,39 @@
{
"name": "@blocksuite/affine-inline-comment",
"description": "Inline comment for BlockSuite.",
"type": "module",
"scripts": {
"build": "yarn run -T tsc"
},
"sideEffects": false,
"keywords": [],
"author": "toeverything",
"license": "MIT",
"dependencies": {
"@blocksuite/affine-ext-loader": "workspace:*",
"@blocksuite/affine-rich-text": "workspace:*",
"@blocksuite/affine-shared": "workspace:*",
"@blocksuite/global": "workspace:*",
"@blocksuite/std": "workspace:*",
"@blocksuite/store": "workspace:*",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lit-html": "^3.2.1",
"lodash-es": "^4.17.23",
"zod": "^3.25.76"
},
"exports": {
".": "./src/index.ts",
"./view": "./src/view.ts",
"./store": "./src/store.ts"
},
"files": [
"src",
"dist",
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.27.5"
}

View File

@@ -0,0 +1,11 @@
import { InlineComment } from './inline-comment';
export function effects() {
customElements.define('inline-comment', InlineComment);
}
declare global {
interface HTMLElementTagNameMap {
'inline-comment': InlineComment;
}
}

View File

@@ -0,0 +1,4 @@
export { InlineCommentManager } from './inline-comment-manager';
export * from './inline-spec';
export * from './utils';
export * from './view';

View File

@@ -0,0 +1,230 @@
import { getInlineEditorByModel } from '@blocksuite/affine-rich-text';
import { getSelectedBlocksCommand } from '@blocksuite/affine-shared/commands';
import {
BlockElementCommentManager,
type CommentId,
CommentProviderIdentifier,
findAllCommentedBlocks,
findAllCommentedElements,
} from '@blocksuite/affine-shared/services';
import type { AffineInlineEditor } from '@blocksuite/affine-shared/types';
import { DisposableGroup } from '@blocksuite/global/disposable';
import {
LifeCycleWatcher,
type TextRangePoint,
TextSelection,
} from '@blocksuite/std';
import type { BaseSelection, BlockModel } from '@blocksuite/store';
import { signal } from '@preact/signals-core';
import difference from 'lodash-es/difference';
import {
extractCommentIdFromDelta,
findAllCommentedTexts,
findCommentedTexts,
} from './utils';
export class InlineCommentManager extends LifeCycleWatcher {
static override key = 'inline-comment-manager';
private readonly _disposables = new DisposableGroup();
private readonly _highlightedCommentId$ = signal<CommentId | null>(null);
private get _provider() {
return this.std.getOptional(CommentProviderIdentifier);
}
override mounted() {
const provider = this._provider;
if (!provider) return;
this._init().catch(console.error);
this._disposables.add(provider.onCommentAdded(this._handleAddComment));
this._disposables.add(
provider.onCommentDeleted(id =>
this._handleDeleteAndResolve(id, 'delete')
)
);
this._disposables.add(
provider.onCommentResolved(id =>
this._handleDeleteAndResolve(id, 'resolve')
)
);
this._disposables.add(
provider.onCommentHighlighted(this._handleHighlightComment)
);
this._disposables.add(
this.std.selection.slots.changed.subscribe(this._handleSelectionChanged)
);
}
override unmounted() {
this._disposables.dispose();
}
private async _init() {
const provider = this._provider;
if (!provider) return;
const commentsInProvider = await provider.getComments('all');
const commentsInEditor = this.getCommentsInEditor();
// remove comments that are in editor but not in provider
// which means the comment may be removed or resolved in provider side
difference(commentsInEditor, commentsInProvider).forEach(comment => {
this.std
.get(BlockElementCommentManager)
.handleDeleteAndResolve(comment, 'delete');
});
}
getCommentsInEditor() {
const inlineComments = [...findAllCommentedTexts(this.std.store).values()];
const blockComments = findAllCommentedBlocks(this.std.store).flatMap(
block => Object.keys(block.props.comments)
);
const surfaceComments = findAllCommentedElements(this.std.store).flatMap(
element => Object.keys(element.comments)
);
const commentsInEditor = [
...new Set([...inlineComments, ...blockComments, ...surfaceComments]),
];
return commentsInEditor;
}
private readonly _handleAddComment = (
id: CommentId,
selections: BaseSelection[]
) => {
const needCommentTexts = selections.flatMap(selection => {
if (!selection.is(TextSelection)) return [];
const [_, { selectedBlocks }] = this.std.command
.chain()
.pipe(getSelectedBlocksCommand, {
textSelection: selection,
})
.run();
if (!selectedBlocks) return [];
type MakeRequired<T, K extends keyof T> = T & {
[key in K]: NonNullable<T[key]>;
};
return selectedBlocks
.map(
({ model }) =>
[model, getInlineEditorByModel(this.std, model)] as const
)
.filter(
(
pair
): pair is [MakeRequired<BlockModel, 'text'>, AffineInlineEditor] =>
!!pair[0].text && !!pair[1]
)
.map(([model, inlineEditor]) => {
let from: TextRangePoint;
let to: TextRangePoint | null;
if (model.id === selection.from.blockId) {
from = selection.from;
to = null;
} else if (model.id === selection.to?.blockId) {
from = selection.to;
to = null;
} else {
from = {
blockId: model.id,
index: 0,
length: model.text.yText.length,
};
to = null;
}
return [new TextSelection({ from, to }), inlineEditor] as const;
});
});
if (needCommentTexts.length === 0) return;
needCommentTexts.forEach(([selection, inlineEditor]) => {
inlineEditor.formatText(
selection.from,
{
[`comment-${id}`]: true,
},
{
withoutTransact: true,
}
);
});
};
private readonly _handleDeleteAndResolve = (
id: CommentId,
type: 'delete' | 'resolve'
) => {
const commentedTexts = findCommentedTexts(this.std.store, id);
if (commentedTexts.length === 0) return;
this.std.store.withoutTransact(() => {
commentedTexts.forEach(selection => {
const inlineEditor = getInlineEditorByModel(
this.std,
selection.from.blockId
);
inlineEditor?.formatText(
selection.from,
{
[`comment-${id}`]: type === 'delete' ? null : false,
},
{
withoutTransact: true,
}
);
});
});
};
private readonly _handleHighlightComment = (id: CommentId | null) => {
this._highlightedCommentId$.value = id;
};
private readonly _handleSelectionChanged = (selections: BaseSelection[]) => {
const currentHighlightedCommentId = this._highlightedCommentId$.peek();
if (selections.length === 1) {
const selection = selections[0];
// InlineCommentManager only handle text selection
if (!selection.is(TextSelection)) return;
if (!selection.isCollapsed() && currentHighlightedCommentId !== null) {
this._provider?.highlightComment(null);
return;
}
const model = this.std.store.getModelById(selection.from.blockId);
if (!model) return;
const inlineEditor = getInlineEditorByModel(this.std, model);
if (!inlineEditor) return;
const delta = inlineEditor.getDeltaByRangeIndex(selection.from.index);
if (!delta) return;
const commentIds = extractCommentIdFromDelta(delta);
if (commentIds.length !== 0) return;
}
if (currentHighlightedCommentId !== null) {
this._provider?.highlightComment(null);
}
};
}

View File

@@ -0,0 +1,112 @@
import {
type CommentId,
CommentProviderIdentifier,
} from '@blocksuite/affine-shared/services';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { WithDisposable } from '@blocksuite/global/lit';
import {
type BlockStdScope,
PropTypes,
requiredProperties,
ShadowlessElement,
stdContext,
} from '@blocksuite/std';
import { consume } from '@lit/context';
import { css, type PropertyValues } from 'lit';
import { property, state } from 'lit/decorators.js';
import { html } from 'lit-html';
import { isEqual } from 'lodash-es';
@requiredProperties({
commentIds: PropTypes.arrayOf(id => typeof id === 'string'),
})
export class InlineComment extends WithDisposable(ShadowlessElement) {
static override styles = css`
inline-comment {
display: inline;
}
inline-comment.unresolved {
background-color: ${unsafeCSSVarV2('block/comment/highlightDefault')};
border-bottom: 2px solid
${unsafeCSSVarV2('block/comment/highlightUnderline')};
}
inline-comment.highlighted {
background-color: ${unsafeCSSVarV2('block/comment/highlightActive')};
}
`;
@property({
attribute: false,
hasChanged: (newVal: string[], oldVal: string[]) =>
!isEqual(newVal, oldVal),
})
accessor commentIds!: string[];
@property({ attribute: false })
accessor unresolved = false;
private _index: number = 0;
@consume({ context: stdContext })
private accessor _std!: BlockStdScope;
@state()
accessor highlighted = false;
private get _provider() {
return this._std.getOptional(CommentProviderIdentifier);
}
private readonly _handleClick = () => {
if (this.unresolved) {
this._provider?.highlightComment(this.commentIds[this._index]);
this._index = (this._index + 1) % this.commentIds.length;
}
};
private readonly _handleHighlight = (id: CommentId | null) => {
if (this.highlighted) {
if (!id || !this.commentIds.includes(id)) {
this.highlighted = false;
}
} else {
if (id && this.commentIds.includes(id)) {
this.highlighted = true;
}
}
};
override connectedCallback() {
super.connectedCallback();
const provider = this._provider;
if (provider) {
this.disposables.addFromEvent(this, 'click', this._handleClick);
this.disposables.add(
provider.onCommentHighlighted(this._handleHighlight)
);
}
}
override willUpdate(_changedProperties: PropertyValues<this>) {
if (_changedProperties.has('highlighted')) {
if (this.highlighted) {
this.classList.add('highlighted');
} else {
this.classList.remove('highlighted');
}
}
if (_changedProperties.has('unresolved')) {
if (this.unresolved) {
this.classList.add('unresolved');
} else {
this.classList.remove('unresolved');
}
}
}
override render() {
return html`<slot></slot>`;
}
}

View File

@@ -0,0 +1,59 @@
import { type CommentId } from '@blocksuite/affine-shared/services';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { dynamicSchema, InlineSpecExtension } from '@blocksuite/std/inline';
import { html, nothing } from 'lit-html';
import { when } from 'lit-html/directives/when.js';
import { z } from 'zod';
import { extractCommentIdFromDelta } from './utils';
type InlineCommendId = `comment-${CommentId}`;
function isInlineCommendId(key: string): key is InlineCommendId {
return key.startsWith('comment-');
}
export const CommentInlineSpecExtension =
InlineSpecExtension<AffineTextAttributes>({
name: 'comment',
schema: dynamicSchema(
isInlineCommendId,
z.boolean().optional().nullable().catch(undefined)
),
match: delta => {
if (!delta.attributes) return false;
const comments = Object.keys(delta.attributes).filter(isInlineCommendId);
return comments.length > 0;
},
renderer: ({ delta, children }) => {
if (!delta.attributes) return html`${nothing}`;
const unresolved = Object.entries(delta.attributes).some(
([key, value]) => isInlineCommendId(key) && value === true
);
return html`<inline-comment
.unresolved=${unresolved}
.commentIds=${extractCommentIdFromDelta(delta)}
>${when(
children,
() => html`${children}`,
() => nothing
)}</inline-comment
>`;
},
wrapper: true,
});
export const NullCommentInlineSpecExtension =
InlineSpecExtension<AffineTextAttributes>({
name: 'comment',
schema: dynamicSchema(
isInlineCommendId,
z.boolean().optional().nullable().catch(undefined)
),
match: () => false,
renderer: () => html``,
});
// reuse the same identifier
NullCommentInlineSpecExtension.identifier =
CommentInlineSpecExtension.identifier;

View File

@@ -0,0 +1,64 @@
import type { CommentId } from '@blocksuite/affine-shared/services';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { TextSelection } from '@blocksuite/std';
import type { DeltaInsert, Store } from '@blocksuite/store';
export function findAllCommentedTexts(
store: Store
): Map<TextSelection, CommentId> {
const result = new Map<TextSelection, CommentId>();
store.getAllModels().forEach(model => {
if (!model.text) return;
let index = 0;
model.text.toDelta().forEach(delta => {
if (!delta.insert) return;
const length = delta.insert.length;
if (!delta.attributes) {
index += length;
return;
}
Object.keys(delta.attributes)
.filter(key => key.startsWith('comment-'))
.forEach(key => {
const commentId = key.replace('comment-', '');
const selection = new TextSelection({
from: {
blockId: model.id,
index,
length,
},
to: null,
});
result.set(selection, commentId);
});
index += length;
});
});
return result;
}
export function findCommentedTexts(
store: Store,
commentId: CommentId
): TextSelection[] {
return [...findAllCommentedTexts(store).entries()]
.filter(([_, id]) => id === commentId)
.map(([selection]) => selection);
}
export function extractCommentIdFromDelta(
delta: DeltaInsert<AffineTextAttributes>
) {
if (!delta.attributes) return [];
return Object.keys(delta.attributes)
.filter(key => key.startsWith('comment-'))
.map(key => key.replace('comment-', ''));
}

View File

@@ -0,0 +1,42 @@
import {
type ViewExtensionContext,
ViewExtensionProvider,
} from '@blocksuite/affine-ext-loader';
import z from 'zod';
import { effects } from './effects';
import { InlineCommentManager } from './inline-comment-manager';
import {
CommentInlineSpecExtension,
NullCommentInlineSpecExtension,
} from './inline-spec';
const optionsSchema = z.object({
enabled: z.boolean().optional().default(true),
});
export class InlineCommentViewExtension extends ViewExtensionProvider<
z.infer<typeof optionsSchema>
> {
override name = 'affine-inline-comment';
override schema = optionsSchema;
override effect(): void {
super.effect();
effects();
}
override setup(
context: ViewExtensionContext,
options?: z.infer<typeof optionsSchema>
) {
super.setup(context, options);
context.register([
options?.enabled
? CommentInlineSpecExtension
: NullCommentInlineSpecExtension,
InlineCommentManager,
]);
}
}

View File

@@ -0,0 +1,17 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src"],
"references": [
{ "path": "../../ext-loader" },
{ "path": "../../rich-text" },
{ "path": "../../shared" },
{ "path": "../../../framework/global" },
{ "path": "../../../framework/std" },
{ "path": "../../../framework/store" }
]
}

View File

@@ -0,0 +1,44 @@
{
"name": "@blocksuite/affine-inline-footnote",
"description": "Inline footnote 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-model": "workspace:*",
"@blocksuite/affine-shared": "workspace:*",
"@blocksuite/global": "workspace:*",
"@blocksuite/std": "workspace:*",
"@blocksuite/store": "workspace:*",
"@floating-ui/dom": "^1.6.13",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.23",
"lit": "^3.2.0",
"lit-html": "^3.2.1",
"zod": "^3.25.76"
},
"devDependencies": {
"@vitest/browser-playwright": "^4.1.10",
"playwright": "=1.58.2",
"vitest": "^4.1.8"
},
"exports": {
".": "./src/index.ts",
"./view": "./src/view.ts",
"./store": "./src/store.ts"
},
"files": [
"src",
"dist",
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.27.5"
}

View File

@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest';
import { preprocessFootnoteReference } from '../../adapters/markdown/preprocessor';
describe('FootnoteReferenceMarkdownPreprocessorExtension', () => {
it('should add space before footnote reference when it follows a URL', () => {
const content = 'https://example.com[^label]';
const expected = 'https://example.com [^label]';
expect(preprocessFootnoteReference(content)).toBe(expected);
});
it('should add space before footnote reference when URL has text prefix with space', () => {
const content = 'hello world https://example.com[^label]';
const expected = 'hello world https://example.com [^label]';
expect(preprocessFootnoteReference(content)).toBe(expected);
});
it('should add space before footnote reference when URL has text prefix with dash', () => {
const content = 'text-https://example.com[^label]';
const expected = 'text-https://example.com [^label]';
expect(preprocessFootnoteReference(content)).toBe(expected);
});
it('should not add space when footnote reference follows non-URL text', () => {
const content = 'normal text[^label]';
const expected = 'normal text[^label]';
expect(preprocessFootnoteReference(content)).toBe(expected);
});
it('should not add space when there is already a space before footnote reference', () => {
const content = 'https://example.com [^label]';
const expected = 'https://example.com [^label]';
expect(preprocessFootnoteReference(content)).toBe(expected);
});
it('should handle multiple footnote references with mixed URL and non-URL text', () => {
const content = 'https://example.com[^1]normal text[^2]http://test.com[^3]';
const expected =
'https://example.com [^1]normal text[^2]http://test.com [^3]';
expect(preprocessFootnoteReference(content)).toBe(expected);
});
it('should not modify footnote definitions', () => {
const content = '[^label]: This is a footnote definition';
const expected = '[^label]: This is a footnote definition';
expect(preprocessFootnoteReference(content)).toBe(expected);
});
it('should handle content without footnote references', () => {
const content = 'This is a normal text without any footnotes';
const expected = 'This is a normal text without any footnotes';
expect(preprocessFootnoteReference(content)).toBe(expected);
});
it('should handle complex URLs with paths and parameters', () => {
const content = 'https://example.com/path?param=value[^label]';
const expected = 'https://example.com/path?param=value [^label]';
expect(preprocessFootnoteReference(content)).toBe(expected);
});
it('should handle invalid URLs', () => {
const content = 'not-a-url[^label]';
const expected = 'not-a-url[^label]';
expect(preprocessFootnoteReference(content)).toBe(expected);
});
});

View File

@@ -0,0 +1,3 @@
export * from './markdown/inline-delta';
export * from './markdown/markdown-inline';
export * from './markdown/preprocessor';

View File

@@ -0,0 +1,50 @@
import {
FOOTNOTE_DEFINITION_PREFIX,
InlineDeltaToMarkdownAdapterExtension,
} from '@blocksuite/affine-shared/adapters';
import type { PhrasingContent } from 'mdast';
export const footnoteReferenceDeltaToMarkdownAdapterMatcher =
InlineDeltaToMarkdownAdapterExtension({
name: 'footnote-reference',
match: delta => !!delta.attributes?.footnote,
toAST: (delta, context) => {
const mdast: PhrasingContent = {
type: 'text',
value: delta.insert,
};
const footnote = delta.attributes?.footnote;
if (!footnote) {
return mdast;
}
const footnoteDefinitionKey = `${FOOTNOTE_DEFINITION_PREFIX}${footnote.label}`;
const { configs } = context;
// FootnoteReference should be paired with FootnoteDefinition
// If the footnoteDefinition is not in the configs, set it to configs
// We should add the footnoteDefinition markdown ast nodes to tree after all the footnoteReference markdown ast nodes are added
if (!configs.has(footnoteDefinitionKey)) {
// clone the footnote reference
const clonedFootnoteReference = { ...footnote.reference };
// If the footnote reference contains url, encode it
if (clonedFootnoteReference.url) {
clonedFootnoteReference.url = encodeURIComponent(
clonedFootnoteReference.url
);
}
if (clonedFootnoteReference.favicon) {
clonedFootnoteReference.favicon = encodeURIComponent(
clonedFootnoteReference.favicon
);
}
configs.set(
footnoteDefinitionKey,
JSON.stringify(clonedFootnoteReference)
);
}
return {
type: 'footnoteReference',
label: footnote.label,
identifier: footnote.label,
};
},
});

View File

@@ -0,0 +1,57 @@
import { FootNoteReferenceParamsSchema } from '@blocksuite/affine-model';
import {
FOOTNOTE_DEFINITION_PREFIX,
MarkdownASTToDeltaExtension,
} from '@blocksuite/affine-shared/adapters';
export const markdownFootnoteReferenceToDeltaMatcher =
MarkdownASTToDeltaExtension({
name: 'footnote-reference',
match: ast => ast.type === 'footnoteReference',
toDelta: (ast, context) => {
if (ast.type !== 'footnoteReference') {
return [];
}
try {
const { configs } = context;
const footnoteDefinitionKey = `${FOOTNOTE_DEFINITION_PREFIX}${ast.identifier}`;
const footnoteDefinition = configs.get(footnoteDefinitionKey);
if (!footnoteDefinition) {
return [];
}
const footnoteDefinitionJson = JSON.parse(footnoteDefinition);
// If the footnote definition contains url, decode it
if (footnoteDefinitionJson.url) {
footnoteDefinitionJson.url = decodeURIComponent(
footnoteDefinitionJson.url
);
}
if (footnoteDefinitionJson.favicon) {
footnoteDefinitionJson.favicon = decodeURIComponent(
footnoteDefinitionJson.favicon
);
}
const footnoteReference = FootNoteReferenceParamsSchema.parse(
footnoteDefinitionJson
);
// If the footnote reference is an attachment, and the file type is not set,
// Try to infer the file type from the file name.
const { type: referenceType, fileName, fileType } = footnoteReference;
if (referenceType === 'attachment' && fileName && !fileType) {
const ext = fileName.split('.').pop()?.toLowerCase();
if (ext) {
footnoteReference.fileType = ext;
}
}
const footnote = {
label: ast.identifier,
reference: footnoteReference,
};
return [{ insert: ' ', attributes: { footnote } }];
} catch {
return [];
}
},
});

View File

@@ -0,0 +1,54 @@
import {
type MarkdownAdapterPreprocessor,
MarkdownPreprocessorExtension,
} from '@blocksuite/affine-shared/adapters';
/**
* Check if a string is a URL
* @param str
* @returns
*/
function isUrl(str: string): boolean {
try {
new URL(str);
return true;
} catch {
return false;
}
}
/**
* Preprocess footnote references to avoid markdown link parsing
* Only add space when footnote reference follows a URL
* @param content
* @returns
* @example
* ```md
* https://example.com[^label] -> https://example.com [^label]
* normal text[^label] -> normal text[^label]
* ```
*/
export function preprocessFootnoteReference(content: string) {
return content.replace(
/([^\s]+?)(\[\^[^\]]+\])(?!:)/g,
(match, prevText, footnoteRef) => {
// Only add space if the previous text is a URL
if (isUrl(prevText)) {
return prevText + ' ' + footnoteRef;
}
// Otherwise return the original match
return match;
}
);
}
const footnoteReferencePreprocessor: MarkdownAdapterPreprocessor = {
name: 'footnote-reference',
levels: ['block', 'slice', 'doc'],
preprocess: content => {
return preprocessFootnoteReference(content);
},
};
export const FootnoteReferenceMarkdownPreprocessorExtension =
MarkdownPreprocessorExtension(footnoteReferencePreprocessor);

View File

@@ -0,0 +1,17 @@
import { AffineFootnoteNode } from './footnote-node/footnote-node';
import { FootNotePopup } from './footnote-node/footnote-popup';
import { FootNotePopupChip } from './footnote-node/footnote-popup-chip';
export function effects() {
customElements.define('affine-footnote-node', AffineFootnoteNode);
customElements.define('footnote-popup', FootNotePopup);
customElements.define('footnote-popup-chip', FootNotePopupChip);
}
declare global {
interface HTMLElementTagNameMap {
'affine-footnote-node': AffineFootnoteNode;
'footnote-popup': FootNotePopup;
'footnote-popup-chip': FootNotePopupChip;
}
}

View File

@@ -0,0 +1,120 @@
import type { FootNote } from '@blocksuite/affine-model';
import { createIdentifier } from '@blocksuite/global/di';
import { type BlockStdScope, StdIdentifier } from '@blocksuite/std';
import type { ExtensionType } from '@blocksuite/store';
import type { TemplateResult } from 'lit';
type FootNoteNodeRenderer = (
footnote: FootNote,
std: BlockStdScope
) => TemplateResult<1>;
type FootNotePopupRenderer = (
footnote: FootNote,
std: BlockStdScope,
abortController: AbortController
) => TemplateResult<1>;
export type FootNotePopupClickHandler = (
footnote: FootNote,
abortController: AbortController
) => void;
export interface FootNoteNodeConfig {
customNodeRenderer?: FootNoteNodeRenderer;
customPopupRenderer?: FootNotePopupRenderer;
interactive?: boolean;
hidePopup?: boolean;
disableHoverEffect?: boolean;
onPopupClick?: FootNotePopupClickHandler;
}
export class FootNoteNodeConfigProvider {
private _customNodeRenderer?: FootNoteNodeRenderer;
private _customPopupRenderer?: FootNotePopupRenderer;
private _hidePopup: boolean;
private _interactive: boolean;
private _disableHoverEffect: boolean;
private _onPopupClick?: FootNotePopupClickHandler;
get customNodeRenderer() {
return this._customNodeRenderer;
}
get customPopupRenderer() {
return this._customPopupRenderer;
}
get onPopupClick() {
return this._onPopupClick;
}
get doc() {
return this.std.store;
}
get hidePopup() {
return this._hidePopup;
}
get interactive() {
return this._interactive;
}
get disableHoverEffect() {
return this._disableHoverEffect;
}
constructor(
config: FootNoteNodeConfig,
readonly std: BlockStdScope
) {
this._customNodeRenderer = config.customNodeRenderer;
this._customPopupRenderer = config.customPopupRenderer;
this._hidePopup = config.hidePopup ?? false;
this._interactive = config.interactive ?? true;
this._disableHoverEffect = config.disableHoverEffect ?? false;
this._onPopupClick = config.onPopupClick;
}
setCustomNodeRenderer(renderer: FootNoteNodeRenderer) {
this._customNodeRenderer = renderer;
}
setCustomPopupRenderer(renderer: FootNotePopupRenderer) {
this._customPopupRenderer = renderer;
}
setHidePopup(hidePopup: boolean) {
this._hidePopup = hidePopup;
}
setInteractive(interactive: boolean) {
this._interactive = interactive;
}
setDisableHoverEffect(disableHoverEffect: boolean) {
this._disableHoverEffect = disableHoverEffect;
}
setPopupClick(onPopupClick: FootNotePopupClickHandler) {
this._onPopupClick = onPopupClick;
}
}
export const FootNoteNodeConfigIdentifier =
createIdentifier<FootNoteNodeConfigProvider>('AffineFootNoteNodeConfig');
export function FootNoteNodeConfigExtension(
config: FootNoteNodeConfig
): ExtensionType {
return {
setup: di => {
di.addImpl(
FootNoteNodeConfigIdentifier,
provider =>
new FootNoteNodeConfigProvider(config, provider.get(StdIdentifier))
);
},
};
}

View File

@@ -0,0 +1,284 @@
import { HoverController } from '@blocksuite/affine-components/hover';
import { PeekViewProvider } from '@blocksuite/affine-components/peek';
import type { FootNote } from '@blocksuite/affine-model';
import { CitationProvider } from '@blocksuite/affine-shared/services';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { isValidUrl, normalizeUrl } from '@blocksuite/affine-shared/utils';
import { WithDisposable } from '@blocksuite/global/lit';
import {
BlockSelection,
type BlockStdScope,
ShadowlessElement,
TextSelection,
} from '@blocksuite/std';
import {
INLINE_ROOT_ATTR,
type InlineRootElement,
ZERO_WIDTH_FOR_EMBED_NODE,
ZERO_WIDTH_FOR_EMPTY_LINE,
} from '@blocksuite/std/inline';
import type { DeltaInsert } from '@blocksuite/store';
import { flip, offset, shift } from '@floating-ui/dom';
import { baseTheme } from '@toeverything/theme';
import { css, html, nothing, unsafeCSS } from 'lit';
import { property } from 'lit/decorators.js';
import { classMap } from 'lit-html/directives/class-map.js';
import { ref } from 'lit-html/directives/ref.js';
import type { FootNoteNodeConfigProvider } from './footnote-config';
// Virtual padding for the footnote popup overflow detection offsets.
const POPUP_SHIFT_PADDING = 8;
// The offset between the footnote node and the popup.
const POPUP_OFFSET = 4;
export class AffineFootnoteNode extends WithDisposable(ShadowlessElement) {
static override styles = css`
.footnote-node {
padding: 0 2px;
user-select: none;
cursor: pointer;
}
.footnote-node {
.footnote-content-default {
display: inline-block;
background: ${unsafeCSSVarV2('block/footnote/numberBgHover')};
color: ${unsafeCSSVarV2('button/pureWhiteText')};
width: 14px;
height: 14px;
line-height: 14px;
font-size: 10px;
font-weight: 400;
border-radius: 50%;
text-align: center;
text-overflow: ellipsis;
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
transition: background 0.3s ease-in-out;
transform: translateY(-0.2em);
}
}
.footnote-node.hover-effect {
.footnote-content-default {
color: var(--affine-text-primary-color);
background: ${unsafeCSSVarV2('block/footnote/numberBg')};
}
}
.footnote-node.hover-effect:hover {
.footnote-content-default {
color: ${unsafeCSSVarV2('button/pureWhiteText')};
background: ${unsafeCSSVarV2('block/footnote/numberBgHover')};
}
}
`;
get customNodeRenderer() {
return this.config?.customNodeRenderer;
}
get customPopupRenderer() {
return this.config?.customPopupRenderer;
}
get interactive() {
return this.config?.interactive;
}
get hidePopup() {
return this.config?.hidePopup;
}
get disableHoverEffect() {
return this.config?.disableHoverEffect;
}
get onPopupClick() {
return this.config?.onPopupClick;
}
get inlineEditor() {
const inlineRoot = this.closest<InlineRootElement<AffineTextAttributes>>(
`[${INLINE_ROOT_ATTR}]`
);
return inlineRoot?.inlineEditor;
}
get selfInlineRange() {
const selfInlineRange = this.inlineEditor?.getInlineRangeFromElement(this);
return selfInlineRange;
}
get footnote() {
return this.delta.attributes?.footnote;
}
get readonly() {
return this.std.store.readonly;
}
get citationService() {
return this.std.get(CitationProvider);
}
onFootnoteClick = () => {
if (!this.footnote) {
return;
}
const { type, docId, url } = this.footnote.reference;
switch (type) {
case 'doc':
if (docId) {
this._handleDocReference(docId);
}
break;
case 'url':
if (url) {
this._handleUrlReference(url);
}
break;
}
};
private readonly _handleDocReference = (docId: string) => {
this.std
.getOptional(PeekViewProvider)
?.peek({
docId,
})
.catch(console.error);
};
private readonly _handleUrlReference = (url: string) => {
const normalizedUrl = normalizeUrl(url);
if (!normalizedUrl || !isValidUrl(normalizedUrl)) return;
window.open(normalizedUrl, '_blank', 'noopener,noreferrer');
};
private readonly _updateFootnoteAttributes = (footnote: FootNote) => {
if (!this.footnote || this.readonly) {
return;
}
if (!this.inlineEditor || !this.selfInlineRange) {
return;
}
this.inlineEditor.formatText(this.selfInlineRange, {
footnote: footnote,
});
};
private readonly _FootNoteDefaultContent = (footnote: FootNote) => {
const label =
footnote.label.match(/^(?:doc|attachment)-(\d+)$/)?.[1] ?? footnote.label;
return html`<span
class="footnote-content-default"
@click=${this.onFootnoteClick}
>${label}</span
>`;
};
private readonly _FootNotePopup = (
footnote: FootNote,
abortController: AbortController
) => {
return this.customPopupRenderer
? this.customPopupRenderer(footnote, this.std, abortController)
: html`<footnote-popup
.footnote=${footnote}
.std=${this.std}
.abortController=${abortController}
.onPopupClick=${this.onPopupClick ?? this.onFootnoteClick}
.updateFootnoteAttributes=${this._updateFootnoteAttributes}
></footnote-popup>`;
};
private readonly _whenHover: HoverController = new HoverController(
this,
({ abortController }) => {
const { footnote } = this;
if (!footnote) return null;
if (
this.config?.hidePopup ||
!this.selfInlineRange ||
!this.inlineEditor
) {
return null;
}
const selection = this.std?.selection;
if (!selection) {
return null;
}
const textSelection = selection.find(TextSelection);
if (!!textSelection && !textSelection.isCollapsed()) {
return null;
}
const blockSelections = selection.filter(BlockSelection);
if (blockSelections.length) {
return null;
}
this.citationService.trackEvent('Hover', {
control: 'Source Footnote',
});
return {
template: this._FootNotePopup(footnote, abortController),
container: this.std.host,
computePosition: {
referenceElement: this,
placement: 'top',
autoUpdate: true,
middleware: [
shift({ padding: POPUP_SHIFT_PADDING }),
flip(),
offset(POPUP_OFFSET),
],
},
};
},
{ enterDelay: 300 }
);
override render() {
const attributes = this.delta.attributes;
const footnote = attributes?.footnote;
if (!footnote) {
return nothing;
}
const node = this.customNodeRenderer
? this.customNodeRenderer(footnote, this.std)
: this._FootNoteDefaultContent(footnote);
const nodeClasses = classMap({
'footnote-node': true,
'hover-effect': !this.disableHoverEffect,
});
return html`<span
${this.hidePopup ? '' : ref(this._whenHover.setReference)}
class=${nodeClasses}
>${node}<v-text .str=${ZERO_WIDTH_FOR_EMBED_NODE}></v-text
></span>`;
}
@property({ attribute: false })
accessor config: FootNoteNodeConfigProvider | undefined = undefined;
@property({ type: Object })
accessor delta: DeltaInsert<AffineTextAttributes> = {
insert: ZERO_WIDTH_FOR_EMPTY_LINE,
attributes: {},
};
@property({ attribute: false })
accessor std!: BlockStdScope;
}

View File

@@ -0,0 +1,76 @@
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { baseTheme } from '@toeverything/theme';
import {
css,
html,
LitElement,
nothing,
type TemplateResult,
unsafeCSS,
} from 'lit';
import { property } from 'lit/decorators.js';
export class FootNotePopupChip extends LitElement {
static override styles = css`
.popup-chip-container {
display: flex;
height: 22px;
align-items: center;
gap: 8px;
box-sizing: border-box;
}
.prefix-icon {
display: flex;
align-items: center;
justify-content: center;
height: 16px;
width: 16px;
color: ${unsafeCSSVarV2('icon/primary')};
border-radius: 4px;
svg,
img {
width: 16px;
height: 16px;
fill: ${unsafeCSSVarV2('icon/primary')};
}
}
.popup-chip-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: left;
height: 22px;
line-height: 22px;
color: ${unsafeCSSVarV2('text/primary')};
font-size: var(--affine-font-sm);
font-weight: 500;
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
}
`;
override render() {
return html`
<div class="popup-chip-container">
${
this.prefixIcon
? html`<div class="prefix-icon">${this.prefixIcon}</div>`
: nothing
}
<div class="popup-chip-label" title=${this.tooltip}>${this.label}</div>
</div>
`;
}
@property({ attribute: false })
accessor prefixIcon: TemplateResult | undefined = undefined;
@property({ attribute: false })
accessor label: string = '';
@property({ attribute: false })
accessor tooltip: string = '';
}

View File

@@ -0,0 +1,227 @@
import {
getAttachmentFileIcon,
LoadingIcon,
WebIcon16,
} from '@blocksuite/affine-components/icons';
import type { FootNote } from '@blocksuite/affine-model';
import { ImageProxyService } from '@blocksuite/affine-shared/adapters';
import {
DocDisplayMetaProvider,
LinkPreviewServiceIdentifier,
} from '@blocksuite/affine-shared/services';
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import type { BlockStdScope } from '@blocksuite/std';
import { computed, signal } from '@preact/signals-core';
import { baseTheme } from '@toeverything/theme';
import { css, html, LitElement, nothing, unsafeCSS } from 'lit';
import { property } from 'lit/decorators.js';
import type { FootNotePopupClickHandler } from './footnote-config';
export class FootNotePopup extends SignalWatcher(WithDisposable(LitElement)) {
static override styles = css`
.footnote-popup-container {
border-radius: 8px;
box-shadow: ${unsafeCSSVar('overlayPanelShadow')};
background-color: ${unsafeCSSVarV2('layer/background/primary')};
border: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
max-width: 260px;
padding: 4px 8px;
display: flex;
flex-direction: column;
gap: 2px;
transition: 0.3s ease-in-out;
cursor: pointer;
}
.footnote-popup-description {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
font-size: var(--affine-font-xs);
font-style: normal;
font-weight: 400;
line-height: 20px;
height: 20px;
}
`;
private readonly _isLoading$ = signal(false);
private readonly _linkPreview$ = signal<{
favicon: string | undefined;
title?: string;
description?: string;
}>({
favicon: undefined,
title: undefined,
description: undefined,
});
private readonly _prefixIcon$ = computed(() => {
const referenceType = this.footnote.reference.type;
if (referenceType === 'doc') {
const docId = this.footnote.reference.docId;
if (!docId) {
return undefined;
}
return this.std.get(DocDisplayMetaProvider).icon(docId).value;
} else if (referenceType === 'attachment') {
const fileType = this.footnote.reference.fileType;
if (!fileType) {
return undefined;
}
return getAttachmentFileIcon(fileType);
} else if (referenceType === 'url') {
if (this._isLoading$.value) {
return LoadingIcon();
}
const favicon = this._linkPreview$.value?.favicon;
const imageSrc = favicon
? this.imageProxyService.buildUrl(favicon)
: undefined;
return imageSrc ? html`<img src=${imageSrc} alt="favicon" />` : WebIcon16;
}
return undefined;
});
private readonly _popupLabel$ = computed(() => {
const referenceType = this.footnote.reference.type;
let label = '';
const { docId, fileName, url } = this.footnote.reference;
switch (referenceType) {
case 'doc':
if (!docId) {
return label;
}
label = this.std.get(DocDisplayMetaProvider).title(docId).value;
break;
case 'attachment':
if (!fileName) {
return label;
}
label = fileName;
break;
case 'url':
if (!url) {
return label;
}
label = this._linkPreview$.value?.title ?? url;
break;
}
return label;
});
private readonly _tooltip$ = computed(() => {
const referenceType = this.footnote.reference.type;
if (referenceType === 'url') {
const title = this._linkPreview$.value?.title;
const url = this.footnote.reference.url;
return [title, url].filter(Boolean).join('\n') || '';
}
return this._popupLabel$.value;
});
private readonly _onClick = () => {
this.onPopupClick(this.footnote, this.abortController);
this.abortController.abort();
};
private readonly _initLinkPreviewData = () => {
this._linkPreview$.value = {
favicon: this.footnote.reference.favicon,
title: this.footnote.reference.title,
description: this.footnote.reference.description,
};
};
override connectedCallback() {
super.connectedCallback();
this._initLinkPreviewData();
// If the reference is a url, and the url exists
// and the link preview data is not already set, fetch the link preview data
const isTitleAndDescriptionEmpty =
!this._linkPreview$.value?.title &&
!this._linkPreview$.value?.description;
if (
this.footnote.reference.type === 'url' &&
this.footnote.reference.url &&
isTitleAndDescriptionEmpty
) {
this._isLoading$.value = true;
this.std
.get(LinkPreviewServiceIdentifier)
.query(this.footnote.reference.url)
.then(data => {
// update the local link preview data
this._linkPreview$.value = {
favicon: data.icon ?? undefined,
title: data.title ?? undefined,
description: data.description ?? undefined,
};
// update the footnote attributes in the node with the link preview data
// to avoid fetching the same data multiple times
const footnote: FootNote = {
...this.footnote,
reference: {
...this.footnote.reference,
...(data.icon && { favicon: data.icon }),
...(data.title && { title: data.title }),
...(data.description && { description: data.description }),
},
};
this.updateFootnoteAttributes(footnote);
})
.catch(console.error)
.finally(() => {
this._isLoading$.value = false;
});
}
}
override render() {
const description = this._linkPreview$.value?.description;
return html`
<div class="footnote-popup-container" @click=${this._onClick}>
<footnote-popup-chip
.prefixIcon=${this._prefixIcon$.value}
.label=${this._popupLabel$.value}
.tooltip=${this._tooltip$.value}
></footnote-popup-chip>
${
description
? html`
<div class="footnote-popup-description">${description}</div>
`
: nothing
}
</div>
`;
}
get imageProxyService() {
return this.std.get(ImageProxyService);
}
@property({ attribute: false })
accessor footnote!: FootNote;
@property({ attribute: false })
accessor std!: BlockStdScope;
@property({ attribute: false })
accessor abortController!: AbortController;
@property({ attribute: false })
accessor onPopupClick: FootNotePopupClickHandler | (() => void) = () => {};
@property({ attribute: false })
accessor updateFootnoteAttributes: (footnote: FootNote) => void = () => {};
}

View File

@@ -0,0 +1,4 @@
export * from './adapters';
export * from './footnote-node/footnote-config.js';
export { AffineFootnoteNode } from './footnote-node/footnote-node.js';
export * from './inline-spec';

View File

@@ -0,0 +1,32 @@
import { FootNoteSchema } from '@blocksuite/affine-model';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { StdIdentifier } from '@blocksuite/std';
import { InlineSpecExtension } from '@blocksuite/std/inline';
import { html } from 'lit';
import z from 'zod';
import { FootNoteNodeConfigIdentifier } from './footnote-node/footnote-config';
export const FootNoteInlineSpecExtension =
InlineSpecExtension<AffineTextAttributes>('footnote', provider => {
const std = provider.get(StdIdentifier);
const config =
provider.getOptional(FootNoteNodeConfigIdentifier) ?? undefined;
return {
name: 'footnote',
schema: z.object({
footnote: FootNoteSchema.optional().nullable().catch(undefined),
}),
match: delta => {
return !!delta.attributes?.footnote;
},
renderer: ({ delta }) => {
return html`<affine-footnote-node
.delta=${delta}
.std=${std}
.config=${config}
></affine-footnote-node>`;
},
embed: true,
};
});

View File

@@ -0,0 +1,21 @@
import {
type StoreExtensionContext,
StoreExtensionProvider,
} from '@blocksuite/affine-ext-loader';
import {
footnoteReferenceDeltaToMarkdownAdapterMatcher,
FootnoteReferenceMarkdownPreprocessorExtension,
markdownFootnoteReferenceToDeltaMatcher,
} from './adapters';
export class FootnoteStoreExtension extends StoreExtensionProvider {
override name = 'affine-footnote-inline';
override setup(context: StoreExtensionContext) {
super.setup(context);
context.register(markdownFootnoteReferenceToDeltaMatcher);
context.register(footnoteReferenceDeltaToMarkdownAdapterMatcher);
context.register(FootnoteReferenceMarkdownPreprocessorExtension);
}
}

View File

@@ -0,0 +1,21 @@
import {
type ViewExtensionContext,
ViewExtensionProvider,
} from '@blocksuite/affine-ext-loader';
import { effects } from './effects';
import { FootNoteInlineSpecExtension } from './inline-spec';
export class FootnoteViewExtension extends ViewExtensionProvider {
override name = 'affine-footnote-inline';
override effect(): void {
super.effect();
effects();
}
override setup(context: ViewExtensionContext) {
super.setup(context);
context.register(FootNoteInlineSpecExtension);
}
}

View File

@@ -0,0 +1,18 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src"],
"references": [
{ "path": "../../components" },
{ "path": "../../ext-loader" },
{ "path": "../../model" },
{ "path": "../../shared" },
{ "path": "../../../framework/global" },
{ "path": "../../../framework/std" },
{ "path": "../../../framework/store" }
]
}

View File

@@ -0,0 +1,25 @@
import { playwright } from '@vitest/browser-playwright';
import { defineConfig } from 'vitest/config';
export default defineConfig({
esbuild: {
target: 'es2018',
},
test: {
browser: {
enabled: true,
headless: true,
instances: [{ browser: 'chromium' }],
provider: playwright(),
isolate: false,
},
include: ['src/__tests__/**/*.unit.spec.ts'],
testTimeout: 500,
coverage: {
provider: 'istanbul',
reporter: ['lcov'],
reportsDirectory: '../../../.coverage/footnote',
},
restoreMocks: true,
},
});

View File

@@ -0,0 +1,42 @@
{
"name": "@blocksuite/affine-inline-latex",
"description": "Inline latex 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-model": "workspace:*",
"@blocksuite/affine-rich-text": "workspace:*",
"@blocksuite/affine-shared": "workspace:*",
"@blocksuite/global": "workspace:*",
"@blocksuite/icons": "^2.2.17",
"@blocksuite/std": "workspace:*",
"@blocksuite/store": "workspace:*",
"@preact/signals-core": "^1.8.0",
"katex": "^0.16.27",
"lit": "^3.2.0",
"remark-math": "^6.0.0",
"shiki": "^3.19.0",
"yjs": "^13.6.27",
"zod": "^3.25.76"
},
"exports": {
".": "./src/index.ts",
"./view": "./src/view.ts",
"./store": "./src/store.ts"
},
"files": [
"src",
"dist",
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.27.5"
}

View File

@@ -0,0 +1,3 @@
export * from './markdown/inline-delta';
export * from './markdown/markdown-inline';
export * from './plain-text/inline-delta';

View File

@@ -0,0 +1,21 @@
import { InlineDeltaToMarkdownAdapterExtension } from '@blocksuite/affine-shared/adapters';
import type { PhrasingContent } from 'mdast';
export const latexDeltaToMarkdownAdapterMatcher =
InlineDeltaToMarkdownAdapterExtension({
name: 'inlineLatex',
match: delta => !!delta.attributes?.latex,
toAST: delta => {
const mdast: PhrasingContent = {
type: 'text',
value: delta.insert,
};
if (delta.attributes?.latex) {
return {
type: 'inlineMath',
value: delta.attributes.latex,
};
}
return mdast;
},
});

View File

@@ -0,0 +1,12 @@
import { MarkdownASTToDeltaExtension } from '@blocksuite/affine-shared/adapters';
export const markdownInlineMathToDeltaMatcher = MarkdownASTToDeltaExtension({
name: 'inlineMath',
match: ast => ast.type === 'inlineMath',
toDelta: ast => {
if (!('value' in ast)) {
return [];
}
return [{ insert: ' ', attributes: { latex: ast.value } }];
},
});

View File

@@ -0,0 +1,21 @@
import {
InlineDeltaToPlainTextAdapterExtension,
type TextBuffer,
} from '@blocksuite/affine-shared/adapters';
export const latexDeltaMarkdownAdapterMatch =
InlineDeltaToPlainTextAdapterExtension({
name: 'inlineLatex',
match: delta => !!delta.attributes?.latex,
toAST: delta => {
const node: TextBuffer = {
content: delta.insert,
};
if (!delta.attributes?.latex) {
return node;
}
return {
content: delta.attributes?.latex,
};
},
});

View File

@@ -0,0 +1,93 @@
import {
DocModeProvider,
TelemetryProvider,
} from '@blocksuite/affine-shared/services';
import type { AffineInlineEditor } from '@blocksuite/affine-shared/types';
import type { Command, TextSelection } from '@blocksuite/std';
import type { InlineRange } from '@blocksuite/std/inline';
function openInlineLatexEditor(
inlineEditor: AffineInlineEditor,
index: number
) {
inlineEditor
.waitForUpdate()
.then(async () => {
await inlineEditor.waitForUpdate();
const textPoint = inlineEditor.getTextPoint(index);
if (!textPoint) return;
const [text] = textPoint;
const latexNode = text.parentElement?.closest('affine-latex-node');
if (!latexNode) return;
latexNode.toggleEditor();
})
.catch(console.error);
}
function getSingleBlockInlineRange(
textSelection: TextSelection
): InlineRange | null {
if (textSelection.to) {
return null;
}
return {
index: textSelection.from.index,
length: textSelection.from.length,
};
}
export const insertInlineLatex: Command<{
currentTextSelection?: TextSelection;
textSelection?: TextSelection;
}> = (ctx, next) => {
const textSelection = ctx.textSelection ?? ctx.currentTextSelection;
if (!textSelection) return;
const blockComponent = ctx.std.view.getBlock(textSelection.from.blockId);
if (!blockComponent) return;
const richText = blockComponent.querySelector('rich-text');
if (!richText) return;
const inlineEditor = richText.inlineEditor;
if (!inlineEditor) return;
const inlineRange = getSingleBlockInlineRange(textSelection);
if (!inlineRange) return;
const latex = textSelection.isCollapsed()
? ''
: inlineEditor.yTextString.slice(
inlineRange.index,
inlineRange.index + inlineRange.length
);
inlineEditor.insertText(inlineRange, ' ', { latex });
inlineEditor.setInlineRange({
index: inlineRange.index,
length: 1,
});
const mode = ctx.std.get(DocModeProvider).getEditorMode() ?? 'page';
const ifEdgelessText = blockComponent.closest('affine-edgeless-text');
ctx.std.getOptional(TelemetryProvider)?.track('Latex', {
from:
mode === 'page'
? 'doc'
: ifEdgelessText
? 'edgeless text'
: 'edgeless note',
page: mode === 'page' ? 'doc' : 'edgeless',
segment: mode === 'page' ? 'doc' : 'whiteboard',
module: 'inline equation',
control: 'create inline equation',
});
if (textSelection.isCollapsed()) {
openInlineLatexEditor(inlineEditor, inlineRange.index + 1);
}
next();
};

View File

@@ -0,0 +1,17 @@
import { LatexEditorMenu } from './latex-node/latex-editor-menu';
import { LatexEditorUnit } from './latex-node/latex-editor-unit';
import { AffineLatexNode } from './latex-node/latex-node';
export function effects() {
customElements.define('latex-editor-menu', LatexEditorMenu);
customElements.define('latex-editor-unit', LatexEditorUnit);
customElements.define('affine-latex-node', AffineLatexNode);
}
declare global {
interface HTMLElementTagNameMap {
'affine-latex-node': AffineLatexNode;
'latex-editor-unit': LatexEditorUnit;
'latex-editor-menu': LatexEditorMenu;
}
}

View File

@@ -0,0 +1,9 @@
import type * as RichTextEffects from '@blocksuite/affine-rich-text/effects';
import type RemarkMath from 'remark-math';
export * from './adapters';
export * from './command';
export * from './inline-spec';
export * from './markdown';
declare type _GLOBAL_ = typeof RichTextEffects | typeof RemarkMath;

View File

@@ -0,0 +1,40 @@
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { StdIdentifier } from '@blocksuite/std';
import { InlineSpecExtension } from '@blocksuite/std/inline';
import { html } from 'lit';
import { z } from 'zod';
export const LatexInlineSpecExtension =
InlineSpecExtension<AffineTextAttributes>('latex', provider => {
const std = provider.get(StdIdentifier);
return {
name: 'latex',
schema: z.object({
latex: z.string().optional().nullable().catch(undefined),
}),
match: delta => typeof delta.attributes?.latex === 'string',
renderer: ({ delta, selected, editor, startOffset, endOffset }) => {
return html`<affine-latex-node
.std=${std}
.delta=${delta}
.selected=${selected}
.editor=${editor}
.startOffset=${startOffset}
.endOffset=${endOffset}
></affine-latex-node>`;
},
embed: true,
};
});
export const LatexEditorUnitSpecExtension =
InlineSpecExtension<AffineTextAttributes>({
name: 'latex-editor-unit',
schema: z.object({
'latex-editor-unit': z.undefined(),
}),
match: () => true,
renderer: ({ delta }) => {
return html`<latex-editor-unit .delta=${delta}></latex-editor-unit>`;
},
});

View File

@@ -0,0 +1,210 @@
import { ColorScheme } from '@blocksuite/affine-model';
import type { RichText } from '@blocksuite/affine-rich-text';
import { ThemeProvider } from '@blocksuite/affine-shared/services';
import { unsafeCSSVar } from '@blocksuite/affine-shared/theme';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { noop } from '@blocksuite/global/utils';
import { DoneIcon } from '@blocksuite/icons/lit';
import { type BlockStdScope, ShadowlessElement } from '@blocksuite/std';
import { InlineManagerExtension } from '@blocksuite/std/inline';
import { effect, type Signal, signal } from '@preact/signals-core';
import { css, html } from 'lit';
import { property } from 'lit/decorators.js';
import { codeToTokensBase, type ThemedToken } from 'shiki';
import * as Y from 'yjs';
import { LatexEditorUnitSpecExtension } from '../inline-spec';
export const LatexEditorInlineManagerExtension =
InlineManagerExtension<AffineTextAttributes>({
id: 'latex-inline-editor',
enableMarkdown: false,
specs: [LatexEditorUnitSpecExtension.identifier],
});
export class LatexEditorMenu extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
static override styles = css`
.latex-editor-container {
display: grid;
grid-template-columns: 1fr auto;
grid-template-rows: auto auto;
grid-template-areas:
'editor-box confirm-box'
'hint-box hint-box';
padding: 8px;
border-radius: 8px;
border: 0.5px solid ${unsafeCSSVar('borderColor')};
background: ${unsafeCSSVar('backgroundOverlayPanelColor')};
/* light/toolbarShadow */
box-shadow: 0px 6px 16px 0px rgba(0, 0, 0, 0.14);
}
.latex-editor {
grid-area: editor-box;
width: 280px;
padding: 4px 10px;
border-radius: 4px;
background: ${unsafeCSSVar('white10')};
/* light/activeShadow */
box-shadow: 0px 0px 0px 2px rgba(30, 150, 235, 0.3);
font-family: ${unsafeCSSVar('fontCodeFamily')};
border: 1px solid transparent;
max-height: 400px;
overflow-y: auto;
}
.latex-editor:focus-within {
border: 1px solid ${unsafeCSSVar('blue700')};
}
.latex-editor-confirm {
grid-area: confirm-box;
display: flex;
align-items: flex-end;
padding-left: 10px;
}
.latex-editor-hint {
grid-area: hint-box;
padding-top: 6px;
color: ${unsafeCSSVar('placeholderColor')};
/* MobileTypeface/caption */
font-family: 'SF Pro Text';
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px; /* 133.333% */
letter-spacing: -0.24px;
}
`;
highlightTokens$: Signal<ThemedToken[][]> = signal([]);
yText!: Y.Text;
get inlineManager() {
return this.std.get(LatexEditorInlineManagerExtension.identifier);
}
get richText() {
return this.querySelector<RichText>('rich-text');
}
private readonly _getVerticalScrollContainer = () => {
return this.querySelector('.latex-editor');
};
private _updateHighlightTokens(text: string) {
const editorTheme = this.std.get(ThemeProvider).theme;
const theme = editorTheme === ColorScheme.Dark ? 'dark-plus' : 'light-plus';
codeToTokensBase(text, {
lang: 'latex',
theme,
})
.then(token => {
this.highlightTokens$.value = token;
})
.catch(console.error);
}
override connectedCallback(): void {
super.connectedCallback();
const doc = new Y.Doc();
this.yText = doc.getText('latex');
this.yText.insert(0, this.latexSignal.value);
const yTextObserver = () => {
const text = this.yText.toString();
this.latexSignal.value = text;
this._updateHighlightTokens(text);
};
this.yText.observe(yTextObserver);
this.disposables.add(() => {
this.yText.unobserve(yTextObserver);
});
this.disposables.add(
effect(() => {
noop(this.highlightTokens$.value);
this.richText?.inlineEditor?.render();
})
);
this.disposables.add(
this.std.get(ThemeProvider).theme$.subscribe(() => {
this._updateHighlightTokens(this.yText.toString());
})
);
this.disposables.addFromEvent(this, 'keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
e.stopPropagation();
this.abortController.abort();
}
});
this.disposables.addFromEvent(this, 'pointerdown', e => {
e.stopPropagation();
});
this.disposables.addFromEvent(this, 'pointerup', e => {
e.stopPropagation();
});
this.updateComplete
.then(async () => {
await this.richText?.updateComplete;
setTimeout(() => {
this.richText?.inlineEditor?.focusEnd();
});
})
.catch(console.error);
}
override render() {
return html`<div class="latex-editor-container">
<div class="latex-editor">
<div class="latex-editor-content">
<rich-text
.yText=${this.yText}
.attributesSchema=${this.inlineManager.getSchema()}
.attributeRenderer=${this.inlineManager.getRenderer()}
.verticalScrollContainerGetter=${this._getVerticalScrollContainer}
></rich-text>
</div>
</div>
<div class="latex-editor-confirm">
<span @click=${() => this.abortController.abort()}
>${DoneIcon({
width: '24',
height: '24',
})}</span
>
</div>
<div class="latex-editor-hint">Shift Enter to line break</div>
</div>`;
}
@property({ attribute: false })
accessor abortController!: AbortController;
@property({ attribute: false })
accessor latexSignal!: Signal<string>;
@property({ attribute: false })
accessor std!: BlockStdScope;
}

View File

@@ -0,0 +1,55 @@
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 LatexEditorUnit extends ShadowlessElement {
get latexMenu() {
return this.closest('latex-editor-menu');
}
get vElement() {
return this.closest('v-element');
}
override render() {
const plainContent = html`<span
><v-text .str=${this.delta.insert}></v-text
></span>`;
const latexMenu = this.latexMenu;
const vElement = this.vElement;
if (!latexMenu || !vElement) {
return plainContent;
}
const lineIndex = this.vElement.lineIndex;
const tokens = latexMenu.highlightTokens$.value[lineIndex] ?? [];
if (
tokens.length === 0 ||
tokens.reduce((acc, token) => acc + token.content, '') !==
this.delta.insert
) {
return plainContent;
}
return html`<span
>${tokens.map(token => {
return html`<v-text
.str=${token.content}
style=${styleMap({
color: token.color,
})}
></v-text>`;
})}</span
>`;
}
@property({ attribute: false })
accessor delta: DeltaInsert<AffineTextAttributes> = {
insert: ZERO_WIDTH_FOR_EMPTY_LINE,
};
}

View File

@@ -0,0 +1,285 @@
import { createLitPortal } from '@blocksuite/affine-components/portal';
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import {
type BlockComponent,
type BlockStdScope,
ShadowlessElement,
} from '@blocksuite/std';
import {
type InlineEditor,
ZERO_WIDTH_FOR_EMBED_NODE,
ZERO_WIDTH_FOR_EMPTY_LINE,
} from '@blocksuite/std/inline';
import type { DeltaInsert } from '@blocksuite/store';
import { signal } from '@preact/signals-core';
import katex from 'katex';
import { css, html, type PropertyValues, render } from 'lit';
import { property } from 'lit/decorators.js';
export class AffineLatexNode extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
static override styles = css`
affine-latex-node {
display: inline-block;
}
affine-latex-node .affine-latex {
white-space: nowrap;
word-break: break-word;
color: ${unsafeCSSVar('textPrimaryColor')};
fill: var(--affine-icon-color);
border-radius: 4px;
text-decoration: none;
cursor: pointer;
user-select: none;
padding: 1px 2px 1px 0;
display: grid;
grid-template-columns: auto 0;
place-items: center;
padding: 0 4px;
margin: 0 2px;
}
affine-latex-node .affine-latex:hover {
background: ${unsafeCSSVar('hoverColor')};
}
affine-latex-node .affine-latex[data-selected='true'] {
background: ${unsafeCSSVar('hoverColor')};
}
affine-latex-node .error-placeholder {
display: flex;
padding: 2px 4px;
justify-content: center;
align-items: flex-start;
gap: 10px;
border-radius: 4px;
background: ${unsafeCSSVarV2('chip/label/red')};
color: ${unsafeCSSVarV2('text/highlight/fg/red')};
font-family: Inter;
font-size: 12px;
font-weight: 500;
line-height: normal;
}
affine-latex-node .placeholder {
display: flex;
padding: 2px 4px;
justify-content: center;
align-items: flex-start;
border-radius: 4px;
background: ${unsafeCSSVarV2('layer/background/secondary')};
color: ${unsafeCSSVarV2('text/secondary')};
font-family: Inter;
font-size: 12px;
font-weight: 500;
line-height: normal;
}
`;
private _editorAbortController: AbortController | null = null;
private _isEditorOpen = false;
readonly latex$ = signal('');
readonly latexEditorSignal = signal('');
get deltaLatex() {
return this.delta.attributes?.latex as string;
}
get latexContainer() {
return this.querySelector<HTMLElement>('.latex-container');
}
override connectedCallback() {
const result = super.connectedCallback();
this.latex$.value = this.deltaLatex;
this.latexEditorSignal.value = this.deltaLatex;
this.disposables.add(
this.latex$.subscribe(latex => {
this.latexEditorSignal.value = latex;
if (latex !== this.deltaLatex) {
this.editor.formatText(
{
index: this.startOffset,
length: this.endOffset - this.startOffset,
},
{
latex,
}
);
}
})
);
this.disposables.add(
this.latexEditorSignal.subscribe(latex => {
this.updateComplete
.then(() => {
const latexContainer = this.latexContainer;
if (!latexContainer) return;
latexContainer.replaceChildren();
// @ts-expect-error lit hack won't fix
delete latexContainer['_$litPart$'];
if (latex.length === 0) {
render(
html`<span class="placeholder">Equation</span>`,
latexContainer
);
} else {
try {
katex.render(latex, latexContainer, {
displayMode: false,
});
} catch {
latexContainer.replaceChildren();
// @ts-expect-error lit hack won't fix
delete latexContainer['_$litPart$'];
render(
html`<span class="error-placeholder">Error equation</span>`,
latexContainer
);
}
}
})
.catch(console.error);
})
);
this._editorAbortController?.abort();
this._editorAbortController = new AbortController();
this.disposables.add(() => {
this._editorAbortController?.abort();
});
this.disposables.addFromEvent(this, 'click', e => {
e.preventDefault();
e.stopPropagation();
if (this.readonly) {
return;
}
this.toggleEditor();
});
return result;
}
protected override updated(changedProperties: PropertyValues<this>) {
super.updated(changedProperties);
if (!changedProperties.has('delta') || this._isEditorOpen) {
return;
}
const latex = this.deltaLatex;
if (this.latex$.peek() !== latex) {
this.latex$.value = latex;
}
if (this.latexEditorSignal.peek() !== latex) {
this.latexEditorSignal.value = latex;
}
}
override render() {
return html`<span class="affine-latex" data-selected=${this.selected}
><div class="latex-container"></div>
<v-text .str=${ZERO_WIDTH_FOR_EMBED_NODE}></v-text
></span>`;
}
toggleEditor() {
const blockComponent = this.closest<BlockComponent>('[data-block-id]');
if (!blockComponent) return;
this._editorAbortController?.abort();
this._editorAbortController = new AbortController();
blockComponent.selection.setGroup('note', []);
const { portal } = createLitPortal({
template: html`<latex-editor-menu
.std=${this.std}
.latexSignal=${this.latexEditorSignal}
.abortController=${this._editorAbortController}
></latex-editor-menu>`,
container: blockComponent.host,
computePosition: {
referenceElement: this,
placement: 'bottom-start',
autoUpdate: {
animationFrame: true,
},
},
closeOnClickAway: true,
abortController: this._editorAbortController,
shadowDom: false,
portalStyles: {
zIndex: 'var(--affine-z-index-popover)',
},
});
this._isEditorOpen = true;
this._editorAbortController.signal.addEventListener(
'abort',
() => {
this._isEditorOpen = false;
portal.remove();
const latex = this.latexEditorSignal.peek();
this.latex$.value = latex;
if (latex !== this.deltaLatex) {
this.editor.formatText(
{
index: this.startOffset,
length: this.endOffset - this.startOffset,
},
{
latex,
}
);
this.editor.setInlineRange({
index: this.endOffset,
length: 0,
});
}
},
{ once: true }
);
}
get readonly() {
return this.std.store.readonly;
}
@property({ attribute: false })
accessor delta: DeltaInsert<AffineTextAttributes> = {
insert: ZERO_WIDTH_FOR_EMPTY_LINE,
};
@property({ attribute: false })
accessor editor!: InlineEditor<AffineTextAttributes>;
@property({ attribute: false })
accessor endOffset!: number;
@property({ attribute: false })
accessor selected = false;
@property({ attribute: false })
accessor startOffset!: number;
@property({ attribute: false })
accessor std!: BlockStdScope;
}

View File

@@ -0,0 +1,180 @@
import {
DocModeProvider,
TelemetryProvider,
} from '@blocksuite/affine-shared/services';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import type { BlockComponent } from '@blocksuite/std';
import { InlineMarkdownExtension } from '@blocksuite/std/inline';
export const LatexExtension = InlineMarkdownExtension<AffineTextAttributes>({
name: 'latex',
pattern:
/(?:\$\$)(?<content>[^$]+)(?:\$\$)\s$|(?<blockPrefix>\$\$\$\$)\s$|(?<inlinePrefix>\$\$)\s$/g,
action: ({ inlineEditor, prefixText, inlineRange, pattern, undoManager }) => {
const match = pattern.exec(prefixText);
if (!match || !match.groups) return;
const content = match.groups['content'];
const inlinePrefix = match.groups['inlinePrefix'];
const blockPrefix = match.groups['blockPrefix'];
if (!inlineEditor.rootElement) return;
const blockComponent =
inlineEditor.rootElement.closest<BlockComponent>('[data-block-id]');
if (!blockComponent) return;
const doc = blockComponent.store;
const std = blockComponent.std;
const parentComponent = blockComponent.parentComponent;
if (!parentComponent) return;
const index = parentComponent.model.children.indexOf(blockComponent.model);
if (index === -1) return;
const mode = std.get(DocModeProvider).getEditorMode() ?? 'page';
const ifEdgelessText = blockComponent.closest('affine-edgeless-text');
if (blockPrefix === '$$$$') {
undoManager.stopCapturing();
inlineEditor.deleteText({
index: inlineRange.index - 5,
length: 5,
});
const id = doc.addBlock(
'affine:latex',
{
latex: '',
},
parentComponent.model,
index + 1
);
blockComponent.host.updateComplete
.then(() => {
const latexBlock = blockComponent.std.view.getBlock(id);
if (!latexBlock || latexBlock.flavour !== 'affine:latex') return;
//FIXME(@Flrande): wait for refactor
// @ts-expect-error BS-2241
latexBlock.toggleEditor();
})
.catch(console.error);
std.getOptional(TelemetryProvider)?.track('Latex', {
from:
mode === 'page'
? 'doc'
: ifEdgelessText
? 'edgeless text'
: 'edgeless note',
page: mode === 'page' ? 'doc' : 'edgeless',
segment: mode === 'page' ? 'doc' : 'whiteboard',
module: 'equation',
control: 'create equation',
});
return;
}
if (inlinePrefix === '$$') {
undoManager.stopCapturing();
inlineEditor.deleteText({
index: inlineRange.index - 3,
length: 3,
});
inlineEditor.insertText(
{
index: inlineRange.index - 3,
length: 0,
},
' '
);
inlineEditor.formatText(
{
index: inlineRange.index - 3,
length: 1,
},
{
latex: '',
}
);
inlineEditor
.waitForUpdate()
.then(async () => {
await inlineEditor.waitForUpdate();
const textPoint = inlineEditor.getTextPoint(
inlineRange.index - 3 + 1
);
if (!textPoint) return;
const [text] = textPoint;
const latexNode = text.parentElement?.closest('affine-latex-node');
if (!latexNode) return;
latexNode.toggleEditor();
})
.catch(console.error);
std.getOptional(TelemetryProvider)?.track('Latex', {
from:
mode === 'page'
? 'doc'
: ifEdgelessText
? 'edgeless text'
: 'edgeless note',
page: mode === 'page' ? 'doc' : 'edgeless',
segment: mode === 'page' ? 'doc' : 'whiteboard',
module: 'inline equation',
control: 'create inline equation',
});
return;
}
if (!content || content.length === 0) return;
undoManager.stopCapturing();
const startIndex = inlineRange.index - 1 - 2 - content.length - 2;
inlineEditor.deleteText({
index: startIndex,
length: 2 + content.length + 2 + 1,
});
inlineEditor.insertText(
{
index: startIndex,
length: 0,
},
' '
);
inlineEditor.formatText(
{
index: startIndex,
length: 1,
},
{
latex: String.raw`${content}`,
}
);
inlineEditor.setInlineRange({
index: startIndex + 1,
length: 0,
});
std.getOptional(TelemetryProvider)?.track('Latex', {
from:
mode === 'page'
? 'doc'
: ifEdgelessText
? 'edgeless text'
: 'edgeless note',
page: mode === 'page' ? 'doc' : 'edgeless',
segment: mode === 'page' ? 'doc' : 'whiteboard',
module: 'inline equation',
control: 'create inline equation',
});
},
});

View File

@@ -0,0 +1,21 @@
import {
type StoreExtensionContext,
StoreExtensionProvider,
} from '@blocksuite/affine-ext-loader';
import {
latexDeltaMarkdownAdapterMatch,
latexDeltaToMarkdownAdapterMatcher,
markdownInlineMathToDeltaMatcher,
} from './adapters';
export class LatexStoreExtension extends StoreExtensionProvider {
override name = 'affine-latex-inline';
override setup(context: StoreExtensionContext) {
super.setup(context);
context.register(latexDeltaMarkdownAdapterMatch);
context.register(latexDeltaToMarkdownAdapterMatcher);
context.register(markdownInlineMathToDeltaMatcher);
}
}

View File

@@ -0,0 +1,29 @@
import {
type ViewExtensionContext,
ViewExtensionProvider,
} from '@blocksuite/affine-ext-loader';
import { effects } from './effects';
import {
LatexEditorUnitSpecExtension,
LatexInlineSpecExtension,
} from './inline-spec';
import { LatexEditorInlineManagerExtension } from './latex-node/latex-editor-menu';
export class LatexViewExtension extends ViewExtensionProvider {
override name = 'affine-latex-inline';
override effect(): void {
super.effect();
effects();
}
override setup(context: ViewExtensionContext) {
super.setup(context);
context.register([
LatexInlineSpecExtension,
LatexEditorUnitSpecExtension,
LatexEditorInlineManagerExtension,
]);
}
}

View File

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

View File

@@ -0,0 +1,40 @@
{
"name": "@blocksuite/affine-inline-link",
"description": "Inline link 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-reference": "workspace:*",
"@blocksuite/affine-model": "workspace:*",
"@blocksuite/affine-shared": "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",
"lit": "^3.2.0",
"lit-html": "^3.2.1",
"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,70 @@
import {
type HtmlAST,
HtmlASTToDeltaExtension,
} from '@blocksuite/affine-shared/adapters';
import type { Element } from 'hast';
const isElement = (ast: HtmlAST): ast is Element => {
return ast.type === 'element';
};
export const htmlLinkElementToDeltaMatcher = HtmlASTToDeltaExtension({
name: 'link-element',
match: ast => isElement(ast) && ast.tagName === 'a',
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
}
const href = ast.properties?.href;
if (typeof href !== 'string') {
return [];
}
const { configs } = context;
const baseUrl = configs.get('docLinkBaseUrl') ?? '';
if (baseUrl && href.startsWith(baseUrl)) {
const path = href.substring(baseUrl.length);
// ^ - /{pageId}?mode={mode}&blockIds={blockIds}&elementIds={elementIds}
const match = path.match(/^\/([^?]+)(\?.*)?$/);
if (match) {
const pageId = match?.[1];
const search = match?.[2];
const searchParams = search ? new URLSearchParams(search) : undefined;
const mode = searchParams?.get('mode');
const blockIds = searchParams?.get('blockIds')?.split(',');
const elementIds = searchParams?.get('elementIds')?.split(',');
return [
{
insert: ' ',
attributes: {
reference: {
type: 'LinkedPage',
pageId,
params: {
mode:
mode && ['edgeless', 'page'].includes(mode)
? (mode as 'edgeless' | 'page')
: undefined,
blockIds,
elementIds,
},
},
},
},
];
}
}
return ast.children.flatMap(child =>
context.toDelta(child, { trim: false }).map(delta => {
if (href.startsWith('http')) {
delta.attributes = {
...delta.attributes,
link: href,
};
return delta;
}
return delta;
})
);
},
});

View File

@@ -0,0 +1,25 @@
import type { InlineHtmlAST } from '@blocksuite/affine-shared/adapters';
import { InlineDeltaToHtmlAdapterExtension } from '@blocksuite/affine-shared/adapters';
export const linkDeltaToHtmlAdapterMatcher = InlineDeltaToHtmlAdapterExtension({
name: 'link',
match: delta => !!delta.attributes?.link,
toAST: (delta, _) => {
const hast: InlineHtmlAST = {
type: 'text',
value: delta.insert,
};
const link = delta.attributes?.link;
if (!link) {
return hast;
}
return {
type: 'element',
tagName: 'a',
properties: {
href: link,
},
children: [hast],
};
},
});

View File

@@ -0,0 +1,6 @@
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';
export * from './plain-text/inline-delta';

View File

@@ -0,0 +1,2 @@
export * from './inline-delta';
export * from './markdown-inline';

View File

@@ -0,0 +1,36 @@
import { InlineDeltaToMarkdownAdapterExtension } from '@blocksuite/affine-shared/adapters';
import type { PhrasingContent } from 'mdast';
export const linkDeltaToMarkdownAdapterMatcher =
InlineDeltaToMarkdownAdapterExtension({
name: 'link',
match: delta => !!delta.attributes?.link,
toAST: (delta, context) => {
const mdast: PhrasingContent = {
type: 'text',
value: delta.insert,
};
const link = delta.attributes?.link;
if (!link) {
return mdast;
}
const { current: currentMdast } = context;
if ('value' in currentMdast) {
if (currentMdast.value === '') {
return {
type: 'text',
value: link,
};
}
if (mdast.value !== link) {
return {
type: 'link',
url: link,
children: [currentMdast],
};
}
}
return mdast;
},
});

View File

@@ -0,0 +1,52 @@
import { MarkdownASTToDeltaExtension } from '@blocksuite/affine-shared/adapters';
export const markdownLinkToDeltaMatcher = MarkdownASTToDeltaExtension({
name: 'link',
match: ast => ast.type === 'link',
toDelta: (ast, context) => {
if (!('children' in ast) || !('url' in ast)) {
return [];
}
const { configs } = context;
const baseUrl = configs.get('docLinkBaseUrl') ?? '';
if (baseUrl && ast.url.startsWith(baseUrl)) {
const path = ast.url.substring(baseUrl.length);
// ^ - /{pageId}?mode={mode}&blockIds={blockIds}&elementIds={elementIds}
const match = path.match(/^\/([^?]+)(\?.*)?$/);
if (match) {
const pageId = match?.[1];
const search = match?.[2];
const searchParams = search ? new URLSearchParams(search) : undefined;
const mode = searchParams?.get('mode');
const blockIds = searchParams?.get('blockIds')?.split(',');
const elementIds = searchParams?.get('elementIds')?.split(',');
return [
{
insert: ' ',
attributes: {
reference: {
type: 'LinkedPage',
pageId,
params: {
mode:
mode && ['edgeless', 'page'].includes(mode)
? (mode as 'edgeless' | 'page')
: undefined,
blockIds,
elementIds,
},
},
},
},
];
}
}
return ast.children.flatMap(child =>
context.toDelta(child).map(delta => {
delta.attributes = { ...delta.attributes, link: ast.url };
return delta;
})
);
},
});

View File

@@ -0,0 +1,52 @@
import {
type HtmlAST,
NotionHtmlASTToDeltaExtension,
} from '@blocksuite/affine-shared/adapters';
import type { Element } from 'hast';
const isElement = (ast: HtmlAST): ast is Element => {
return ast.type === 'element';
};
export const notionHtmlLinkElementToDeltaMatcher =
NotionHtmlASTToDeltaExtension({
name: 'link-element',
match: ast => isElement(ast) && ast.tagName === 'a',
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
}
const href = ast.properties?.href;
if (typeof href !== 'string') {
return [];
}
const { toDelta, options } = context;
return ast.children.flatMap(child =>
toDelta(child, options).map(delta => {
if (options.pageMap) {
const pageId = options.pageMap.get(decodeURIComponent(href));
if (pageId) {
delta.attributes = {
...delta.attributes,
reference: {
type: 'LinkedPage',
pageId,
},
};
delta.insert = ' ';
return delta;
}
}
if (href.startsWith('http')) {
delta.attributes = {
...delta.attributes,
link: href,
};
return delta;
}
return delta;
})
);
},
});

View File

@@ -0,0 +1,25 @@
import {
InlineDeltaToPlainTextAdapterExtension,
type TextBuffer,
} from '@blocksuite/affine-shared/adapters';
export const linkDeltaMarkdownAdapterMatch =
InlineDeltaToPlainTextAdapterExtension({
name: 'link',
match: delta => !!delta.attributes?.link,
toAST: delta => {
const linkText = delta.insert;
const node: TextBuffer = {
content: linkText,
};
const link = delta.attributes?.link;
if (!link) {
return node;
}
const content = `${linkText ? `${linkText}: ` : ''}${link}`;
return {
content,
};
},
});

View File

@@ -0,0 +1,42 @@
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import type { Command } from '@blocksuite/std';
import {
INLINE_ROOT_ATTR,
type InlineRootElement,
} from '@blocksuite/std/inline';
import { toggleLinkPopup } from './link-node/link-popup/toggle-link-popup';
export const toggleLink: Command = (ctx, next) => {
const selection = document.getSelection();
if (!selection || selection.rangeCount === 0) return false;
const range = selection.getRangeAt(0);
if (range.collapsed) return false;
const inlineRoot = range.startContainer.parentElement?.closest<
InlineRootElement<AffineTextAttributes>
>(`[${INLINE_ROOT_ATTR}]`);
if (!inlineRoot) return false;
const inlineEditor = inlineRoot.inlineEditor;
const targetInlineRange = inlineEditor.getInlineRange();
if (!targetInlineRange || targetInlineRange.length === 0) return false;
const format = inlineEditor.getFormat(targetInlineRange);
if (format.link) {
inlineEditor.formatText(targetInlineRange, { link: null });
return next();
}
const abortController = new AbortController();
const popup = toggleLinkPopup(
ctx.std,
'create',
inlineEditor,
targetInlineRange,
abortController
);
abortController.signal.addEventListener('abort', () => popup.remove());
return next();
};

View File

@@ -0,0 +1,7 @@
import { AffineLink } from './link-node/affine-link';
import { LinkPopup } from './link-node/link-popup/link-popup';
export function effects() {
customElements.define('link-popup', LinkPopup);
customElements.define('affine-link', AffineLink);
}

View File

@@ -0,0 +1,6 @@
export * from './adapters';
export * from './command';
export * from './inline-spec';
export * from './link-node';
export * from './markdown';
export * from './toolbar';

View File

@@ -0,0 +1,22 @@
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { StdIdentifier } from '@blocksuite/std';
import { InlineSpecExtension } from '@blocksuite/std/inline';
import { html } from 'lit';
import { z } from 'zod';
export const LinkInlineSpecExtension =
InlineSpecExtension<AffineTextAttributes>('link', provider => {
const std = provider.get(StdIdentifier);
return {
name: 'link',
schema: z.object({
link: z.string().optional().nullable().catch(undefined),
}),
match: delta => {
return !!delta.attributes?.link;
},
renderer: ({ delta }) => {
return html`<affine-link .std=${std} .delta=${delta}></affine-link>`;
},
};
});

View File

@@ -0,0 +1,187 @@
import { whenHover } from '@blocksuite/affine-components/hover';
import { RefNodeSlotsProvider } from '@blocksuite/affine-inline-reference';
import type { ReferenceInfo } from '@blocksuite/affine-model';
import {
ParseDocUrlProvider,
ToolbarRegistryIdentifier,
} from '@blocksuite/affine-shared/services';
import { affineTextStyles } from '@blocksuite/affine-shared/styles';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { normalizeUrl } from '@blocksuite/affine-shared/utils';
import { WithDisposable } from '@blocksuite/global/lit';
import type { BlockComponent, BlockStdScope } from '@blocksuite/std';
import { BLOCK_ID_ATTR, ShadowlessElement } from '@blocksuite/std';
import {
INLINE_ROOT_ATTR,
type InlineRootElement,
ZERO_WIDTH_FOR_EMPTY_LINE,
} from '@blocksuite/std/inline';
import type { DeltaInsert } from '@blocksuite/store';
import { css, html } from 'lit';
import { property } from 'lit/decorators.js';
import { type StyleInfo, styleMap } from 'lit/directives/style-map.js';
export class AffineLink extends WithDisposable(ShadowlessElement) {
static override styles = css`
affine-link a:hover [data-v-text='true'] {
text-decoration: underline;
}
`;
// The link has been identified.
private _identified: boolean = false;
// see https://github.com/toeverything/AFFiNE/issues/1540
private readonly _onMouseUp = () => {
const anchorElement = this.querySelector('a');
if (!anchorElement || !anchorElement.isContentEditable) return;
anchorElement.contentEditable = 'false';
setTimeout(() => {
anchorElement.removeAttribute('contenteditable');
}, 0);
};
private _referenceInfo: ReferenceInfo | null = null;
openLink = (e?: MouseEvent) => {
if (!this._identified) {
this._identified = true;
this._identify();
}
const referenceInfo = this._referenceInfo;
if (!referenceInfo) return;
const refNodeSlotsProvider = this.std.getOptional(RefNodeSlotsProvider);
if (!refNodeSlotsProvider) return;
e?.preventDefault();
refNodeSlotsProvider.docLinkClicked.next({
...referenceInfo,
openMode: e?.button === 1 ? 'open-in-new-tab' : undefined,
host: this.std.host,
});
};
_whenHover = whenHover(
hovered => {
const message$ = this.std.get(ToolbarRegistryIdentifier).message$;
if (hovered) {
message$.value = {
flavour: 'affine:link',
element: this,
setFloating: this._whenHover.setFloating,
};
return;
}
// Clears previous bindings
message$.value = null;
this._whenHover.setFloating();
},
{ enterDelay: 500 }
);
override connectedCallback() {
super.connectedCallback();
this._whenHover.setReference(this);
const message$ = this.std.get(ToolbarRegistryIdentifier).message$;
this._disposables.add(() => {
if (message$?.value) {
message$.value = null;
}
this._whenHover.dispose();
});
}
// Workaround for links not working in contenteditable div
// see also https://stackoverflow.com/questions/12059211/how-to-make-clickable-anchor-in-contenteditable-div
//
// Note: We cannot use JS to directly open a new page as this may be blocked by the browser.
//
// Please also note that when readonly mode active,
// this workaround is not necessary and links work normally.
get block() {
if (!this.inlineEditor?.rootElement) return null;
const block = this.inlineEditor.rootElement.closest<BlockComponent>(
`[${BLOCK_ID_ATTR}]`
);
return block;
}
get inlineEditor() {
const inlineRoot = this.closest<InlineRootElement<AffineTextAttributes>>(
`[${INLINE_ROOT_ATTR}]`
);
return inlineRoot?.inlineEditor;
}
get link() {
return normalizeUrl(this.delta.attributes?.link ?? '');
}
get selfInlineRange() {
const selfInlineRange = this.inlineEditor?.getInlineRangeFromElement(this);
return selfInlineRange;
}
// Identify if url is an internal link
private _identify() {
const link = this.link;
if (!link) return;
const result = this.std.getOptional(ParseDocUrlProvider)?.parseDocUrl(link);
if (!result) return;
const { docId: pageId, ...params } = result;
this._referenceInfo = { pageId, params };
}
private _renderLink(style: StyleInfo) {
return html`<a
href=${this.link}
rel="noopener noreferrer"
target="_blank"
style=${styleMap(style)}
@click=${this.openLink}
@auxclick=${this.openLink}
@mouseup=${this._onMouseUp}
><v-text .str=${this.delta.insert}></v-text
></a>`;
}
override render() {
const linkStyle = {
color: 'var(--affine-link-color)',
fill: 'var(--affine-link-color)',
cursor: 'pointer',
};
if (this.delta.attributes && this.delta.attributes?.code) {
const codeStyle = affineTextStyles(this.delta.attributes);
return html`<code style=${styleMap(codeStyle)}>
${this._renderLink(linkStyle)}
</code>`;
}
const style = this.delta.attributes
? affineTextStyles(this.delta.attributes, linkStyle)
: {};
return this._renderLink(style);
}
@property({ type: Object })
accessor delta: DeltaInsert<AffineTextAttributes> = {
insert: ZERO_WIDTH_FOR_EMPTY_LINE,
};
@property({ attribute: false })
accessor std!: BlockStdScope;
}

View File

@@ -0,0 +1,365 @@
import { toast } from '@blocksuite/affine-components/toast';
import {
ActionPlacement,
EmbedIframeService,
EmbedOptionProvider,
type ToolbarAction,
type ToolbarActionGroup,
type ToolbarModuleConfig,
} from '@blocksuite/affine-shared/services';
import {
CopyIcon,
DeleteIcon,
EditIcon,
UnlinkIcon,
} from '@blocksuite/icons/lit';
import { BlockSelection } from '@blocksuite/std';
import { signal } from '@preact/signals-core';
import { html } from 'lit-html';
import { keyed } from 'lit-html/directives/keyed.js';
import { AffineLink } from '../affine-link';
import { toggleLinkPopup } from '../link-popup/toggle-link-popup';
const trackBaseProps = {
segment: 'doc',
page: 'doc editor',
module: 'toolbar',
category: 'link',
type: 'inline view',
};
export const builtinInlineLinkToolbarConfig = {
actions: [
{
id: 'a.preview',
content(cx) {
const target = cx.message$.peek()?.element;
if (!(target instanceof AffineLink)) return null;
const { link } = target;
if (!link) return null;
return html`<affine-link-preview .url=${link}></affine-link-preview>`;
},
},
{
id: 'b.copy-link-and-edit',
actions: [
{
id: 'copy-link',
tooltip: 'Copy link',
icon: CopyIcon(),
run(ctx) {
const target = ctx.message$.peek()?.element;
if (!(target instanceof AffineLink)) return;
const { link } = target;
if (!link) return;
// Clears
ctx.reset();
navigator.clipboard.writeText(link).catch(console.error);
toast(ctx.host, 'Copied link to clipboard');
ctx.track('CopiedLink', {
...trackBaseProps,
control: 'copy link',
});
},
},
{
id: 'edit',
tooltip: 'Edit Description',
icon: EditIcon(),
run(ctx) {
const target = ctx.message$.peek()?.element;
if (!(target instanceof AffineLink)) return;
const { inlineEditor, selfInlineRange } = target;
if (!inlineEditor || !selfInlineRange) return;
const abortController = new AbortController();
const popover = toggleLinkPopup(
ctx.std,
'edit',
inlineEditor,
selfInlineRange,
abortController
);
abortController.signal.onabort = () => popover.remove();
ctx.track('OpenedAliasPopup', {
...trackBaseProps,
control: 'edit',
});
},
},
],
},
{
id: 'c.conversions',
actions: [
{
id: 'inline',
label: 'Inline view',
disabled: true,
},
{
id: 'card',
label: 'Card view',
run(ctx) {
const target = ctx.message$.peek()?.element;
if (!(target instanceof AffineLink)) return;
if (!target.block) return;
const url = target.link;
if (!url) return;
const {
block: { model },
inlineEditor,
selfInlineRange,
} = target;
const { parent } = model;
if (!inlineEditor || !selfInlineRange || !parent) return;
// Clears
ctx.reset();
const title = inlineEditor.yTextString.slice(
selfInlineRange.index,
selfInlineRange.index + selfInlineRange.length
);
const options = ctx.std
.get(EmbedOptionProvider)
.getEmbedBlockOptions(url);
const flavour =
options?.viewType === 'card'
? options.flavour
: 'affine:bookmark';
const index = parent.children.indexOf(model);
const props = {
url,
title: title === url ? '' : title,
};
const blockId = ctx.store.addBlock(
flavour,
props,
parent,
index + 1
);
const totalTextLength = inlineEditor.yTextLength;
const inlineTextLength = selfInlineRange.length;
if (totalTextLength === inlineTextLength) {
ctx.store.deleteBlock(model);
} else {
inlineEditor.formatText(selfInlineRange, { link: null });
}
ctx.select('note', [
ctx.selection.create(BlockSelection, { blockId }),
]);
ctx.track('SelectedView', {
...trackBaseProps,
control: 'select view',
type: 'card view',
});
},
},
{
id: 'embed',
label: 'Embed view',
when(ctx) {
const target = ctx.message$.peek()?.element;
if (!(target instanceof AffineLink)) return false;
if (!target.block) return false;
const url = target.link;
if (!url) return false;
const {
block: { model },
inlineEditor,
selfInlineRange,
} = target;
const { parent } = model;
if (!inlineEditor || !selfInlineRange || !parent) return false;
// check if the url can be embedded as iframe block
const embedIframeService = ctx.std.get(EmbedIframeService);
const canEmbedAsIframe = embedIframeService.canEmbed(url);
const options = ctx.std
.get(EmbedOptionProvider)
.getEmbedBlockOptions(url);
return canEmbedAsIframe || options?.viewType === 'embed';
},
run(ctx) {
const target = ctx.message$.peek()?.element;
if (!(target instanceof AffineLink)) return;
if (!target.block) return;
const url = target.link;
if (!url) return;
const {
block: { model },
inlineEditor,
selfInlineRange,
} = target;
const { parent } = model;
if (!inlineEditor || !selfInlineRange || !parent) return;
// Clears
ctx.reset();
const index = parent.children.indexOf(model);
const props = { url };
let blockId: string | undefined;
const embedIframeService = ctx.std.get(EmbedIframeService);
const embedOptions = ctx.std
.get(EmbedOptionProvider)
.getEmbedBlockOptions(url);
if (embedOptions?.viewType === 'embed') {
const flavour = embedOptions.flavour;
blockId = ctx.store.addBlock(flavour, props, parent, index + 1);
} else if (embedIframeService.canEmbed(url)) {
blockId = embedIframeService.addEmbedIframeBlock(
props,
parent.id,
index + 1
);
}
if (!blockId) return;
const totalTextLength = inlineEditor.yTextLength;
const inlineTextLength = selfInlineRange.length;
if (totalTextLength === inlineTextLength) {
ctx.store.deleteBlock(model);
} else {
inlineEditor.formatText(selfInlineRange, { link: null });
}
ctx.select('note', [
ctx.selection.create(BlockSelection, { blockId }),
]);
ctx.track('SelectedView', {
...trackBaseProps,
control: 'select view',
type: 'embed view',
});
},
},
],
content(ctx) {
const target = ctx.message$.peek()?.element;
if (!(target instanceof AffineLink)) return null;
const actions = this.actions.map(action => ({ ...action }));
const viewType$ = signal(actions[0].label);
const onToggle = (e: CustomEvent<boolean>) => {
const opened = e.detail;
if (!opened) return;
ctx.track('OpenedViewSelector', {
...trackBaseProps,
control: 'switch view',
});
};
return html`${keyed(
target,
html`<affine-view-dropdown-menu
.actions=${actions}
.context=${ctx}
.onToggle=${onToggle}
.viewTypeSignal=${viewType$}
></affine-view-dropdown-menu>`
)}`;
},
when(ctx) {
const target = ctx.message$.peek()?.element;
if (!(target instanceof AffineLink)) return false;
if (!target.block) return false;
if (ctx.flags.isNative()) return false;
if (
target.block.closest('affine-database') ||
target.block.closest('affine-table')
)
return false;
if (!target.link.startsWith('http')) return false;
const { model } = target.block;
const parent = model.parent;
if (!parent) return false;
const schema = ctx.store.schema;
const bookmarkSchema = schema.flavourSchemaMap.get('affine:bookmark');
if (!bookmarkSchema) return false;
const parentSchema = schema.flavourSchemaMap.get(parent.flavour);
if (!parentSchema) return false;
try {
schema.validateSchema(bookmarkSchema, parentSchema);
} catch {
return false;
}
return true;
},
} satisfies ToolbarActionGroup<ToolbarAction>,
{
placement: ActionPlacement.More,
id: 'b.remove-link',
label: 'Remove link',
icon: UnlinkIcon(),
run(ctx) {
const target = ctx.message$.peek()?.element;
if (!(target instanceof AffineLink)) return;
const { inlineEditor, selfInlineRange } = target;
if (!inlineEditor || !selfInlineRange) return;
if (!inlineEditor.isValidInlineRange(selfInlineRange)) return;
inlineEditor.formatText(selfInlineRange, { link: null });
},
},
{
placement: ActionPlacement.More,
id: 'c.delete',
label: 'Delete',
icon: DeleteIcon(),
variant: 'destructive',
run(ctx) {
const target = ctx.message$.peek()?.element;
if (!(target instanceof AffineLink)) return;
const { inlineEditor, selfInlineRange } = target;
if (!inlineEditor || !selfInlineRange) return;
if (!inlineEditor.isValidInlineRange(selfInlineRange)) return;
inlineEditor.deleteText(selfInlineRange);
},
},
],
} as const satisfies ToolbarModuleConfig;

View File

@@ -0,0 +1,2 @@
export { AffineLink } from './affine-link.js';
export { toggleLinkPopup } from './link-popup/toggle-link-popup.js';

View File

@@ -0,0 +1,335 @@
import type { EditorIconButton } from '@blocksuite/affine-components/toolbar';
import type { AffineInlineEditor } from '@blocksuite/affine-shared/types';
import {
isValidUrl,
normalizeUrl,
stopPropagation,
} from '@blocksuite/affine-shared/utils';
import { WithDisposable } from '@blocksuite/global/lit';
import { DoneIcon } from '@blocksuite/icons/lit';
import {
type BlockStdScope,
ShadowlessElement,
TextSelection,
} from '@blocksuite/std';
import type { InlineRange } from '@blocksuite/std/inline';
import {
autoUpdate,
computePosition,
inline,
offset,
shift,
} from '@floating-ui/dom';
import { html } from 'lit';
import { property, query } from 'lit/decorators.js';
import { choose } from 'lit/directives/choose.js';
import { linkPopupStyle } from './styles';
export class LinkPopup extends WithDisposable(ShadowlessElement) {
static override styles = linkPopupStyle;
private _bodyOverflowStyle = '';
private readonly _createTemplate = () => {
this.updateComplete
.then(() => {
this.linkInput?.focus();
this._updateConfirmBtn();
})
.catch(console.error);
return html`
<div class="affine-link-popover create">
<input
id="link-input"
class="affine-link-popover-input"
type="text"
spellcheck="false"
placeholder="Paste or type a link"
@paste=${this._updateConfirmBtn}
@input=${this._updateConfirmBtn}
/>
${this._confirmBtnTemplate()}
</div>
`;
};
private readonly _editTemplate = () => {
this.updateComplete
.then(() => {
if (
!this.textInput ||
!this.linkInput ||
!this.currentText ||
!this.currentLink
)
return;
this.textInput.value = this.currentText;
this.linkInput.value = this.currentLink;
this.textInput.select();
this._updateConfirmBtn();
})
.catch(console.error);
return html`
<div class="affine-link-edit-popover">
<div class="affine-edit-area text">
<input
class="affine-edit-input"
id="text-input"
type="text"
placeholder="Enter text"
@input=${this._updateConfirmBtn}
/>
<label class="affine-edit-label" for="text-input">Text</label>
</div>
<div class="affine-edit-area link">
<input
id="link-input"
class="affine-edit-input"
type="text"
spellcheck="false"
placeholder="Paste or type a link"
@input=${this._updateConfirmBtn}
/>
<label class="affine-edit-label" for="link-input">Link</label>
</div>
${this._confirmBtnTemplate()}
</div>
`;
};
get currentLink() {
return this.inlineEditor.getFormat(this.targetInlineRange).link;
}
get currentText() {
return this.inlineEditor.yTextString.slice(
this.targetInlineRange.index,
this.targetInlineRange.index + this.targetInlineRange.length
);
}
private _confirmBtnTemplate() {
return html`
<editor-icon-button
class="affine-confirm-button"
.iconSize="${'24px'}"
.disabled=${true}
@click=${this._onConfirm}
>
${DoneIcon()}
</editor-icon-button>
`;
}
private _onConfirm() {
if (!this.inlineEditor.isValidInlineRange(this.targetInlineRange)) return;
if (!this.linkInput) return;
const linkInputValue = this.linkInput.value;
if (!linkInputValue || !isValidUrl(linkInputValue)) return;
const link = normalizeUrl(linkInputValue);
if (this.type === 'create') {
this.inlineEditor.formatText(this.targetInlineRange, {
link: link,
reference: null,
});
this.inlineEditor.setInlineRange(this.targetInlineRange);
} else if (this.type === 'edit') {
const text = this.textInput?.value ?? link;
this.inlineEditor.insertText(this.targetInlineRange, text, {
link: link,
reference: null,
});
this.inlineEditor.setInlineRange({
index: this.targetInlineRange.index,
length: text.length,
});
}
const textSelection = this.std.host.selection.find(TextSelection);
if (textSelection) {
this.std.range.syncTextSelectionToRange(textSelection);
}
this.abortController.abort();
}
private _onKeydown(e: KeyboardEvent) {
e.stopPropagation();
if (!e.isComposing) {
if (e.key === 'Escape') {
e.preventDefault();
this.abortController.abort();
this.std.host.selection.clear();
return;
}
if (e.key === 'Enter') {
e.preventDefault();
this._onConfirm();
}
}
}
private _updateConfirmBtn() {
if (!this.confirmButton) {
return;
}
const link = this.linkInput?.value.trim();
const disabled = !(link && isValidUrl(link));
this.confirmButton.disabled = disabled;
this.confirmButton.active = !disabled;
this.confirmButton.requestUpdate();
}
private updateMockSelection(rects: DOMRect[]) {
if (!this.mockSelectionContainer) {
return;
}
this.mockSelectionContainer
.querySelectorAll('div')
.forEach(e => e.remove());
const fragment = document.createDocumentFragment();
rects.forEach(domRect => {
const mockSelection = document.createElement('div');
mockSelection.classList.add('mock-selection');
// Get the container's bounding rect to account for its position
const containerRect = this.mockSelectionContainer.getBoundingClientRect();
// Adjust the position by subtracting the container's offset
mockSelection.style.left = `${domRect.left - containerRect.left}px`;
mockSelection.style.top = `${domRect.top - containerRect.top}px`;
mockSelection.style.width = `${domRect.width}px`;
mockSelection.style.height = `${domRect.height}px`;
fragment.append(mockSelection);
});
this.mockSelectionContainer.append(fragment);
}
override connectedCallback() {
super.connectedCallback();
if (this.targetInlineRange.length === 0) {
return;
}
// disable body scroll
this._bodyOverflowStyle = document.body.style.overflow;
document.body.style.overflow = 'hidden';
this.disposables.add({
dispose: () => {
document.body.style.overflow = this._bodyOverflowStyle;
},
});
}
override firstUpdated() {
this.disposables.addFromEvent(this, 'keydown', this._onKeydown);
this.disposables.addFromEvent(this, 'copy', stopPropagation);
this.disposables.addFromEvent(this, 'cut', stopPropagation);
this.disposables.addFromEvent(this, 'paste', stopPropagation);
this.disposables.addFromEvent(this.overlayMask, 'click', e => {
e.stopPropagation();
this.std.host.selection.setGroup('note', []);
this.abortController.abort();
});
const range = this.inlineEditor.toDomRange(this.targetInlineRange);
if (!range) {
return;
}
const visualElement = {
getBoundingClientRect: () => range.getBoundingClientRect(),
getClientRects: () => range.getClientRects(),
};
const popover = this.popoverContainer;
this.disposables.add(
autoUpdate(visualElement, popover, () => {
computePosition(visualElement, popover, {
middleware: [
offset(10),
inline(),
shift({
padding: 6,
}),
],
})
.then(({ x, y }) => {
popover.style.left = `${x}px`;
popover.style.top = `${y}px`;
this.updateMockSelection(
Array.from(visualElement.getClientRects())
);
})
.catch(console.error);
})
);
}
override render() {
return html`
<div class="overlay-root">
<div class="overlay-mask"></div>
<div class="popover-container">
${choose(this.type, [
['create', this._createTemplate],
['edit', this._editTemplate],
])}
</div>
<div class="mock-selection-container"></div>
</div>
`;
}
@property({ attribute: false })
accessor abortController!: AbortController;
@query('.affine-confirm-button')
accessor confirmButton: EditorIconButton | null = null;
@property({ attribute: false })
accessor inlineEditor!: AffineInlineEditor;
@query('#link-input')
accessor linkInput: HTMLInputElement | null = null;
@query('.mock-selection-container')
accessor mockSelectionContainer!: HTMLDivElement;
@query('.overlay-mask')
accessor overlayMask!: HTMLDivElement;
@query('.popover-container')
accessor popoverContainer!: HTMLDivElement;
@property({ attribute: false })
accessor targetInlineRange!: InlineRange;
@query('#text-input')
accessor textInput: HTMLInputElement | null = null;
@property()
accessor type: 'create' | 'edit' = 'create';
@property({ attribute: false })
accessor std!: BlockStdScope;
}

View File

@@ -0,0 +1,162 @@
import { fontSMStyle, panelBaseStyle } from '@blocksuite/affine-shared/styles';
import { css } from 'lit';
const editLinkStyle = css`
.affine-link-edit-popover {
display: grid;
grid-template-columns: auto auto;
grid-template-rows: repeat(2, 1fr);
grid-template-areas:
'text-area .'
'link-area btn';
justify-items: center;
align-items: center;
width: 320px;
gap: 8px 12px;
padding: 8px;
box-sizing: content-box;
}
${fontSMStyle('.affine-link-edit-popover label')}
.affine-link-edit-popover label {
box-sizing: border-box;
color: var(--affine-icon-color);
font-weight: 400;
}
${fontSMStyle('.affine-link-edit-popover input')}
.affine-link-edit-popover input {
color: inherit;
padding: 0;
border: none;
background: transparent;
color: var(--affine-text-primary-color);
}
.affine-link-edit-popover input::placeholder {
color: var(--affine-placeholder-color);
}
input:focus {
outline: none;
}
.affine-link-edit-popover input:focus ~ label,
.affine-link-edit-popover input:active ~ label {
color: var(--affine-primary-color);
}
.affine-edit-area {
width: 280px;
padding: 4px 10px;
display: grid;
gap: 8px;
grid-template-columns: 26px auto;
grid-template-rows: repeat(1, 1fr);
grid-template-areas: 'label input';
user-select: none;
box-sizing: border-box;
border: 1px solid var(--affine-border-color);
box-sizing: border-box;
outline: none;
border-radius: 4px;
background: transparent;
}
.affine-edit-area:focus-within {
border-color: var(--affine-blue-700);
box-shadow: var(--affine-active-shadow);
}
.affine-edit-area.text {
grid-area: text-area;
}
.affine-edit-area.link {
grid-area: link-area;
}
.affine-edit-label {
grid-area: label;
}
.affine-edit-input {
grid-area: input;
}
.affine-confirm-button {
grid-area: btn;
user-select: none;
}
`;
export const linkPopupStyle = css`
:host {
box-sizing: border-box;
}
.mock-selection {
position: absolute;
background-color: rgba(35, 131, 226, 0.28);
}
${panelBaseStyle('.popover-container')}
.popover-container {
z-index: var(--affine-z-index-popover);
animation: affine-popover-fade-in 0.2s ease;
position: absolute;
}
@keyframes affine-popover-fade-in {
from {
opacity: 0;
transform: translateY(-3px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.overlay-root {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: var(--affine-z-index-popover);
}
.mock-selection-container {
pointer-events: none;
}
.affine-link-popover.create {
display: flex;
gap: 12px;
padding: 8px;
color: var(--affine-text-primary-color);
}
.affine-link-popover-input {
min-width: 280px;
height: 30px;
box-sizing: border-box;
padding: 4px 10px;
background: var(--affine-white-10);
border-radius: 4px;
border-width: 1px;
border-style: solid;
border-color: var(--affine-border-color);
color: var(--affine-text-primary-color);
}
${fontSMStyle('.affine-link-popover-input')}
.affine-link-popover-input::placeholder {
color: var(--affine-placeholder-color);
}
.affine-link-popover-input:focus {
border-color: var(--affine-blue-700);
box-shadow: var(--affine-active-shadow);
}
${editLinkStyle}
`;

View File

@@ -0,0 +1,27 @@
import type { AffineInlineEditor } from '@blocksuite/affine-shared/types';
import type { BlockStdScope } from '@blocksuite/std';
import type { InlineRange } from '@blocksuite/std/inline';
import { LinkPopup } from './link-popup';
export function toggleLinkPopup(
std: BlockStdScope,
type: LinkPopup['type'],
inlineEditor: AffineInlineEditor,
targetInlineRange: InlineRange,
abortController: AbortController
): LinkPopup {
const popup = new LinkPopup();
popup.std = std;
popup.type = type;
popup.inlineEditor = inlineEditor;
popup.targetInlineRange = targetInlineRange;
popup.abortController = abortController;
const root =
inlineEditor.rootElement?.closest('editor-host')?.parentElement ??
document.body;
root.append(popup);
return popup;
}

View File

@@ -0,0 +1,53 @@
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { InlineMarkdownExtension } from '@blocksuite/std/inline';
export const LinkExtension = InlineMarkdownExtension<AffineTextAttributes>({
name: 'link',
pattern: /.*\[(.+?)\]\((.+?)\)\s$/,
action: ({ inlineEditor, prefixText, inlineRange, pattern, undoManager }) => {
const match = prefixText.match(pattern);
if (!match) return;
const linkText = match[1];
const linkUrl = match[2];
const annotatedText = match[0].slice(
-(linkText.length + linkUrl.length + 4 + 1),
-1
);
const startIndex = inlineRange.index - annotatedText.length - 1;
undoManager.stopCapturing();
// aaa[bbb](baidu.com) + space
// delete (baidu.com) + space
inlineEditor.deleteText({
index: startIndex + 1 + linkText.length + 1,
length: 1 + linkUrl.length + 1 + 1,
});
// delete [ and ]
inlineEditor.deleteText({
index: startIndex + 1 + linkText.length,
length: 1,
});
inlineEditor.deleteText({
index: startIndex,
length: 1,
});
inlineEditor.formatText(
{
index: startIndex,
length: linkText.length,
},
{
link: linkUrl,
}
);
inlineEditor.setInlineRange({
index: startIndex + linkText.length,
length: 0,
});
},
});

View File

@@ -0,0 +1,27 @@
import {
type StoreExtensionContext,
StoreExtensionProvider,
} from '@blocksuite/affine-ext-loader';
import {
htmlLinkElementToDeltaMatcher,
linkDeltaMarkdownAdapterMatch,
linkDeltaToHtmlAdapterMatcher,
linkDeltaToMarkdownAdapterMatcher,
markdownLinkToDeltaMatcher,
notionHtmlLinkElementToDeltaMatcher,
} from './adapters';
export class LinkStoreExtension extends StoreExtensionProvider {
override name = 'affine-link-inline';
override setup(context: StoreExtensionContext) {
super.setup(context);
context.register(linkDeltaMarkdownAdapterMatch);
context.register(linkDeltaToMarkdownAdapterMatcher);
context.register(notionHtmlLinkElementToDeltaMatcher);
context.register(markdownLinkToDeltaMatcher);
context.register(htmlLinkElementToDeltaMatcher);
context.register(linkDeltaToHtmlAdapterMatcher);
}
}

View File

@@ -0,0 +1,9 @@
import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services';
import { BlockFlavourIdentifier } from '@blocksuite/std';
import { builtinInlineLinkToolbarConfig } from './link-node/configs/toolbar.js';
export const linkToolbar = ToolbarModuleExtension({
id: BlockFlavourIdentifier('affine:link'),
config: builtinInlineLinkToolbarConfig,
});

View File

@@ -0,0 +1,23 @@
import {
type ViewExtensionContext,
ViewExtensionProvider,
} from '@blocksuite/affine-ext-loader';
import { effects } from './effects';
import { LinkInlineSpecExtension } from './inline-spec';
import { linkToolbar } from './toolbar';
export class LinkViewExtension extends ViewExtensionProvider {
override name = 'affine-link-inline';
override effect(): void {
super.effect();
effects();
}
override setup(context: ViewExtensionContext): void {
super.setup(context);
context.register(LinkInlineSpecExtension);
context.register(linkToolbar);
}
}

View File

@@ -0,0 +1,19 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src"],
"references": [
{ "path": "../../components" },
{ "path": "../../ext-loader" },
{ "path": "../reference" },
{ "path": "../../model" },
{ "path": "../../shared" },
{ "path": "../../../framework/global" },
{ "path": "../../../framework/std" },
{ "path": "../../../framework/store" }
]
}

View File

@@ -0,0 +1,32 @@
{
"name": "@blocksuite/affine-inline-mention",
"description": "Inline mention for BlockSuite.",
"type": "module",
"scripts": {
"build": "yarn run -T tsc"
},
"sideEffects": false,
"keywords": [],
"author": "toeverything",
"license": "MIT",
"dependencies": {
"@blocksuite/affine-ext-loader": "workspace:*",
"@blocksuite/affine-shared": "workspace:*",
"@blocksuite/global": "workspace:*",
"@blocksuite/std": "workspace:*",
"@blocksuite/store": "workspace:*",
"lit": "^3.2.0",
"zod": "^3.25.76"
},
"exports": {
".": "./src/index.ts",
"./view": "./src/view.ts"
},
"files": [
"src",
"dist",
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.27.5"
}

View File

@@ -0,0 +1,150 @@
import { UserProvider } from '@blocksuite/affine-shared/services';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import type { BlockStdScope } from '@blocksuite/std';
import { ShadowlessElement } from '@blocksuite/std';
import {
ZERO_WIDTH_FOR_EMBED_NODE,
ZERO_WIDTH_FOR_EMPTY_LINE,
} from '@blocksuite/std/inline';
import type { DeltaInsert } from '@blocksuite/store';
import { css, html } from 'lit';
import { property } from 'lit/decorators.js';
export class AffineMention extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
static override styles = css`
.affine-mention {
color: ${unsafeCSSVarV2('text/primary')};
font-feature-settings:
'liga' off,
'clig' off;
/* Client/baseMedium */
font-family: Inter;
font-size: var(--affine-font-size-base);
font-style: normal;
font-weight: 500;
line-height: 24px; /* 160% */
padding: 0 4px;
border-radius: 4px;
user-select: none;
}
.affine-mention:hover {
background: var(--affine-hover-color);
}
.affine-mention[data-selected='true'] {
background: var(--affine-hover-color);
}
.affine-mention[data-type='default'] {
color: ${unsafeCSSVarV2('text/primary')};
}
.affine-mention[data-type='removed'] {
color: ${unsafeCSSVarV2('text/disable')};
}
.affine-mention[data-type='error'] {
color: ${unsafeCSSVarV2('text/disable')};
}
.affine-mention[data-type='loading'] {
color: ${unsafeCSSVarV2('text/placeholder')};
background: ${unsafeCSSVarV2('skeleton/skeleton')};
}
.loading-text {
display: inline-block;
}
.dots {
display: inline-block;
}
.dot {
display: inline-block;
opacity: 0;
animation: pulse 1.2s infinite;
}
.dots > .dot:nth-child(2) {
animation-delay: 0.4s;
}
.dots > .dot:nth-child(3) {
animation-delay: 0.8s;
}
@keyframes pulse {
0% {
opacity: 0;
}
33.333% {
opacity: 1;
}
66.666% {
opacity: 1;
}
100% {
opacity: 0;
}
}
`;
override render() {
const errorContent = html`<span
data-selected=${this.selected}
data-type="error"
class="affine-mention"
>@Unknown Member<v-text .str=${ZERO_WIDTH_FOR_EMBED_NODE}></v-text
></span>`;
const userService = this.std.getOptional(UserProvider);
const memberId = this.delta.attributes?.mention?.member;
if (!userService || !memberId) {
return errorContent;
}
userService.revalidateUserInfo(memberId);
const isLoading$ = userService.isLoading$(memberId);
const userInfo$ = userService.userInfo$(memberId);
if (userInfo$.value) {
if (userInfo$.value.removed) {
return html`<span
data-selected=${this.selected}
data-type="removed"
class="affine-mention"
>@Inactive Member<v-text .str=${ZERO_WIDTH_FOR_EMBED_NODE}></v-text
></span>`;
} else {
return html`<span
data-selected=${this.selected}
data-type="default"
class="affine-mention"
>@${userInfo$.value.name ?? 'Unknown'}<v-text
.str=${ZERO_WIDTH_FOR_EMBED_NODE}
></v-text
></span>`;
}
}
if (isLoading$.value) {
return html`<span
data-selected=${this.selected}
data-type="loading"
class="affine-mention"
>@loading<span class="dots"
><span class="dot">.</span><span class="dot">.</span
><span class="dot">.</span></span
><v-text .str=${ZERO_WIDTH_FOR_EMBED_NODE}></v-text
></span>`;
}
return errorContent;
}
@property({ type: Object })
accessor delta: DeltaInsert<AffineTextAttributes> = {
insert: ZERO_WIDTH_FOR_EMPTY_LINE,
attributes: {},
};
@property({ type: Boolean })
accessor selected = false;
@property({ attribute: false })
accessor std!: BlockStdScope;
}

View File

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

View File

@@ -0,0 +1,3 @@
export * from './affine-mention';
export * from './effects';
export * from './inline-spec';

View File

@@ -0,0 +1,34 @@
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { StdIdentifier } from '@blocksuite/std';
import { InlineSpecExtension } from '@blocksuite/std/inline';
import { html } from 'lit';
import { z } from 'zod';
export const MentionInlineSpecExtension =
InlineSpecExtension<AffineTextAttributes>('mention', provider => {
const std = provider.get(StdIdentifier);
return {
name: 'mention',
schema: z.object({
mention: z
.object({
member: z.string(),
notification: z.string().optional(),
})
.optional()
.nullable()
.catch(undefined),
}),
match: delta => {
return !!delta.attributes?.mention?.member;
},
renderer: ({ delta, selected }) => {
return html`<affine-mention
.delta=${delta}
.std=${std}
.selected=${selected}
></affine-mention>`;
},
embed: true,
};
});

View File

@@ -0,0 +1,21 @@
import {
type ViewExtensionContext,
ViewExtensionProvider,
} from '@blocksuite/affine-ext-loader';
import { effects } from './effects';
import { MentionInlineSpecExtension } from './inline-spec';
export class MentionViewExtension extends ViewExtensionProvider {
override name = 'affine-mention-inline';
override effect(): void {
super.effect();
effects();
}
override setup(context: ViewExtensionContext): void {
super.setup(context);
context.register(MentionInlineSpecExtension);
}
}

View File

@@ -0,0 +1,16 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src"],
"references": [
{ "path": "../../ext-loader" },
{ "path": "../../shared" },
{ "path": "../../../framework/global" },
{ "path": "../../../framework/std" },
{ "path": "../../../framework/store" }
]
}

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,
};
}

Some files were not shown because too many files have changed in this diff Show More