snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 4s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Analyze (javascript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-16 00:31:07 +05:00
commit 50f788e799
7907 changed files with 988785 additions and 0 deletions

View File

@@ -0,0 +1,36 @@
{
"name": "@blocksuite/affine-rich-text",
"description": "Inline rich text for BlockSuite.",
"type": "module",
"scripts": {
"build": "yarn run -T tsc"
},
"sideEffects": false,
"keywords": [],
"author": "toeverything",
"license": "MIT",
"dependencies": {
"@blocksuite/affine-components": "workspace:*",
"@blocksuite/affine-model": "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",
"lit": "^3.2.0",
"yjs": "^13.6.27",
"zod": "^3.25.76"
},
"exports": {
".": "./src/index.ts",
"./effects": "./src/effects.ts"
},
"files": [
"src",
"dist",
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.27.5"
}

View File

@@ -0,0 +1,35 @@
import { TextAlign } from '@blocksuite/affine-model';
import {
TextAlignCenterIcon,
TextAlignLeftIcon,
TextAlignRightIcon,
} from '@blocksuite/icons/lit';
import type { TemplateResult } from 'lit';
export interface TextAlignConfig {
textAlign: TextAlign;
name: string;
hotkey: string[] | null;
icon: TemplateResult<1>;
}
export const textAlignConfigs: TextAlignConfig[] = [
{
textAlign: TextAlign.Left,
name: 'Align left',
hotkey: [`Mod-Shift-L`],
icon: TextAlignLeftIcon(),
},
{
textAlign: TextAlign.Center,
name: 'Align center',
hotkey: [`Mod-Shift-E`],
icon: TextAlignCenterIcon(),
},
{
textAlign: TextAlign.Right,
name: 'Align right',
hotkey: [`Mod-Shift-R`],
icon: TextAlignRightIcon(),
},
];

View File

@@ -0,0 +1,148 @@
import {
BulletedListIcon,
CheckBoxIcon,
CodeBlockIcon,
DividerIcon,
Heading1Icon,
Heading2Icon,
Heading3Icon,
Heading4Icon,
Heading5Icon,
Heading6Icon,
NumberedListIcon,
QuoteIcon,
TextIcon,
} from '@blocksuite/affine-components/icons';
import { TeXIcon } from '@blocksuite/icons/lit';
import type { TemplateResult } from 'lit';
/**
* Text primitive entries used in slash menu and format bar,
* which are also used for registering hotkeys for converting block flavours.
*/
export interface TextConversionConfig {
flavour: string;
type?: string;
name: string;
description?: string;
hotkey: string[] | null;
icon: TemplateResult<1>;
searchAlias?: string[];
}
export const textConversionConfigs: TextConversionConfig[] = [
{
flavour: 'affine:paragraph',
type: 'text',
name: 'Text',
description: 'Start typing with plain text.',
hotkey: [`Mod-Alt-0`, `Mod-Shift-0`],
icon: TextIcon,
},
{
flavour: 'affine:paragraph',
type: 'h1',
name: 'Heading 1',
description: 'Headings in the largest font.',
hotkey: [`Mod-Alt-1`, `Mod-Shift-1`],
icon: Heading1Icon,
},
{
flavour: 'affine:paragraph',
type: 'h2',
name: 'Heading 2',
description: 'Headings in the 2nd font size.',
hotkey: [`Mod-Alt-2`, `Mod-Shift-2`],
icon: Heading2Icon,
},
{
flavour: 'affine:paragraph',
type: 'h3',
name: 'Heading 3',
description: 'Headings in the 3rd font size.',
hotkey: [`Mod-Alt-3`, `Mod-Shift-3`],
icon: Heading3Icon,
},
{
flavour: 'affine:paragraph',
type: 'h4',
name: 'Heading 4',
description: 'Headings in the 4th font size.',
hotkey: [`Mod-Alt-4`, `Mod-Shift-4`],
icon: Heading4Icon,
},
{
flavour: 'affine:paragraph',
type: 'h5',
name: 'Heading 5',
description: 'Headings in the 5th font size.',
hotkey: [`Mod-Alt-5`, `Mod-Shift-5`],
icon: Heading5Icon,
},
{
flavour: 'affine:paragraph',
type: 'h6',
name: 'Heading 6',
description: 'Headings in the 6th font size.',
hotkey: [`Mod-Alt-6`, `Mod-Shift-6`],
icon: Heading6Icon,
},
{
flavour: 'affine:list',
type: 'bulleted',
name: 'Bulleted List',
description: 'Create a bulleted list.',
hotkey: [`Mod-Alt-8`, `Mod-Shift-8`],
icon: BulletedListIcon,
},
{
flavour: 'affine:list',
type: 'numbered',
name: 'Numbered List',
description: 'Create a numbered list.',
hotkey: [`Mod-Alt-9`, `Mod-Shift-9`],
icon: NumberedListIcon,
},
{
flavour: 'affine:list',
type: 'todo',
name: 'To-do List',
description: 'Add tasks to a to-do list.',
searchAlias: ['checkbox'],
hotkey: null,
icon: CheckBoxIcon,
},
{
flavour: 'affine:code',
type: undefined,
name: 'Code Block',
description: 'Code snippet with formatting.',
hotkey: [`Mod-Alt-c`],
icon: CodeBlockIcon,
},
{
flavour: 'affine:latex',
type: undefined,
name: 'Equation',
description: 'Formula block with LaTeX rendering.',
hotkey: null,
icon: TeXIcon(),
searchAlias: ['mathBlock', 'equationBlock', 'latexBlock'],
},
{
flavour: 'affine:paragraph',
type: 'quote',
name: 'Quote',
description: 'Add a blockquote for emphasis.',
hotkey: null,
icon: QuoteIcon,
},
{
flavour: 'affine:divider',
type: 'divider',
name: 'Divider',
description: 'Visually separate content.',
hotkey: [`Mod-Alt-d`, `Mod-Shift-d`],
icon: DividerIcon,
},
];

View File

@@ -0,0 +1,172 @@
import { DatabaseBlockModel } from '@blocksuite/affine-model';
import {
asyncGetBlockComponent,
getCurrentNativeRange,
matchModels,
} from '@blocksuite/affine-shared/utils';
import { type BlockStdScope, TextSelection } from '@blocksuite/std';
import type { InlineEditor, InlineRange } from '@blocksuite/std/inline';
import { BlockModel } from '@blocksuite/store';
import type { RichText } from './rich-text.js';
/**
* In most cases, you not need RichText, you can use {@link getInlineEditorByModel} instead.
*/
export function getRichTextByModel(std: BlockStdScope, id: string) {
const blockComponent = std.view.getBlock(id);
const richText = blockComponent?.querySelector<RichText>('rich-text');
if (!richText) return null;
return richText;
}
export async function asyncGetRichText(std: BlockStdScope, id: string) {
const blockComponent = await asyncGetBlockComponent(std, id);
if (!blockComponent) return null;
await blockComponent.updateComplete;
const richText = blockComponent?.querySelector<RichText>('rich-text');
if (!richText) return null;
return richText;
}
export function getInlineEditorByModel(
std: BlockStdScope,
model: BlockModel | string
) {
const blockModel =
typeof model === 'string' ? std.store.getBlock(model)?.model : model;
if (!blockModel || matchModels(blockModel, [DatabaseBlockModel])) {
// Not support database model since it's may be have multiple inline editor instances.
// Support to enter the editing state through the Enter key in the database.
return null;
}
const richText = getRichTextByModel(std, blockModel.id);
if (!richText) return null;
return richText.inlineEditor;
}
export async function asyncSetInlineRange(
std: BlockStdScope,
model: BlockModel,
inlineRange: InlineRange
) {
const richText = await asyncGetRichText(std, model.id);
if (!richText) {
return;
}
await richText.updateComplete;
const inlineEditor = richText.inlineEditor;
if (!inlineEditor) {
return;
}
inlineEditor.setInlineRange(inlineRange);
}
export function focusTextModel(
std: BlockStdScope,
id: string,
offset: number = 0
) {
std.event.active = true;
selectTextModel(std, id, offset);
}
export function selectTextModel(
std: BlockStdScope,
id: string,
index: number = 0,
length: number = 0
) {
const { selection } = std;
selection.setGroup('note', [
selection.create(TextSelection, {
from: { blockId: id, index, length },
to: null,
}),
]);
}
export async function onModelTextUpdated(
std: BlockStdScope,
model: BlockModel,
callback?: (text: RichText) => void
) {
const richText = await asyncGetRichText(std, model.id);
if (!richText) {
console.error('RichText is not ready yet.');
return;
}
await richText.updateComplete;
const inlineEditor = richText.inlineEditor;
if (!inlineEditor) {
console.error('Inline editor is not ready yet.');
return;
}
const subscription = inlineEditor.slots.renderComplete.subscribe(() => {
subscription.unsubscribe();
if (callback) {
callback(richText);
}
});
}
/**
* Remove specified text from the current range.
*/
export function cleanSpecifiedTail(
std: BlockStdScope,
inlineEditorOrModel: InlineEditor | BlockModel,
str: string
) {
if (!str) {
console.warn('Failed to clean text! Unexpected empty string');
return;
}
const inlineEditor =
inlineEditorOrModel instanceof BlockModel
? getInlineEditorByModel(std, inlineEditorOrModel)
: inlineEditorOrModel;
if (!inlineEditor) {
return;
}
const inlineRange = inlineEditor.getInlineRange();
if (!inlineRange) {
return;
}
const idx = inlineRange.index - str.length;
const textStr = inlineEditor.yText.toString().slice(idx, idx + str.length);
if (textStr !== str) {
console.warn(
`Failed to clean text! Text mismatch expected: ${str} but actual: ${textStr}`
);
return;
}
inlineEditor.deleteText({ index: idx, length: str.length });
inlineEditor.setInlineRange({
index: idx,
length: 0,
});
}
export function getTextContentFromInlineRange(
inlineEditor: InlineEditor,
startRange: InlineRange | null
) {
const nativeRange = getCurrentNativeRange();
if (!nativeRange) {
return null;
}
if (nativeRange.startContainer !== nativeRange.endContainer) {
return null;
}
const curRange = inlineEditor.getInlineRange();
if (!startRange || !curRange) {
return null;
}
if (curRange.index < startRange.index) {
return null;
}
const text = inlineEditor.yText.toString();
return text.slice(startRange.index, curRange.index);
}

View File

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

View File

@@ -0,0 +1,116 @@
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { isStrictUrl } from '@blocksuite/affine-shared/utils';
import type {
BeforeinputHookCtx,
CompositionEndHookCtx,
HookContext,
} from '@blocksuite/std/inline';
const EDGE_IGNORED_ATTRIBUTES = ['code', 'link'] as const;
const GLOBAL_IGNORED_ATTRIBUTES = [] as const;
const autoIdentifyLink = (ctx: HookContext<AffineTextAttributes>) => {
// auto identify link only on pressing space
if (ctx.data !== ' ') {
return;
}
// space is typed at the end of link, remove the link attribute on typed space
if (ctx.attributes?.link) {
if (ctx.inlineRange.index === ctx.inlineEditor.yText.length) {
delete ctx.attributes['link'];
}
return;
}
const lineInfo = ctx.inlineEditor.getLine(ctx.inlineRange.index);
if (!lineInfo) {
return;
}
const { line, lineIndex, rangeIndexRelatedToLine } = lineInfo;
if (lineIndex !== 0) {
return;
}
const verifyData = line.vTextContent
.slice(0, rangeIndexRelatedToLine)
.split(' ');
const verifyStr = verifyData[verifyData.length - 1];
const isUrl = isStrictUrl(verifyStr);
if (!isUrl) {
return;
}
const startIndex = ctx.inlineRange.index - verifyStr.length;
ctx.inlineEditor.formatText(
{
index: startIndex,
length: verifyStr.length,
},
{
link: verifyStr,
}
);
};
function handleExtendedAttributes(
ctx:
| BeforeinputHookCtx<AffineTextAttributes>
| CompositionEndHookCtx<AffineTextAttributes>
) {
const { data, inlineEditor, inlineRange } = ctx;
const deltas = inlineEditor.getDeltasByInlineRange(inlineRange);
// oxlint-disable-next-line sonarjs/no-collapsible-if
if (data && data.length > 0 && data !== '\n') {
if (
// cursor is in the between of two deltas
(deltas.length > 1 ||
// cursor is in the end of line or in the middle of a delta
(deltas.length === 1 && inlineRange.index !== 0)) &&
!inlineEditor.isEmbed(deltas[0][0]) // embeds should not be extended
) {
// each new text inserted by inline editor will not contain any attributes,
// but we want to keep the attributes of previous text or current text where the cursor is in
// here are two cases:
// 1. aaa**b|bb**ccc --input 'd'--> aaa**bdbb**ccc, d should extend the bold attribute
// 2. aaa**bbb|**ccc --input 'd'--> aaa**bbbd**ccc, d should extend the bold attribute
const { attributes } = deltas[0][0];
if (
deltas.length !== 1 ||
inlineRange.index === inlineEditor.yText.length
) {
// `EDGE_IGNORED_ATTRIBUTES` is which attributes should be ignored in case 2
EDGE_IGNORED_ATTRIBUTES.forEach(attr => {
delete attributes?.[attr];
});
}
// `GLOBAL_IGNORED_ATTRIBUTES` is which attributes should be ignored in case 1, 2
GLOBAL_IGNORED_ATTRIBUTES.forEach(attr => {
delete attributes?.[attr];
});
ctx.attributes = attributes ?? {};
}
}
return ctx;
}
export const onVBeforeinput = (
ctx: BeforeinputHookCtx<AffineTextAttributes>
) => {
handleExtendedAttributes(ctx);
autoIdentifyLink(ctx);
};
export const onVCompositionEnd = (
ctx: CompositionEndHookCtx<AffineTextAttributes>
) => {
handleExtendedAttributes(ctx);
};

View File

@@ -0,0 +1,15 @@
export { type TextAlignConfig, textAlignConfigs } from './align';
export { type TextConversionConfig, textConversionConfigs } from './conversion';
export {
asyncGetRichText,
asyncSetInlineRange,
cleanSpecifiedTail,
focusTextModel,
getInlineEditorByModel,
getRichTextByModel,
getTextContentFromInlineRange,
onModelTextUpdated,
selectTextModel,
} from './dom';
export { RichText } from './rich-text';
export * from './utils';

View File

@@ -0,0 +1,480 @@
import type {
AffineInlineEditor,
AffineTextAttributes,
} from '@blocksuite/affine-shared/types';
import { WithDisposable } from '@blocksuite/global/lit';
import { ShadowlessElement } from '@blocksuite/std';
import {
type AttributeRenderer,
InlineEditor,
type InlineMarkdownMatch,
type InlineRange,
type InlineRangeProvider,
type VLine,
} from '@blocksuite/std/inline';
import type { DeltaInsert } from '@blocksuite/store';
import { Text } from '@blocksuite/store';
import { effect, signal } from '@preact/signals-core';
import { css, html, type TemplateResult } from 'lit';
import { property, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import * as Y from 'yjs';
import { z } from 'zod';
import { onVBeforeinput, onVCompositionEnd } from './hooks.js';
import { getPrefixText } from './utils.js';
interface RichTextStackItem {
meta: Map<'richtext-v-range', InlineRange | null>;
}
export class RichText extends WithDisposable(ShadowlessElement) {
static override styles = css`
rich-text {
display: block;
height: 100%;
width: 100%;
overflow-x: auto;
overflow-y: hidden;
scroll-margin-top: 50px;
scroll-margin-bottom: 30px;
}
.inline-editor {
height: 100%;
width: 100%;
outline: none;
cursor: text;
}
.inline-editor.readonly {
cursor: default;
}
rich-text .nowrap-lines v-text span,
rich-text .nowrap-lines v-element span {
white-space: pre !important;
}
`;
#verticalScrollContainer: HTMLElement | null = null;
private readonly _inlineEditor$ = signal<AffineInlineEditor | null>(null);
private readonly _onCopy = (e: ClipboardEvent) => {
const inlineEditor = this.inlineEditor;
if (!inlineEditor) return;
const inlineRange = inlineEditor.getInlineRange();
if (!inlineRange) return;
const text = inlineEditor.yTextString.slice(
inlineRange.index,
inlineRange.index + inlineRange.length
);
e.clipboardData?.setData('text/plain', text);
e.preventDefault();
e.stopPropagation();
};
private readonly _onCut = (e: ClipboardEvent) => {
const inlineEditor = this.inlineEditor;
if (!inlineEditor) return;
const inlineRange = inlineEditor.getInlineRange();
if (!inlineRange) return;
const text = inlineEditor.yTextString.slice(
inlineRange.index,
inlineRange.index + inlineRange.length
);
inlineEditor.deleteText(inlineRange);
inlineEditor.setInlineRange({
index: inlineRange.index,
length: 0,
});
e.clipboardData?.setData('text/plain', text);
e.preventDefault();
e.stopPropagation();
};
private readonly _onPaste = (e: ClipboardEvent) => {
const inlineEditor = this.inlineEditor;
if (!inlineEditor) return;
const inlineRange = inlineEditor.getInlineRange();
if (!inlineRange) return;
const text = e.clipboardData
?.getData('text/plain')
?.replace(/\r?\n|\r/g, '\n');
if (!text) return;
inlineEditor.insertText(inlineRange, text);
inlineEditor.setInlineRange({
index: inlineRange.index + text.length,
length: 0,
});
e.preventDefault();
e.stopPropagation();
};
private readonly _onStackItemAdded = (event: {
stackItem: RichTextStackItem;
}) => {
const inlineRange = this.inlineEditor?.getInlineRange();
if (inlineRange) {
event.stackItem.meta.set('richtext-v-range', inlineRange);
}
};
private readonly _onStackItemPopped = (event: {
stackItem: RichTextStackItem;
}) => {
const inlineRange = event.stackItem.meta.get('richtext-v-range');
if (inlineRange && this.inlineEditor?.isValidInlineRange(inlineRange)) {
this.inlineEditor?.setInlineRange(inlineRange);
}
};
private get _yText() {
return this.yText instanceof Text ? this.yText.yText : this.yText;
}
// It will listen ctrl+z/ctrl+shift+z and call undoManager.undo/redo, keydown event will not
get inlineEditor() {
return this._inlineEditor$.value;
}
get inlineEditorContainer() {
return this._inlineEditorContainer;
}
private _init() {
if (this.inlineEditor) {
console.error('Inline editor already exists.');
return;
}
if (!this.enableFormat) {
this.attributesSchema = z.object({});
}
// init inline editor
this._inlineEditor$.value = new InlineEditor<AffineTextAttributes>(
this._yText,
{
isEmbed: delta => this.embedChecker(delta),
hooks: {
beforeinput: onVBeforeinput,
compositionEnd: onVCompositionEnd,
},
inlineRangeProvider: this.inlineRangeProvider,
vLineRenderer: this.vLineRenderer,
}
);
const inlineEditor = this._inlineEditor$.value;
if (this.attributesSchema) {
inlineEditor.setAttributeSchema(this.attributesSchema);
}
if (this.attributeRenderer) {
inlineEditor.setAttributeRenderer(this.attributeRenderer);
}
const markdownMatches = this.markdownMatches;
if (markdownMatches) {
const markdownTransform = (isEnter: boolean = false) => {
let inlineRange = inlineEditor.getInlineRange();
if (!inlineRange) return false;
let prefixText = getPrefixText(inlineEditor);
if (isEnter) prefixText = `${prefixText} `;
for (const match of markdownMatches) {
const { pattern, action } = match;
if (prefixText.match(pattern)) {
if (isEnter) {
inlineEditor.insertText(
{
index: inlineRange.index,
length: 0,
},
' '
);
inlineEditor.setInlineRange({
index: inlineRange.index + 1,
length: 0,
});
inlineRange = inlineEditor.getInlineRange();
if (!inlineRange) return false;
}
action({
inlineEditor,
prefixText,
inlineRange,
pattern,
undoManager: this.undoManager,
});
return true;
}
}
return false;
};
inlineEditor.disposables.add(
inlineEditor.slots.inputting.subscribe(data => {
if (!inlineEditor.isComposing && data === ' ') {
markdownTransform();
}
})
);
inlineEditor.disposables.add(
inlineEditor.slots.keydown.subscribe(event => {
if (event.key === 'Enter' && markdownTransform(true)) {
event.stopPropagation();
event.preventDefault();
}
})
);
}
// init auto scroll
inlineEditor.disposables.add(
effect(() => {
const inlineRange = inlineEditor.inlineRange$.value;
if (!inlineRange) return;
// lazy
const verticalScrollContainer =
this.#verticalScrollContainer ||
(this.#verticalScrollContainer =
this.verticalScrollContainerGetter?.() || null);
inlineEditor
.waitForUpdate()
.then(() => {
if (!inlineEditor.mounted || inlineEditor.rendering) return;
const range = inlineEditor.toDomRange(inlineRange);
if (!range) return;
if (verticalScrollContainer) {
const nativeRange = inlineEditor.getNativeRange();
if (
!nativeRange ||
nativeRange.commonAncestorContainer.parentElement?.contains(
inlineEditor.rootElement
)
)
return;
const containerRect =
verticalScrollContainer.getBoundingClientRect();
const rangeRect = range.getBoundingClientRect();
if (rangeRect.top < containerRect.top) {
this.scrollIntoView({ block: 'start' });
} else if (rangeRect.bottom > containerRect.bottom) {
this.scrollIntoView({ block: 'end' });
}
}
// scroll container is this
if (this.enableAutoScrollHorizontally) {
const containerRect = this.getBoundingClientRect();
const rangeRect = range.getBoundingClientRect();
let scrollLeft = this.scrollLeft;
if (
rangeRect.left + rangeRect.width >
containerRect.left + containerRect.width
) {
scrollLeft +=
rangeRect.left +
rangeRect.width -
(containerRect.left + containerRect.width) +
2;
}
this.scrollLeft = scrollLeft;
}
})
.catch(console.error);
})
);
inlineEditor.mount(
this.inlineEditorContainer,
this.inlineEventSource,
this.readonly
);
}
private _unmount() {
if (this.inlineEditor?.mounted) {
this.inlineEditor.unmount();
}
this._inlineEditor$.value = null;
}
override connectedCallback() {
super.connectedCallback();
if (!this._yText) {
console.error('rich-text need yText to init.');
return;
}
if (!this._yText.doc) {
console.error('yText should be bind to yDoc.');
return;
}
if (!this.undoManager) {
this.undoManager = new Y.UndoManager(this._yText, {
trackedOrigins: new Set([this._yText.doc.clientID]),
});
}
if (this.enableUndoRedo) {
this.disposables.addFromEvent(this, 'keydown', (e: KeyboardEvent) => {
// oxlint-disable-next-line sonarjs/no-collapsible-if
if (e.ctrlKey || e.metaKey) {
if (e.key === 'z' || e.key === 'Z') {
if (e.shiftKey) {
this.undoManager.redo();
} else {
this.undoManager.undo();
}
e.stopPropagation();
}
}
});
this.undoManager.on('stack-item-added', this._onStackItemAdded);
this.undoManager.on('stack-item-popped', this._onStackItemPopped);
this.disposables.add({
dispose: () => {
this.undoManager.off('stack-item-added', this._onStackItemAdded);
this.undoManager.off('stack-item-popped', this._onStackItemPopped);
},
});
}
if (this.enableClipboard) {
this.disposables.addFromEvent(this, 'copy', this._onCopy);
this.disposables.addFromEvent(this, 'cut', this._onCut);
this.disposables.addFromEvent(this, 'paste', this._onPaste);
}
this.updateComplete
.then(() => {
this._unmount();
this._init();
this.disposables.add({
dispose: () => {
this._unmount();
},
});
})
.catch(console.error);
}
override async getUpdateComplete(): Promise<boolean> {
const result = await super.getUpdateComplete();
await this.inlineEditor?.waitForUpdate();
return result;
}
// If it is true rich-text will handle undo/redo by itself. (including v-range restore)
override render() {
const classes = classMap({
'inline-editor': true,
'nowrap-lines': !this.wrapText,
readonly: this.readonly,
});
return html`<div
contenteditable=${this.readonly ? 'false' : 'true'}
class=${classes}
></div>`;
}
override updated(changedProperties: Map<string | number | symbol, unknown>) {
const inlineEditor = this.inlineEditor;
if (inlineEditor && this._yText && this._yText !== inlineEditor.yText) {
this._unmount();
this._init();
return;
}
if (this.inlineEditor && changedProperties.has('readonly')) {
this.inlineEditor.setReadonly(this.readonly);
}
}
@query('.inline-editor')
private accessor _inlineEditorContainer!: HTMLDivElement;
@property({ attribute: false })
accessor attributeRenderer: AttributeRenderer | undefined = undefined;
@property({ attribute: false })
accessor attributesSchema: z.ZodSchema | undefined = undefined;
@property({ attribute: false })
accessor embedChecker: <
TextAttributes extends AffineTextAttributes = AffineTextAttributes,
>(
delta: DeltaInsert<TextAttributes>
) => boolean = () => false;
@property({ attribute: false })
accessor enableAutoScrollHorizontally = true;
// If it is true rich-text will prevent events related to clipboard bubbling up and handle them by itself.
@property({ attribute: false })
accessor enableClipboard = true;
// `attributesSchema` will be overwritten to `z.object({})` if `enableFormat` is false.
@property({ attribute: false })
accessor enableFormat = true;
// bubble up if pressed ctrl+z/ctrl+shift+z.
@property({ attribute: false })
accessor enableUndoRedo = true;
@property({ attribute: false })
accessor inlineEventSource: HTMLElement | undefined = undefined;
@property({ attribute: false })
accessor inlineRangeProvider: InlineRangeProvider | undefined = undefined;
@property({ attribute: false })
accessor markdownMatches: InlineMarkdownMatch<AffineTextAttributes>[] = [];
@property({ attribute: false })
accessor readonly = false;
// rich-text will create a undoManager if it is not provided.
@property({ attribute: false })
accessor undoManager!: Y.UndoManager;
@property({ attribute: false })
accessor verticalScrollContainerGetter:
| (() => HTMLElement | null)
| undefined = undefined;
@property({ attribute: false })
accessor vLineRenderer: ((vLine: VLine) => TemplateResult) | undefined;
@property({ attribute: false })
accessor wrapText = true;
@property({ attribute: false })
accessor yText!: Y.Text | Text;
}

View File

@@ -0,0 +1,70 @@
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import type { BlockStdScope } from '@blocksuite/std';
import type { InlineEditor } from '@blocksuite/std/inline';
import type { BlockModel } from '@blocksuite/store';
import { effect } from '@preact/signals-core';
import { getInlineEditorByModel } from './dom';
export function insertContent(
std: BlockStdScope,
model: BlockModel,
text: string,
attributes?: AffineTextAttributes
) {
if (!model.text) {
console.error("Can't insert text! Text not found");
return;
}
const inlineEditor = getInlineEditorByModel(std, model);
if (!inlineEditor) {
console.error("Can't insert text! Inline editor not found");
return;
}
const inlineRange = inlineEditor.getInlineRange();
const index = inlineRange ? inlineRange.index : model.text.length;
model.text.insert(text, index, attributes as Record<string, unknown>);
// Update the caret to the end of the inserted text
inlineEditor.setInlineRange({
index: index + text.length,
length: 0,
});
}
// When the user selects a range, check if it matches the previous selection.
// If it does, apply the marks from the previous selection.
// If it does not, remove the marks from the previous selection.
export function clearMarksOnDiscontinuousInput(
inlineEditor: InlineEditor
): void {
let inlineRange = inlineEditor.getInlineRange();
const dispose = effect(() => {
const r = inlineEditor.inlineRange$.value;
if (
inlineRange &&
r &&
(inlineRange.index === r.index || inlineRange.index === r.index + 1)
) {
inlineRange = r;
} else {
inlineEditor.resetMarks();
dispose();
}
});
}
export function getPrefixText(inlineEditor: InlineEditor) {
const inlineRange = inlineEditor.getInlineRange();
if (!inlineRange || inlineRange.length > 0) return '';
const maxMarkdownPrefixLength = 512;
const prefixStart = Math.max(0, inlineRange.index - maxMarkdownPrefixLength);
const yTextString = inlineEditor.yTextString;
const prefixWindow = yTextString.slice(prefixStart, inlineRange.index);
const nearestLineBreakIndex = prefixWindow.lastIndexOf('\n');
if (nearestLineBreakIndex === -1 && prefixStart > 0) return '';
return nearestLineBreakIndex === -1
? prefixWindow
: prefixWindow.slice(nearestLineBreakIndex + 1);
}

View File

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