snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 4s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Analyze (javascript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Some checks failed
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 4s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Analyze (javascript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
This commit is contained in:
36
blocksuite/affine/rich-text/package.json
Normal file
36
blocksuite/affine/rich-text/package.json
Normal 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"
|
||||
}
|
||||
35
blocksuite/affine/rich-text/src/align.ts
Normal file
35
blocksuite/affine/rich-text/src/align.ts
Normal 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(),
|
||||
},
|
||||
];
|
||||
148
blocksuite/affine/rich-text/src/conversion.ts
Normal file
148
blocksuite/affine/rich-text/src/conversion.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
172
blocksuite/affine/rich-text/src/dom.ts
Normal file
172
blocksuite/affine/rich-text/src/dom.ts
Normal 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);
|
||||
}
|
||||
11
blocksuite/affine/rich-text/src/effects.ts
Normal file
11
blocksuite/affine/rich-text/src/effects.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
116
blocksuite/affine/rich-text/src/hooks.ts
Normal file
116
blocksuite/affine/rich-text/src/hooks.ts
Normal 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);
|
||||
};
|
||||
15
blocksuite/affine/rich-text/src/index.ts
Normal file
15
blocksuite/affine/rich-text/src/index.ts
Normal 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';
|
||||
480
blocksuite/affine/rich-text/src/rich-text.ts
Normal file
480
blocksuite/affine/rich-text/src/rich-text.ts
Normal 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;
|
||||
}
|
||||
70
blocksuite/affine/rich-text/src/utils.ts
Normal file
70
blocksuite/affine/rich-text/src/utils.ts
Normal 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);
|
||||
}
|
||||
17
blocksuite/affine/rich-text/tsconfig.json
Normal file
17
blocksuite/affine/rich-text/tsconfig.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user