mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 21:27:20 +00:00
feat(editor): rich text package (#10689)
This PR performs a significant architectural refactoring by extracting rich text functionality into a dedicated package. Here are the key changes: 1. **New Package Creation** - Created a new package `@blocksuite/affine-rich-text` to house rich text related functionality - Moved rich text components, utilities, and types from `@blocksuite/affine-components` to this new package 2. **Dependency Updates** - Updated multiple block packages to include the new `@blocksuite/affine-rich-text` as a direct dependency: - block-callout - block-code - block-database - block-edgeless-text - block-embed - block-list - block-note - block-paragraph 3. **Import Path Updates** - Refactored all imports that previously referenced rich text functionality from `@blocksuite/affine-components/rich-text` to now use `@blocksuite/affine-rich-text` - Updated imports for components like: - DefaultInlineManagerExtension - RichText types and interfaces - Text manipulation utilities (focusTextModel, textKeymap, etc.) - Reference node components and providers 4. **Build Configuration Updates** - Added references to the new rich text package in the `tsconfig.json` files of all affected packages - Maintained workspace dependencies using the `workspace:*` version specifier The primary motivation appears to be: 1. Better separation of concerns by isolating rich text functionality 2. Improved maintainability through more modular package structure 3. Clearer dependencies between packages 4. Potential for better tree-shaking and bundle optimization This is primarily an architectural improvement that should make the codebase more maintainable and better organized.
This commit is contained in:
45
blocksuite/affine/rich-text/src/all-extensions.ts
Normal file
45
blocksuite/affine/rich-text/src/all-extensions.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
|
||||
import { InlineManagerExtension } from './extension/index.js';
|
||||
import {
|
||||
BackgroundInlineSpecExtension,
|
||||
BoldInlineSpecExtension,
|
||||
CodeInlineSpecExtension,
|
||||
ColorInlineSpecExtension,
|
||||
FootNoteInlineSpecExtension,
|
||||
InlineAdapterExtensions,
|
||||
InlineSpecExtensions,
|
||||
ItalicInlineSpecExtension,
|
||||
LatexInlineSpecExtension,
|
||||
LinkInlineSpecExtension,
|
||||
MarkdownExtensions,
|
||||
ReferenceInlineSpecExtension,
|
||||
StrikeInlineSpecExtension,
|
||||
UnderlineInlineSpecExtension,
|
||||
} from './inline/index.js';
|
||||
import { LatexEditorInlineManagerExtension } from './inline/presets/nodes/latex-node/latex-editor-menu.js';
|
||||
|
||||
export const DefaultInlineManagerExtension = InlineManagerExtension({
|
||||
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,
|
||||
],
|
||||
});
|
||||
|
||||
export const RichTextExtensions: ExtensionType[] = [
|
||||
InlineSpecExtensions,
|
||||
MarkdownExtensions,
|
||||
LatexEditorInlineManagerExtension,
|
||||
DefaultInlineManagerExtension,
|
||||
InlineAdapterExtensions,
|
||||
].flat();
|
||||
136
blocksuite/affine/rich-text/src/conversion.ts
Normal file
136
blocksuite/affine/rich-text/src/conversion.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
BulletedListIcon,
|
||||
CheckBoxIcon,
|
||||
CodeBlockIcon,
|
||||
DividerIcon,
|
||||
Heading1Icon,
|
||||
Heading2Icon,
|
||||
Heading3Icon,
|
||||
Heading4Icon,
|
||||
Heading5Icon,
|
||||
Heading6Icon,
|
||||
NumberedListIcon,
|
||||
QuoteIcon,
|
||||
TextIcon,
|
||||
} from '@blocksuite/affine-components/icons';
|
||||
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>;
|
||||
}
|
||||
|
||||
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.',
|
||||
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: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,
|
||||
},
|
||||
];
|
||||
177
blocksuite/affine/rich-text/src/dom.ts
Normal file
177
blocksuite/affine/rich-text/src/dom.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { DatabaseBlockModel } from '@blocksuite/affine-model';
|
||||
import {
|
||||
asyncGetBlockComponent,
|
||||
getCurrentNativeRange,
|
||||
matchModels,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import {
|
||||
type BlockStdScope,
|
||||
type EditorHost,
|
||||
TextSelection,
|
||||
} from '@blocksuite/block-std';
|
||||
import type { InlineEditor, InlineRange } from '@blocksuite/inline';
|
||||
import { BlockModel } from '@blocksuite/store';
|
||||
|
||||
import type { AffineInlineEditor } from './inline/index.js';
|
||||
import type { RichText } from './rich-text.js';
|
||||
|
||||
/**
|
||||
* In most cases, you not need RichText, you can use {@link getInlineEditorByModel} instead.
|
||||
*/
|
||||
export function getRichTextByModel(editorHost: EditorHost, id: string) {
|
||||
const blockComponent = editorHost.view.getBlock(id);
|
||||
const richText = blockComponent?.querySelector<RichText>('rich-text');
|
||||
if (!richText) return null;
|
||||
return richText;
|
||||
}
|
||||
|
||||
export async function asyncGetRichText(editorHost: EditorHost, id: string) {
|
||||
const blockComponent = await asyncGetBlockComponent(editorHost, id);
|
||||
if (!blockComponent) return null;
|
||||
await blockComponent.updateComplete;
|
||||
const richText = blockComponent?.querySelector<RichText>('rich-text');
|
||||
if (!richText) return null;
|
||||
return richText;
|
||||
}
|
||||
|
||||
export function getInlineEditorByModel(
|
||||
editorHost: EditorHost,
|
||||
model: BlockModel | string
|
||||
) {
|
||||
const blockModel =
|
||||
typeof model === 'string'
|
||||
? editorHost.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(editorHost, blockModel.id);
|
||||
if (!richText) return null;
|
||||
return richText.inlineEditor;
|
||||
}
|
||||
|
||||
export async function asyncSetInlineRange(
|
||||
editorHost: EditorHost,
|
||||
model: BlockModel,
|
||||
inlineRange: InlineRange
|
||||
) {
|
||||
const richText = await asyncGetRichText(editorHost, 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
|
||||
) {
|
||||
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(
|
||||
editorHost: EditorHost,
|
||||
model: BlockModel,
|
||||
callback?: (text: RichText) => void
|
||||
) {
|
||||
const richText = await asyncGetRichText(editorHost, 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;
|
||||
}
|
||||
inlineEditor.slots.renderComplete.once(() => {
|
||||
if (callback) {
|
||||
callback(richText);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove specified text from the current range.
|
||||
*/
|
||||
export function cleanSpecifiedTail(
|
||||
editorHost: EditorHost,
|
||||
inlineEditorOrModel: AffineInlineEditor | BlockModel,
|
||||
str: string
|
||||
) {
|
||||
if (!str) {
|
||||
console.warn('Failed to clean text! Unexpected empty string');
|
||||
return;
|
||||
}
|
||||
const inlineEditor =
|
||||
inlineEditorOrModel instanceof BlockModel
|
||||
? getInlineEditorByModel(editorHost, 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);
|
||||
}
|
||||
46
blocksuite/affine/rich-text/src/effects.ts
Normal file
46
blocksuite/affine/rich-text/src/effects.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
AffineFootnoteNode,
|
||||
AffineLink,
|
||||
AffineReference,
|
||||
} from './inline/index.js';
|
||||
import { AffineText } from './inline/presets/nodes/affine-text.js';
|
||||
import { FootNotePopup } from './inline/presets/nodes/footnote-node/footnote-popup.js';
|
||||
import { FootNotePopupChip } from './inline/presets/nodes/footnote-node/footnote-popup-chip.js';
|
||||
import { LatexEditorMenu } from './inline/presets/nodes/latex-node/latex-editor-menu.js';
|
||||
import { LatexEditorUnit } from './inline/presets/nodes/latex-node/latex-editor-unit.js';
|
||||
import { AffineLatexNode } from './inline/presets/nodes/latex-node/latex-node.js';
|
||||
import { LinkPopup } from './inline/presets/nodes/link-node/link-popup/link-popup.js';
|
||||
import { ReferencePopup } from './inline/presets/nodes/reference-node/reference-popup/reference-popup.js';
|
||||
import { RichText } from './rich-text.js';
|
||||
|
||||
export function effects() {
|
||||
customElements.define('affine-text', AffineText);
|
||||
customElements.define('latex-editor-menu', LatexEditorMenu);
|
||||
customElements.define('latex-editor-unit', LatexEditorUnit);
|
||||
customElements.define('rich-text', RichText);
|
||||
customElements.define('affine-latex-node', AffineLatexNode);
|
||||
customElements.define('link-popup', LinkPopup);
|
||||
customElements.define('affine-link', AffineLink);
|
||||
customElements.define('reference-popup', ReferencePopup);
|
||||
customElements.define('affine-reference', AffineReference);
|
||||
customElements.define('affine-footnote-node', AffineFootnoteNode);
|
||||
customElements.define('footnote-popup', FootNotePopup);
|
||||
customElements.define('footnote-popup-chip', FootNotePopupChip);
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-latex-node': AffineLatexNode;
|
||||
'affine-reference': AffineReference;
|
||||
'affine-footnote-node': AffineFootnoteNode;
|
||||
'footnote-popup': FootNotePopup;
|
||||
'footnote-popup-chip': FootNotePopupChip;
|
||||
'affine-link': AffineLink;
|
||||
'affine-text': AffineText;
|
||||
'rich-text': RichText;
|
||||
'reference-popup': ReferencePopup;
|
||||
'latex-editor-unit': LatexEditorUnit;
|
||||
'latex-editor-menu': LatexEditorMenu;
|
||||
'link-popup': LinkPopup;
|
||||
}
|
||||
}
|
||||
5
blocksuite/affine/rich-text/src/extension/index.ts
Normal file
5
blocksuite/affine/rich-text/src/extension/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './inline-manager.js';
|
||||
export * from './inline-spec.js';
|
||||
export * from './markdown-matcher.js';
|
||||
export * from './ref-node-slots.js';
|
||||
export * from './type.js';
|
||||
104
blocksuite/affine/rich-text/src/extension/inline-manager.ts
Normal file
104
blocksuite/affine/rich-text/src/extension/inline-manager.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import { type BlockStdScope, StdIdentifier } from '@blocksuite/block-std';
|
||||
import {
|
||||
createIdentifier,
|
||||
type ServiceIdentifier,
|
||||
} from '@blocksuite/global/di';
|
||||
import {
|
||||
type AttributeRenderer,
|
||||
baseTextAttributes,
|
||||
type DeltaInsert,
|
||||
getDefaultAttributeRenderer,
|
||||
} from '@blocksuite/inline';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
import { z, type ZodObject, type ZodTypeAny } from 'zod';
|
||||
|
||||
import { MarkdownMatcherIdentifier } from './markdown-matcher.js';
|
||||
import type { InlineMarkdownMatch, InlineSpecs } from './type.js';
|
||||
|
||||
export class InlineManager {
|
||||
embedChecker = (delta: DeltaInsert<AffineTextAttributes>) => {
|
||||
for (const spec of this.specs) {
|
||||
if (spec.embed && spec.match(delta)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
getRenderer = (): AttributeRenderer<AffineTextAttributes> => {
|
||||
const defaultRenderer = getDefaultAttributeRenderer<AffineTextAttributes>();
|
||||
|
||||
const renderer: AttributeRenderer<AffineTextAttributes> = props => {
|
||||
// Priority increases from front to back
|
||||
for (const spec of this.specs.toReversed()) {
|
||||
if (spec.match(props.delta)) {
|
||||
return spec.renderer(props);
|
||||
}
|
||||
}
|
||||
return defaultRenderer(props);
|
||||
};
|
||||
return renderer;
|
||||
};
|
||||
|
||||
getSchema = (): ZodObject<Record<keyof AffineTextAttributes, ZodTypeAny>> => {
|
||||
const defaultSchema = baseTextAttributes as unknown as ZodObject<
|
||||
Record<keyof AffineTextAttributes, ZodTypeAny>
|
||||
>;
|
||||
|
||||
const schema: ZodObject<Record<keyof AffineTextAttributes, ZodTypeAny>> =
|
||||
this.specs.reduce((acc, cur) => {
|
||||
const currentSchema = z.object({
|
||||
[cur.name]: cur.schema,
|
||||
}) as ZodObject<Record<keyof AffineTextAttributes, ZodTypeAny>>;
|
||||
return acc.merge(currentSchema) as ZodObject<
|
||||
Record<keyof AffineTextAttributes, ZodTypeAny>
|
||||
>;
|
||||
}, defaultSchema);
|
||||
return schema;
|
||||
};
|
||||
|
||||
readonly specs: Array<InlineSpecs<AffineTextAttributes>>;
|
||||
|
||||
constructor(
|
||||
readonly std: BlockStdScope,
|
||||
readonly markdownMatches: InlineMarkdownMatch<AffineTextAttributes>[],
|
||||
...specs: Array<InlineSpecs<AffineTextAttributes>>
|
||||
) {
|
||||
this.specs = specs;
|
||||
}
|
||||
}
|
||||
|
||||
export const InlineManagerIdentifier = createIdentifier<InlineManager>(
|
||||
'AffineInlineManager'
|
||||
);
|
||||
|
||||
export type InlineManagerExtensionConfig = {
|
||||
id: string;
|
||||
enableMarkdown?: boolean;
|
||||
specs: ServiceIdentifier<InlineSpecs<AffineTextAttributes>>[];
|
||||
};
|
||||
|
||||
export function InlineManagerExtension({
|
||||
id,
|
||||
enableMarkdown = true,
|
||||
specs,
|
||||
}: InlineManagerExtensionConfig): ExtensionType & {
|
||||
identifier: ServiceIdentifier<InlineManager>;
|
||||
} {
|
||||
const identifier = InlineManagerIdentifier(id);
|
||||
return {
|
||||
setup: di => {
|
||||
di.addImpl(identifier, provider => {
|
||||
return new InlineManager(
|
||||
provider.get(StdIdentifier),
|
||||
enableMarkdown
|
||||
? Array.from(provider.getAll(MarkdownMatcherIdentifier).values())
|
||||
: [],
|
||||
...specs.map(spec => provider.get(spec))
|
||||
);
|
||||
});
|
||||
},
|
||||
identifier,
|
||||
};
|
||||
}
|
||||
47
blocksuite/affine/rich-text/src/extension/inline-spec.ts
Normal file
47
blocksuite/affine/rich-text/src/extension/inline-spec.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import {
|
||||
createIdentifier,
|
||||
type ServiceIdentifier,
|
||||
type ServiceProvider,
|
||||
} from '@blocksuite/global/di';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
|
||||
import type { InlineSpecs } from './type.js';
|
||||
|
||||
export const InlineSpecIdentifier =
|
||||
createIdentifier<InlineSpecs<AffineTextAttributes>>('AffineInlineSpec');
|
||||
|
||||
export function InlineSpecExtension(
|
||||
name: string,
|
||||
getSpec: (provider: ServiceProvider) => InlineSpecs<AffineTextAttributes>
|
||||
): ExtensionType & {
|
||||
identifier: ServiceIdentifier<InlineSpecs<AffineTextAttributes>>;
|
||||
};
|
||||
export function InlineSpecExtension(
|
||||
spec: InlineSpecs<AffineTextAttributes>
|
||||
): ExtensionType & {
|
||||
identifier: ServiceIdentifier<InlineSpecs<AffineTextAttributes>>;
|
||||
};
|
||||
export function InlineSpecExtension(
|
||||
nameOrSpec: string | InlineSpecs<AffineTextAttributes>,
|
||||
getSpec?: (provider: ServiceProvider) => InlineSpecs<AffineTextAttributes>
|
||||
): ExtensionType & {
|
||||
identifier: ServiceIdentifier<InlineSpecs<AffineTextAttributes>>;
|
||||
} {
|
||||
if (typeof nameOrSpec === 'string') {
|
||||
const identifier = InlineSpecIdentifier(nameOrSpec);
|
||||
return {
|
||||
identifier,
|
||||
setup: di => {
|
||||
di.addImpl(identifier, provider => getSpec!(provider));
|
||||
},
|
||||
};
|
||||
}
|
||||
const identifier = InlineSpecIdentifier(nameOrSpec.name);
|
||||
return {
|
||||
identifier,
|
||||
setup: di => {
|
||||
di.addImpl(identifier, nameOrSpec);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import {
|
||||
createIdentifier,
|
||||
type ServiceIdentifier,
|
||||
} from '@blocksuite/global/di';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
|
||||
import type { InlineMarkdownMatch } from './type.js';
|
||||
|
||||
export const MarkdownMatcherIdentifier = createIdentifier<
|
||||
InlineMarkdownMatch<AffineTextAttributes>
|
||||
>('AffineMarkdownMatcher');
|
||||
|
||||
export function InlineMarkdownExtension(
|
||||
matcher: InlineMarkdownMatch<AffineTextAttributes>
|
||||
): ExtensionType & {
|
||||
identifier: ServiceIdentifier<InlineMarkdownMatch<AffineTextAttributes>>;
|
||||
} {
|
||||
const identifier = MarkdownMatcherIdentifier(matcher.name);
|
||||
|
||||
return {
|
||||
setup: di => {
|
||||
di.addImpl(identifier, () => ({ ...matcher }));
|
||||
},
|
||||
identifier,
|
||||
};
|
||||
}
|
||||
18
blocksuite/affine/rich-text/src/extension/ref-node-slots.ts
Normal file
18
blocksuite/affine/rich-text/src/extension/ref-node-slots.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { createIdentifier } from '@blocksuite/global/di';
|
||||
import { Slot } from '@blocksuite/global/slot';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
|
||||
import type { RefNodeSlots } from '../inline/index.js';
|
||||
|
||||
export const RefNodeSlotsProvider =
|
||||
createIdentifier<RefNodeSlots>('AffineRefNodeSlots');
|
||||
|
||||
const slots: RefNodeSlots = {
|
||||
docLinkClicked: new Slot(),
|
||||
};
|
||||
|
||||
export const RefNodeSlotsExtension: ExtensionType = {
|
||||
setup: di => {
|
||||
di.addImpl(RefNodeSlotsProvider, () => slots);
|
||||
},
|
||||
};
|
||||
38
blocksuite/affine/rich-text/src/extension/type.ts
Normal file
38
blocksuite/affine/rich-text/src/extension/type.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type {
|
||||
AttributeRenderer,
|
||||
BaseTextAttributes,
|
||||
DeltaInsert,
|
||||
InlineEditor,
|
||||
InlineRange,
|
||||
} from '@blocksuite/inline';
|
||||
import type * as Y from 'yjs';
|
||||
import type { ZodTypeAny } from 'zod';
|
||||
|
||||
export type InlineSpecs<
|
||||
AffineTextAttributes extends BaseTextAttributes = BaseTextAttributes,
|
||||
> = {
|
||||
name: keyof AffineTextAttributes | string;
|
||||
schema: ZodTypeAny;
|
||||
match: (delta: DeltaInsert<AffineTextAttributes>) => boolean;
|
||||
renderer: AttributeRenderer<AffineTextAttributes>;
|
||||
embed?: boolean;
|
||||
};
|
||||
|
||||
export type InlineMarkdownMatchAction<
|
||||
// @ts-expect-error We allow to covariance for AffineTextAttributes
|
||||
in AffineTextAttributes extends BaseTextAttributes = BaseTextAttributes,
|
||||
> = (props: {
|
||||
inlineEditor: InlineEditor<AffineTextAttributes>;
|
||||
prefixText: string;
|
||||
inlineRange: InlineRange;
|
||||
pattern: RegExp;
|
||||
undoManager: Y.UndoManager;
|
||||
}) => void;
|
||||
|
||||
export type InlineMarkdownMatch<
|
||||
AffineTextAttributes extends BaseTextAttributes = BaseTextAttributes,
|
||||
> = {
|
||||
name: string;
|
||||
pattern: RegExp;
|
||||
action: InlineMarkdownMatchAction<AffineTextAttributes>;
|
||||
};
|
||||
138
blocksuite/affine/rich-text/src/format/config.ts
Normal file
138
blocksuite/affine/rich-text/src/format/config.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import {
|
||||
BoldIcon,
|
||||
CodeIcon,
|
||||
ItalicIcon,
|
||||
LinkIcon,
|
||||
StrikethroughIcon,
|
||||
UnderlineIcon,
|
||||
} from '@blocksuite/affine-components/icons';
|
||||
import { type EditorHost, TextSelection } from '@blocksuite/block-std';
|
||||
import type { TemplateResult } from 'lit';
|
||||
|
||||
import {
|
||||
isTextStyleActive,
|
||||
toggleBold,
|
||||
toggleCode,
|
||||
toggleItalic,
|
||||
toggleLink,
|
||||
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(isTextStyleActive, { 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(isTextStyleActive, { 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(isTextStyleActive, { 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(isTextStyleActive, { 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(isTextStyleActive, { 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(isTextStyleActive, { 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)
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
19
blocksuite/affine/rich-text/src/format/consts.ts
Normal file
19
blocksuite/affine/rich-text/src/format/consts.ts
Normal 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,
|
||||
];
|
||||
81
blocksuite/affine/rich-text/src/format/delete-text.ts
Normal file
81
blocksuite/affine/rich-text/src/format/delete-text.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { RootBlockModel } from '@blocksuite/affine-model';
|
||||
import { matchModels } from '@blocksuite/affine-shared/utils';
|
||||
import { type Command, TextSelection } from '@blocksuite/block-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.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();
|
||||
};
|
||||
72
blocksuite/affine/rich-text/src/format/format-block.ts
Normal file
72
blocksuite/affine/rich-text/src/format/format-block.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { getSelectedBlocksCommand } from '@blocksuite/affine-shared/commands';
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import type { BlockSelection, Command } from '@blocksuite/block-std';
|
||||
import { INLINE_ROOT_ATTR, type InlineRootElement } from '@blocksuite/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();
|
||||
};
|
||||
50
blocksuite/affine/rich-text/src/format/format-native.ts
Normal file
50
blocksuite/affine/rich-text/src/format/format-native.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import {
|
||||
BLOCK_ID_ATTR,
|
||||
type BlockComponent,
|
||||
type Command,
|
||||
} from '@blocksuite/block-std';
|
||||
import { INLINE_ROOT_ATTR, type InlineRootElement } from '@blocksuite/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();
|
||||
};
|
||||
88
blocksuite/affine/rich-text/src/format/format-text.ts
Normal file
88
blocksuite/affine/rich-text/src/format/format-text.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { getSelectedBlocksCommand } from '@blocksuite/affine-shared/commands';
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import type { Command, TextSelection } from '@blocksuite/block-std';
|
||||
import { INLINE_ROOT_ATTR, type InlineRootElement } from '@blocksuite/inline';
|
||||
|
||||
import { FORMAT_TEXT_SUPPORT_FLAVOURS } from './consts.js';
|
||||
import { clearMarksOnDiscontinuousInput } from './utils.js';
|
||||
|
||||
// for text selection
|
||||
export const formatTextCommand: Command<{
|
||||
currentTextSelection?: TextSelection;
|
||||
textSelection?: TextSelection;
|
||||
styles: AffineTextAttributes;
|
||||
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();
|
||||
};
|
||||
28
blocksuite/affine/rich-text/src/format/index.ts
Normal file
28
blocksuite/affine/rich-text/src/format/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
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 { insertInlineLatex } from './insert-inline-latex.js';
|
||||
export {
|
||||
getTextStyle,
|
||||
isTextStyleActive,
|
||||
toggleBold,
|
||||
toggleCode,
|
||||
toggleItalic,
|
||||
toggleLink,
|
||||
toggleStrike,
|
||||
toggleTextStyleCommand,
|
||||
toggleUnderline,
|
||||
} from './text-style.js';
|
||||
export {
|
||||
clearMarksOnDiscontinuousInput,
|
||||
insertContent,
|
||||
isFormatSupported,
|
||||
} from './utils.js';
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Command, TextSelection } from '@blocksuite/block-std';
|
||||
|
||||
export const insertInlineLatex: Command<{
|
||||
currentTextSelection?: TextSelection;
|
||||
textSelection?: TextSelection;
|
||||
}> = (ctx, next) => {
|
||||
const textSelection = ctx.textSelection ?? ctx.currentTextSelection;
|
||||
if (!textSelection || !textSelection.isCollapsed()) 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;
|
||||
|
||||
inlineEditor.insertText(
|
||||
{
|
||||
index: textSelection.from.index,
|
||||
length: 0,
|
||||
},
|
||||
' '
|
||||
);
|
||||
inlineEditor.formatText(
|
||||
{
|
||||
index: textSelection.from.index,
|
||||
length: 1,
|
||||
},
|
||||
{
|
||||
latex: '',
|
||||
}
|
||||
);
|
||||
inlineEditor.setInlineRange({
|
||||
index: textSelection.from.index,
|
||||
length: 1,
|
||||
});
|
||||
|
||||
inlineEditor
|
||||
.waitForUpdate()
|
||||
.then(async () => {
|
||||
await inlineEditor.waitForUpdate();
|
||||
|
||||
const textPoint = inlineEditor.getTextPoint(textSelection.from.index + 1);
|
||||
if (!textPoint) return;
|
||||
const [text] = textPoint;
|
||||
const latexNode = text.parentElement?.closest('affine-latex-node');
|
||||
if (!latexNode) return;
|
||||
latexNode.toggleEditor();
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
next();
|
||||
};
|
||||
141
blocksuite/affine/rich-text/src/format/text-style.ts
Normal file
141
blocksuite/affine/rich-text/src/format/text-style.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
getBlockSelectionsCommand,
|
||||
getTextSelectionCommand,
|
||||
} from '@blocksuite/affine-shared/commands';
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import type { Command } from '@blocksuite/block-std';
|
||||
import { INLINE_ROOT_ATTR, type InlineRootElement } from '@blocksuite/inline';
|
||||
|
||||
import { toggleLinkPopup } from '../inline/index.js';
|
||||
import { formatBlockCommand } from './format-block.js';
|
||||
import { formatNativeCommand } from './format-native.js';
|
||||
import { formatTextCommand } from './format-text.js';
|
||||
import { getCombinedTextStyle } from './utils.js';
|
||||
|
||||
export const toggleTextStyleCommand: Command<{
|
||||
key: Extract<
|
||||
keyof AffineTextAttributes,
|
||||
'bold' | 'italic' | 'underline' | 'strike' | 'code'
|
||||
>;
|
||||
}> = (ctx, next) => {
|
||||
const { std, key } = ctx;
|
||||
const [active] = std.command.chain().pipe(isTextStyleActive, { key }).run();
|
||||
|
||||
const payload: {
|
||||
styles: AffineTextAttributes;
|
||||
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 AffineTextAttributes,
|
||||
'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 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();
|
||||
};
|
||||
|
||||
export const getTextStyle: Command<{}, { textStyle: AffineTextAttributes }> = (
|
||||
ctx,
|
||||
next
|
||||
) => {
|
||||
const [result, innerCtx] = getCombinedTextStyle(
|
||||
ctx.std.command.chain()
|
||||
).run();
|
||||
if (!result) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return next({ textStyle: innerCtx.textStyle });
|
||||
};
|
||||
|
||||
export const isTextStyleActive: Command<{ key: keyof AffineTextAttributes }> = (
|
||||
ctx,
|
||||
next
|
||||
) => {
|
||||
const key = ctx.key;
|
||||
const [result] = getCombinedTextStyle(ctx.std.command.chain())
|
||||
.pipe((ctx, next) => {
|
||||
const { textStyle } = ctx;
|
||||
|
||||
if (textStyle && key in textStyle) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return false;
|
||||
})
|
||||
.run();
|
||||
|
||||
if (!result) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
258
blocksuite/affine/rich-text/src/format/utils.ts
Normal file
258
blocksuite/affine/rich-text/src/format/utils.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import {
|
||||
getBlockSelectionsCommand,
|
||||
getSelectedBlocksCommand,
|
||||
getTextSelectionCommand,
|
||||
} from '@blocksuite/affine-shared/commands';
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import {
|
||||
BLOCK_ID_ATTR,
|
||||
type BlockComponent,
|
||||
type Chain,
|
||||
type EditorHost,
|
||||
type InitCommandCtx,
|
||||
} from '@blocksuite/block-std';
|
||||
import {
|
||||
INLINE_ROOT_ATTR,
|
||||
type InlineEditor,
|
||||
type InlineRange,
|
||||
type InlineRootElement,
|
||||
} from '@blocksuite/inline';
|
||||
import type { BlockModel } from '@blocksuite/store';
|
||||
import { effect } from '@preact/signals-core';
|
||||
|
||||
import { getInlineEditorByModel } from '../dom.js';
|
||||
import type { AffineInlineEditor } from '../inline/index.js';
|
||||
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>[]
|
||||
) => { textStyle: AffineTextAttributes } | boolean | void
|
||||
): Chain<InitCommandCtx & { textStyle: 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 getCombinedTextStyle(chain: Chain<InitCommandCtx>) {
|
||||
return handleCurrentSelection(chain, (type, inlineEditors) => {
|
||||
if (type === 'text') {
|
||||
return {
|
||||
textStyle: getCombinedFormatFromInlineEditors(
|
||||
inlineEditors.map(e => [e, e.getInlineRange()])
|
||||
),
|
||||
};
|
||||
}
|
||||
if (type === 'block') {
|
||||
return {
|
||||
textStyle: getCombinedFormatFromInlineEditors(
|
||||
inlineEditors.map(e => [e, { index: 0, length: e.yTextLength }])
|
||||
),
|
||||
};
|
||||
}
|
||||
if (type === 'native') {
|
||||
return {
|
||||
textStyle: getCombinedFormatFromInlineEditors(
|
||||
inlineEditors.map(e => [e, e.getInlineRange()])
|
||||
),
|
||||
};
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
export function isFormatSupported(chain: Chain<InitCommandCtx>) {
|
||||
return handleCurrentSelection(
|
||||
chain,
|
||||
(_type, inlineEditors) => inlineEditors.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 insertContent(
|
||||
editorHost: EditorHost,
|
||||
model: BlockModel,
|
||||
text: string,
|
||||
attributes?: AffineTextAttributes
|
||||
) {
|
||||
if (!model.text) {
|
||||
console.error("Can't insert text! Text not found");
|
||||
return;
|
||||
}
|
||||
const inlineEditor = getInlineEditorByModel(editorHost, 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,
|
||||
});
|
||||
}
|
||||
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/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);
|
||||
// eslint-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);
|
||||
};
|
||||
20
blocksuite/affine/rich-text/src/index.ts
Normal file
20
blocksuite/affine/rich-text/src/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export * from './all-extensions';
|
||||
export { type TextConversionConfig, textConversionConfigs } from './conversion';
|
||||
export {
|
||||
asyncGetRichText,
|
||||
asyncSetInlineRange,
|
||||
cleanSpecifiedTail,
|
||||
focusTextModel,
|
||||
getInlineEditorByModel,
|
||||
getRichTextByModel,
|
||||
getTextContentFromInlineRange,
|
||||
onModelTextUpdated,
|
||||
selectTextModel,
|
||||
} from './dom';
|
||||
export * from './extension';
|
||||
export * from './format';
|
||||
export * from './inline';
|
||||
export { textKeymap } from './keymap';
|
||||
export { insertLinkedNode } from './linked-node';
|
||||
export { markdownInput } from './markdown';
|
||||
export { RichText } from './rich-text';
|
||||
@@ -0,0 +1,17 @@
|
||||
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';
|
||||
import { InlineDeltaToPlainTextAdapterExtensions } from './plain-text/inline-delta';
|
||||
|
||||
export const InlineAdapterExtensions: ExtensionType[] = [
|
||||
HtmlInlineToDeltaAdapterExtensions,
|
||||
InlineDeltaToHtmlAdapterExtensions,
|
||||
InlineDeltaToPlainTextAdapterExtensions,
|
||||
NotionHtmlInlineToDeltaAdapterExtensions,
|
||||
InlineDeltaToMarkdownAdapterExtensions,
|
||||
MarkdownInlineToDeltaAdapterExtensions,
|
||||
].flat();
|
||||
@@ -0,0 +1,235 @@
|
||||
import {
|
||||
type HtmlAST,
|
||||
HtmlASTToDeltaExtension,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import { collapseWhiteSpace } from 'collapse-white-space';
|
||||
import type { Element } from 'hast';
|
||||
|
||||
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']);
|
||||
|
||||
export const htmlTextToDeltaMatcher = HtmlASTToDeltaExtension({
|
||||
name: 'text',
|
||||
match: ast => ast.type === 'text',
|
||||
toDelta: (ast, context) => {
|
||||
if (!('value' in ast)) {
|
||||
return [];
|
||||
}
|
||||
const { options } = context;
|
||||
options.trim ??= true;
|
||||
|
||||
if (options.pre) {
|
||||
return [{ insert: ast.value }];
|
||||
}
|
||||
|
||||
const value = options.trim
|
||||
? collapseWhiteSpace(ast.value, { trim: options.trim })
|
||||
: collapseWhiteSpace(ast.value);
|
||||
return value ? [{ insert: value }] : [];
|
||||
},
|
||||
});
|
||||
|
||||
export const htmlTextLikeElementToDeltaMatcher = HtmlASTToDeltaExtension({
|
||||
name: 'text-like-element',
|
||||
match: ast => isElement(ast) && textLikeElementTags.has(ast.tagName),
|
||||
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 => isElement(ast) && strongElementTags.has(ast.tagName),
|
||||
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 => isElement(ast) && italicElementTags.has(ast.tagName),
|
||||
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 => isElement(ast) && ast.tagName === 'del',
|
||||
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 => isElement(ast) && ast.tagName === 'u',
|
||||
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 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;
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const htmlMarkElementToDeltaMatcher = HtmlASTToDeltaExtension({
|
||||
name: 'mark-element',
|
||||
match: ast => isElement(ast) && ast.tagName === 'mark',
|
||||
toDelta: (ast, context) => {
|
||||
if (!isElement(ast)) {
|
||||
return [];
|
||||
}
|
||||
return ast.children.flatMap(child =>
|
||||
context.toDelta(child, { trim: false }).map(delta => {
|
||||
delta.attributes = { ...delta.attributes };
|
||||
return delta;
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const htmlBrElementToDeltaMatcher = HtmlASTToDeltaExtension({
|
||||
name: 'br-element',
|
||||
match: ast => isElement(ast) && ast.tagName === 'br',
|
||||
toDelta: () => {
|
||||
return [{ insert: '\n' }];
|
||||
},
|
||||
});
|
||||
|
||||
export const HtmlInlineToDeltaAdapterExtensions = [
|
||||
htmlTextToDeltaMatcher,
|
||||
htmlTextLikeElementToDeltaMatcher,
|
||||
htmlStrongElementToDeltaMatcher,
|
||||
htmlItalicElementToDeltaMatcher,
|
||||
htmlCodeElementToDeltaMatcher,
|
||||
htmlDelElementToDeltaMatcher,
|
||||
htmlUnderlineElementToDeltaMatcher,
|
||||
htmlLinkElementToDeltaMatcher,
|
||||
htmlMarkElementToDeltaMatcher,
|
||||
htmlBrElementToDeltaMatcher,
|
||||
];
|
||||
@@ -0,0 +1,219 @@
|
||||
import type { InlineHtmlAST } from '@blocksuite/affine-shared/adapters';
|
||||
import {
|
||||
AdapterTextUtils,
|
||||
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 referenceDeltaToHtmlAdapterMatcher =
|
||||
InlineDeltaToHtmlAdapterExtension({
|
||||
name: 'reference',
|
||||
match: delta => !!delta.attributes?.reference,
|
||||
toAST: (delta, context) => {
|
||||
let hast: InlineHtmlAST = {
|
||||
type: 'text',
|
||||
value: delta.insert,
|
||||
};
|
||||
const reference = delta.attributes?.reference;
|
||||
if (!reference) {
|
||||
return hast;
|
||||
}
|
||||
|
||||
const { configs } = context;
|
||||
const title = configs.get(`title:${reference.pageId}`);
|
||||
const url = AdapterTextUtils.generateDocUrl(
|
||||
configs.get('docLinkBaseUrl') ?? '',
|
||||
String(reference.pageId),
|
||||
reference.params ?? Object.create(null)
|
||||
);
|
||||
if (title) {
|
||||
hast.value = title;
|
||||
}
|
||||
hast = {
|
||||
type: 'element',
|
||||
tagName: 'a',
|
||||
properties: {
|
||||
href: url,
|
||||
},
|
||||
children: [hast],
|
||||
};
|
||||
|
||||
return hast;
|
||||
},
|
||||
});
|
||||
|
||||
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],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
referenceDeltaToHtmlAdapterMatcher,
|
||||
linkDeltaToHtmlAdapterMatcher,
|
||||
];
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './inline-delta';
|
||||
export * from './markdown-inline';
|
||||
@@ -0,0 +1,199 @@
|
||||
import {
|
||||
AdapterTextUtils,
|
||||
FOOTNOTE_DEFINITION_PREFIX,
|
||||
InlineDeltaToMarkdownAdapterExtension,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import type { PhrasingContent } from 'mdast';
|
||||
import type RemarkMath from 'remark-math';
|
||||
|
||||
declare type _GLOBAL_ = typeof RemarkMath;
|
||||
|
||||
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 referenceDeltaToMarkdownAdapterMatcher =
|
||||
InlineDeltaToMarkdownAdapterExtension({
|
||||
name: 'reference',
|
||||
match: delta => !!delta.attributes?.reference,
|
||||
toAST: (delta, context) => {
|
||||
let mdast: PhrasingContent = {
|
||||
type: 'text',
|
||||
value: delta.insert,
|
||||
};
|
||||
const reference = delta.attributes?.reference;
|
||||
if (!reference) {
|
||||
return mdast;
|
||||
}
|
||||
|
||||
const { configs } = context;
|
||||
const title = configs.get(`title:${reference.pageId}`);
|
||||
const params = reference.params ?? {};
|
||||
const url = AdapterTextUtils.generateDocUrl(
|
||||
configs.get('docLinkBaseUrl') ?? '',
|
||||
String(reference.pageId),
|
||||
params
|
||||
);
|
||||
mdast = {
|
||||
type: 'link',
|
||||
url,
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
value: title ?? '',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return 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;
|
||||
},
|
||||
});
|
||||
|
||||
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;
|
||||
},
|
||||
});
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
configs.set(
|
||||
footnoteDefinitionKey,
|
||||
JSON.stringify(clonedFootnoteReference)
|
||||
);
|
||||
}
|
||||
return {
|
||||
type: 'footnoteReference',
|
||||
label: footnote.label,
|
||||
identifier: footnote.label,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const InlineDeltaToMarkdownAdapterExtensions = [
|
||||
referenceDeltaToMarkdownAdapterMatcher,
|
||||
linkDeltaToMarkdownAdapterMatcher,
|
||||
inlineCodeDeltaToMarkdownAdapterMatcher,
|
||||
boldDeltaToMarkdownAdapterMatcher,
|
||||
italicDeltaToMarkdownAdapterMatcher,
|
||||
strikeDeltaToMarkdownAdapterMatcher,
|
||||
latexDeltaToMarkdownAdapterMatcher,
|
||||
footnoteReferenceDeltaToMarkdownAdapterMatcher,
|
||||
];
|
||||
@@ -0,0 +1,192 @@
|
||||
import { FootNoteReferenceParamsSchema } from '@blocksuite/affine-model';
|
||||
import {
|
||||
FOOTNOTE_DEFINITION_PREFIX,
|
||||
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 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;
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const markdownListToDeltaMatcher = MarkdownASTToDeltaExtension({
|
||||
name: 'list',
|
||||
match: ast => ast.type === 'list',
|
||||
toDelta: () => [],
|
||||
});
|
||||
|
||||
export const markdownInlineMathToDeltaMatcher = MarkdownASTToDeltaExtension({
|
||||
name: 'inlineMath',
|
||||
match: ast => ast.type === 'inlineMath',
|
||||
toDelta: ast => {
|
||||
if (!('value' in ast)) {
|
||||
return [];
|
||||
}
|
||||
return [{ insert: ' ', attributes: { latex: ast.value } }];
|
||||
},
|
||||
});
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
const footnoteReference = FootNoteReferenceParamsSchema.parse(
|
||||
footnoteDefinitionJson
|
||||
);
|
||||
const footnote = {
|
||||
label: ast.identifier,
|
||||
reference: footnoteReference,
|
||||
};
|
||||
return [{ insert: ' ', attributes: { footnote } }];
|
||||
} catch (error) {
|
||||
console.warn('Error parsing footnote reference', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const MarkdownInlineToDeltaAdapterExtensions = [
|
||||
markdownTextToDeltaMatcher,
|
||||
markdownInlineCodeToDeltaMatcher,
|
||||
markdownStrongToDeltaMatcher,
|
||||
markdownEmphasisToDeltaMatcher,
|
||||
markdownDeleteToDeltaMatcher,
|
||||
markdownLinkToDeltaMatcher,
|
||||
markdownInlineMathToDeltaMatcher,
|
||||
markdownListToDeltaMatcher,
|
||||
markdownFootnoteReferenceToDeltaMatcher,
|
||||
];
|
||||
@@ -0,0 +1,300 @@
|
||||
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 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;
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
notionHtmlLinkElementToDeltaMatcher,
|
||||
notionHtmlMarkElementToDeltaMatcher,
|
||||
notionHtmlListToDeltaMatcher,
|
||||
notionHtmlLiElementToDeltaMatcher,
|
||||
notionHtmlBrElementToDeltaMatcher,
|
||||
notionHtmlStyleElementToDeltaMatcher,
|
||||
];
|
||||
@@ -0,0 +1,78 @@
|
||||
import {
|
||||
AdapterTextUtils,
|
||||
InlineDeltaToPlainTextAdapterExtension,
|
||||
type TextBuffer,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
|
||||
export const referenceDeltaMarkdownAdapterMatch =
|
||||
InlineDeltaToPlainTextAdapterExtension({
|
||||
name: 'reference',
|
||||
match: delta => !!delta.attributes?.reference,
|
||||
toAST: (delta, context) => {
|
||||
const node: TextBuffer = {
|
||||
content: delta.insert,
|
||||
};
|
||||
const reference = delta.attributes?.reference;
|
||||
if (!reference) {
|
||||
return node;
|
||||
}
|
||||
|
||||
const { configs } = context;
|
||||
const title = configs.get(`title:${reference.pageId}`) ?? '';
|
||||
const url = AdapterTextUtils.generateDocUrl(
|
||||
configs.get('docLinkBaseUrl') ?? '',
|
||||
String(reference.pageId),
|
||||
reference.params ?? Object.create(null)
|
||||
);
|
||||
const content = `${title ? `${title}: ` : ''}${url}`;
|
||||
|
||||
return {
|
||||
content,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const InlineDeltaToPlainTextAdapterExtensions: ExtensionType[] = [
|
||||
referenceDeltaMarkdownAdapterMatch,
|
||||
linkDeltaMarkdownAdapterMatch,
|
||||
latexDeltaMarkdownAdapterMatch,
|
||||
];
|
||||
9
blocksuite/affine/rich-text/src/inline/index.ts
Normal file
9
blocksuite/affine/rich-text/src/inline/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export * from './adapters/extensions';
|
||||
export * from './adapters/html/html-inline';
|
||||
export * from './adapters/html/inline-delta';
|
||||
export * from './adapters/markdown';
|
||||
export * from './adapters/notion-html/html-inline';
|
||||
export * from './adapters/plain-text/inline-delta';
|
||||
export * from './presets/affine-inline-specs';
|
||||
export * from './presets/markdown';
|
||||
export * from './presets/nodes/index';
|
||||
@@ -0,0 +1,236 @@
|
||||
import { FootNoteSchema, ReferenceInfoSchema } from '@blocksuite/affine-model';
|
||||
import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services';
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import { BlockFlavourIdentifier, StdIdentifier } from '@blocksuite/block-std';
|
||||
import type { InlineEditor, InlineRootElement } from '@blocksuite/inline';
|
||||
import { html } from 'lit';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { InlineSpecExtension } from '../../extension/index.js';
|
||||
import { FootNoteNodeConfigIdentifier } from './nodes/footnote-node/footnote-config.js';
|
||||
import { builtinInlineLinkToolbarConfig } from './nodes/link-node/configs/toolbar.js';
|
||||
import { builtinInlineReferenceToolbarConfig } from './nodes/reference-node/configs/toolbar.js';
|
||||
import {
|
||||
ReferenceNodeConfigIdentifier,
|
||||
ReferenceNodeConfigProvider,
|
||||
} from './nodes/reference-node/reference-config.js';
|
||||
|
||||
export type AffineInlineEditor = InlineEditor<AffineTextAttributes>;
|
||||
export type AffineInlineRootElement = InlineRootElement<AffineTextAttributes>;
|
||||
|
||||
export const BoldInlineSpecExtension = InlineSpecExtension({
|
||||
name: 'bold',
|
||||
schema: 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({
|
||||
name: 'italic',
|
||||
schema: 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({
|
||||
name: 'underline',
|
||||
schema: 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({
|
||||
name: 'strike',
|
||||
schema: 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({
|
||||
name: 'code',
|
||||
schema: 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({
|
||||
name: 'background',
|
||||
schema: 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({
|
||||
name: 'color',
|
||||
schema: z.string().optional().nullable().catch(undefined),
|
||||
match: delta => {
|
||||
return !!delta.attributes?.color;
|
||||
},
|
||||
renderer: ({ delta }) => {
|
||||
return html`<affine-text .delta=${delta}></affine-text>`;
|
||||
},
|
||||
});
|
||||
|
||||
export const LatexInlineSpecExtension = InlineSpecExtension(
|
||||
'latex',
|
||||
provider => {
|
||||
const std = provider.get(StdIdentifier);
|
||||
return {
|
||||
name: 'latex',
|
||||
schema: 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 ReferenceInlineSpecExtension = InlineSpecExtension(
|
||||
'reference',
|
||||
provider => {
|
||||
const std = provider.get(StdIdentifier);
|
||||
const configProvider = new ReferenceNodeConfigProvider(std);
|
||||
const config = provider.getOptional(ReferenceNodeConfigIdentifier) ?? {};
|
||||
if (config.customContent) {
|
||||
configProvider.setCustomContent(config.customContent);
|
||||
}
|
||||
if (config.interactable !== undefined) {
|
||||
configProvider.setInteractable(config.interactable);
|
||||
}
|
||||
if (config.hidePopup !== undefined) {
|
||||
configProvider.setHidePopup(config.hidePopup);
|
||||
}
|
||||
return {
|
||||
name: 'reference',
|
||||
schema: z
|
||||
.object({
|
||||
type: z.enum([
|
||||
// @deprecated Subpage is deprecated, use LinkedPage instead
|
||||
'Subpage',
|
||||
'LinkedPage',
|
||||
]),
|
||||
})
|
||||
.merge(ReferenceInfoSchema)
|
||||
.optional()
|
||||
.nullable()
|
||||
.catch(undefined),
|
||||
match: delta => {
|
||||
return !!delta.attributes?.reference;
|
||||
},
|
||||
renderer: ({ delta, selected }) => {
|
||||
return html`<affine-reference
|
||||
.std=${std}
|
||||
.delta=${delta}
|
||||
.selected=${selected}
|
||||
.config=${configProvider}
|
||||
></affine-reference>`;
|
||||
},
|
||||
embed: true,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export const LinkInlineSpecExtension = InlineSpecExtension('link', provider => {
|
||||
const std = provider.get(StdIdentifier);
|
||||
return {
|
||||
name: 'link',
|
||||
schema: z.string().optional().nullable().catch(undefined),
|
||||
match: delta => {
|
||||
return !!delta.attributes?.link;
|
||||
},
|
||||
renderer: ({ delta }) => {
|
||||
return html`<affine-link .std=${std} .delta=${delta}></affine-link>`;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const LatexEditorUnitSpecExtension = InlineSpecExtension({
|
||||
name: 'latex-editor-unit',
|
||||
schema: z.undefined(),
|
||||
match: () => true,
|
||||
renderer: ({ delta }) => {
|
||||
return html`<latex-editor-unit .delta=${delta}></latex-editor-unit>`;
|
||||
},
|
||||
});
|
||||
|
||||
export const FootNoteInlineSpecExtension = InlineSpecExtension(
|
||||
'footnote',
|
||||
provider => {
|
||||
const std = provider.get(StdIdentifier);
|
||||
const config =
|
||||
provider.getOptional(FootNoteNodeConfigIdentifier) ?? undefined;
|
||||
return {
|
||||
name: 'footnote',
|
||||
schema: 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,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export const InlineSpecExtensions = [
|
||||
BoldInlineSpecExtension,
|
||||
ItalicInlineSpecExtension,
|
||||
UnderlineInlineSpecExtension,
|
||||
StrikeInlineSpecExtension,
|
||||
CodeInlineSpecExtension,
|
||||
BackgroundInlineSpecExtension,
|
||||
ColorInlineSpecExtension,
|
||||
LatexInlineSpecExtension,
|
||||
ReferenceInlineSpecExtension,
|
||||
LinkInlineSpecExtension,
|
||||
LatexEditorUnitSpecExtension,
|
||||
FootNoteInlineSpecExtension,
|
||||
|
||||
ToolbarModuleExtension({
|
||||
id: BlockFlavourIdentifier('affine:reference'),
|
||||
config: builtinInlineReferenceToolbarConfig,
|
||||
}),
|
||||
|
||||
ToolbarModuleExtension({
|
||||
id: BlockFlavourIdentifier('affine:link'),
|
||||
config: builtinInlineLinkToolbarConfig,
|
||||
}),
|
||||
];
|
||||
583
blocksuite/affine/rich-text/src/inline/presets/markdown.ts
Normal file
583
blocksuite/affine/rich-text/src/inline/presets/markdown.ts
Normal file
@@ -0,0 +1,583 @@
|
||||
import type { BlockComponent } from '@blocksuite/block-std';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
|
||||
import { InlineMarkdownExtension } from '../../extension/markdown-matcher.js';
|
||||
|
||||
// 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({
|
||||
name: 'bolditalic',
|
||||
pattern: /.*\*{3}([^\s*][^*]*[^\s*])\*{3}$|.*\*{3}([^\s*])\*{3}$/,
|
||||
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);
|
||||
const startIndex = inlineRange.index - annotatedText.length;
|
||||
|
||||
inlineEditor.insertText(
|
||||
{
|
||||
index: startIndex + annotatedText.length,
|
||||
length: 0,
|
||||
},
|
||||
' '
|
||||
);
|
||||
inlineEditor.setInlineRange({
|
||||
index: startIndex + annotatedText.length + 1,
|
||||
length: 0,
|
||||
});
|
||||
|
||||
undoManager.stopCapturing();
|
||||
|
||||
inlineEditor.formatText(
|
||||
{
|
||||
index: startIndex,
|
||||
length: annotatedText.length,
|
||||
},
|
||||
{
|
||||
bold: true,
|
||||
italic: true,
|
||||
}
|
||||
);
|
||||
|
||||
inlineEditor.deleteText({
|
||||
index: startIndex + annotatedText.length,
|
||||
length: 1,
|
||||
});
|
||||
inlineEditor.deleteText({
|
||||
index: startIndex + annotatedText.length - 3,
|
||||
length: 3,
|
||||
});
|
||||
inlineEditor.deleteText({
|
||||
index: startIndex,
|
||||
length: 3,
|
||||
});
|
||||
|
||||
inlineEditor.setInlineRange({
|
||||
index: startIndex + annotatedText.length - 6,
|
||||
length: 0,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const BoldMarkdown = InlineMarkdownExtension({
|
||||
name: 'bold',
|
||||
pattern: /.*\*{2}([^\s][^*]*[^\s*])\*{2}$|.*\*{2}([^\s*])\*{2}$/,
|
||||
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);
|
||||
const startIndex = inlineRange.index - annotatedText.length;
|
||||
|
||||
inlineEditor.insertText(
|
||||
{
|
||||
index: startIndex + annotatedText.length,
|
||||
length: 0,
|
||||
},
|
||||
' '
|
||||
);
|
||||
inlineEditor.setInlineRange({
|
||||
index: startIndex + annotatedText.length + 1,
|
||||
length: 0,
|
||||
});
|
||||
|
||||
undoManager.stopCapturing();
|
||||
|
||||
inlineEditor.formatText(
|
||||
{
|
||||
index: startIndex,
|
||||
length: annotatedText.length,
|
||||
},
|
||||
{
|
||||
bold: true,
|
||||
}
|
||||
);
|
||||
|
||||
inlineEditor.deleteText({
|
||||
index: startIndex + annotatedText.length,
|
||||
length: 1,
|
||||
});
|
||||
inlineEditor.deleteText({
|
||||
index: startIndex + annotatedText.length - 2,
|
||||
length: 2,
|
||||
});
|
||||
inlineEditor.deleteText({
|
||||
index: startIndex,
|
||||
length: 2,
|
||||
});
|
||||
|
||||
inlineEditor.setInlineRange({
|
||||
index: startIndex + annotatedText.length - 4,
|
||||
length: 0,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const ItalicExtension = InlineMarkdownExtension({
|
||||
name: 'italic',
|
||||
pattern: /.*\*{1}([^\s][^*]*[^\s*])\*{1}$|.*\*{1}([^\s*])\*{1}$/,
|
||||
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);
|
||||
const startIndex = inlineRange.index - annotatedText.length;
|
||||
|
||||
inlineEditor.insertText(
|
||||
{
|
||||
index: startIndex + annotatedText.length,
|
||||
length: 0,
|
||||
},
|
||||
' '
|
||||
);
|
||||
inlineEditor.setInlineRange({
|
||||
index: startIndex + annotatedText.length + 1,
|
||||
length: 0,
|
||||
});
|
||||
|
||||
undoManager.stopCapturing();
|
||||
|
||||
inlineEditor.formatText(
|
||||
{
|
||||
index: startIndex,
|
||||
length: annotatedText.length,
|
||||
},
|
||||
{
|
||||
italic: true,
|
||||
}
|
||||
);
|
||||
|
||||
inlineEditor.deleteText({
|
||||
index: startIndex + annotatedText.length,
|
||||
length: 1,
|
||||
});
|
||||
inlineEditor.deleteText({
|
||||
index: startIndex + annotatedText.length - 1,
|
||||
length: 1,
|
||||
});
|
||||
inlineEditor.deleteText({
|
||||
index: startIndex,
|
||||
length: 1,
|
||||
});
|
||||
|
||||
inlineEditor.setInlineRange({
|
||||
index: startIndex + annotatedText.length - 2,
|
||||
length: 0,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const StrikethroughExtension = InlineMarkdownExtension({
|
||||
name: 'strikethrough',
|
||||
pattern: /.*~{2}([^\s][^~]*[^\s])~{2}$|.*~{2}([^\s~])~{2}$/,
|
||||
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);
|
||||
const startIndex = inlineRange.index - annotatedText.length;
|
||||
|
||||
inlineEditor.insertText(
|
||||
{
|
||||
index: startIndex + annotatedText.length,
|
||||
length: 0,
|
||||
},
|
||||
' '
|
||||
);
|
||||
inlineEditor.setInlineRange({
|
||||
index: startIndex + annotatedText.length + 1,
|
||||
length: 0,
|
||||
});
|
||||
|
||||
undoManager.stopCapturing();
|
||||
|
||||
inlineEditor.formatText(
|
||||
{
|
||||
index: startIndex,
|
||||
length: annotatedText.length,
|
||||
},
|
||||
{
|
||||
strike: true,
|
||||
}
|
||||
);
|
||||
|
||||
inlineEditor.deleteText({
|
||||
index: startIndex + annotatedText.length,
|
||||
length: 1,
|
||||
});
|
||||
inlineEditor.deleteText({
|
||||
index: startIndex + annotatedText.length - 2,
|
||||
length: 2,
|
||||
});
|
||||
inlineEditor.deleteText({
|
||||
index: startIndex,
|
||||
length: 2,
|
||||
});
|
||||
|
||||
inlineEditor.setInlineRange({
|
||||
index: startIndex + annotatedText.length - 4,
|
||||
length: 0,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const UnderthroughExtension = InlineMarkdownExtension({
|
||||
name: 'underthrough',
|
||||
pattern: /.*~{1}([^\s][^~]*[^\s~])~{1}$|.*~{1}([^\s~])~{1}$/,
|
||||
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);
|
||||
const startIndex = inlineRange.index - annotatedText.length;
|
||||
|
||||
inlineEditor.insertText(
|
||||
{
|
||||
index: startIndex + annotatedText.length,
|
||||
length: 0,
|
||||
},
|
||||
' '
|
||||
);
|
||||
inlineEditor.setInlineRange({
|
||||
index: startIndex + annotatedText.length + 1,
|
||||
length: 0,
|
||||
});
|
||||
|
||||
undoManager.stopCapturing();
|
||||
|
||||
inlineEditor.formatText(
|
||||
{
|
||||
index: startIndex,
|
||||
length: annotatedText.length,
|
||||
},
|
||||
{
|
||||
underline: true,
|
||||
}
|
||||
);
|
||||
|
||||
inlineEditor.deleteText({
|
||||
index: startIndex + annotatedText.length,
|
||||
length: 1,
|
||||
});
|
||||
inlineEditor.deleteText({
|
||||
index: inlineRange.index - 1,
|
||||
length: 1,
|
||||
});
|
||||
inlineEditor.deleteText({
|
||||
index: startIndex,
|
||||
length: 1,
|
||||
});
|
||||
|
||||
inlineEditor.setInlineRange({
|
||||
index: startIndex + annotatedText.length - 2,
|
||||
length: 0,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const CodeExtension = InlineMarkdownExtension({
|
||||
name: 'code',
|
||||
pattern: /.*`([^\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);
|
||||
const startIndex = inlineRange.index - annotatedText.length;
|
||||
|
||||
inlineEditor.insertText(
|
||||
{
|
||||
index: startIndex + annotatedText.length,
|
||||
length: 0,
|
||||
},
|
||||
' '
|
||||
);
|
||||
inlineEditor.setInlineRange({
|
||||
index: startIndex + annotatedText.length + 1,
|
||||
length: 0,
|
||||
});
|
||||
|
||||
undoManager.stopCapturing();
|
||||
|
||||
inlineEditor.formatText(
|
||||
{
|
||||
index: startIndex,
|
||||
length: annotatedText.length,
|
||||
},
|
||||
{
|
||||
code: true,
|
||||
}
|
||||
);
|
||||
|
||||
inlineEditor.deleteText({
|
||||
index: startIndex + annotatedText.length,
|
||||
length: 1,
|
||||
});
|
||||
inlineEditor.deleteText({
|
||||
index: startIndex + annotatedText.length - 1,
|
||||
length: 1,
|
||||
});
|
||||
inlineEditor.deleteText({
|
||||
index: startIndex,
|
||||
length: 1,
|
||||
});
|
||||
|
||||
inlineEditor.setInlineRange({
|
||||
index: startIndex + annotatedText.length - 2,
|
||||
length: 0,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const LinkExtension = InlineMarkdownExtension({
|
||||
name: 'link',
|
||||
pattern: /.*\[(.+?)\]\((.+?)\)$/,
|
||||
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);
|
||||
const startIndex = inlineRange.index - annotatedText.length;
|
||||
|
||||
inlineEditor.insertText(
|
||||
{
|
||||
index: inlineRange.index,
|
||||
length: 0,
|
||||
},
|
||||
' '
|
||||
);
|
||||
inlineEditor.setInlineRange({
|
||||
index: inlineRange.index + 1,
|
||||
length: 0,
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const LatexExtension = InlineMarkdownExtension({
|
||||
name: 'latex',
|
||||
|
||||
pattern:
|
||||
/(?:\$\$)(?<content>[^$]+)(?:\$\$)$|(?<blockPrefix>\$\$\$\$)|(?<inlinePrefix>\$\$)$/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 (blockPrefix === '$$$$') {
|
||||
inlineEditor.insertText(
|
||||
{
|
||||
index: inlineRange.index,
|
||||
length: 0,
|
||||
},
|
||||
' '
|
||||
);
|
||||
inlineEditor.setInlineRange({
|
||||
index: inlineRange.index + 1,
|
||||
length: 0,
|
||||
});
|
||||
|
||||
undoManager.stopCapturing();
|
||||
|
||||
if (!inlineEditor.rootElement) return;
|
||||
const blockComponent =
|
||||
inlineEditor.rootElement.closest<BlockComponent>('[data-block-id]');
|
||||
if (!blockComponent) return;
|
||||
|
||||
const doc = blockComponent.doc;
|
||||
const parentComponent = blockComponent.parentComponent;
|
||||
if (!parentComponent) return;
|
||||
|
||||
const index = parentComponent.model.children.indexOf(
|
||||
blockComponent.model
|
||||
);
|
||||
if (index === -1) return;
|
||||
|
||||
inlineEditor.deleteText({
|
||||
index: inlineRange.index - 4,
|
||||
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);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (inlinePrefix === '$$') {
|
||||
inlineEditor.insertText(
|
||||
{
|
||||
index: inlineRange.index,
|
||||
length: 0,
|
||||
},
|
||||
' '
|
||||
);
|
||||
inlineEditor.setInlineRange({
|
||||
index: inlineRange.index + 1,
|
||||
length: 0,
|
||||
});
|
||||
|
||||
undoManager.stopCapturing();
|
||||
|
||||
inlineEditor.deleteText({
|
||||
index: inlineRange.index - 2,
|
||||
length: 3,
|
||||
});
|
||||
inlineEditor.insertText(
|
||||
{
|
||||
index: inlineRange.index - 2,
|
||||
length: 0,
|
||||
},
|
||||
' '
|
||||
);
|
||||
inlineEditor.formatText(
|
||||
{
|
||||
index: inlineRange.index - 2,
|
||||
length: 1,
|
||||
},
|
||||
{
|
||||
latex: '',
|
||||
}
|
||||
);
|
||||
|
||||
inlineEditor
|
||||
.waitForUpdate()
|
||||
.then(async () => {
|
||||
await inlineEditor.waitForUpdate();
|
||||
|
||||
const textPoint = inlineEditor.getTextPoint(
|
||||
inlineRange.index - 2 + 1
|
||||
);
|
||||
if (!textPoint) return;
|
||||
|
||||
const [text] = textPoint;
|
||||
const latexNode = text.parentElement?.closest('affine-latex-node');
|
||||
if (!latexNode) return;
|
||||
|
||||
latexNode.toggleEditor();
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!content || content.length === 0) return;
|
||||
|
||||
inlineEditor.insertText(
|
||||
{
|
||||
index: inlineRange.index,
|
||||
length: 0,
|
||||
},
|
||||
' '
|
||||
);
|
||||
inlineEditor.setInlineRange({
|
||||
index: inlineRange.index + 1,
|
||||
length: 0,
|
||||
});
|
||||
|
||||
undoManager.stopCapturing();
|
||||
|
||||
const startIndex = inlineRange.index - 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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const MarkdownExtensions: ExtensionType[] = [
|
||||
BoldItalicMarkdown,
|
||||
BoldMarkdown,
|
||||
ItalicExtension,
|
||||
StrikethroughExtension,
|
||||
UnderthroughExtension,
|
||||
CodeExtension,
|
||||
LinkExtension,
|
||||
LatexExtension,
|
||||
];
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import { ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { type DeltaInsert, ZERO_WIDTH_SPACE } from '@blocksuite/inline';
|
||||
import { html } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { type StyleInfo, styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
export function affineTextStyles(
|
||||
props: AffineTextAttributes,
|
||||
override?: Readonly<StyleInfo>
|
||||
): StyleInfo {
|
||||
let textDecorations = '';
|
||||
if (props.underline) {
|
||||
textDecorations += 'underline';
|
||||
}
|
||||
if (props.strike) {
|
||||
textDecorations += ' line-through';
|
||||
}
|
||||
|
||||
let inlineCodeStyle = {};
|
||||
if (props.code) {
|
||||
inlineCodeStyle = {
|
||||
'font-family': 'var(--affine-font-code-family)',
|
||||
background: 'var(--affine-background-code-block)',
|
||||
border: '1px solid var(--affine-border-color)',
|
||||
'border-radius': '4px',
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
'font-variant-ligatures': 'none',
|
||||
'line-height': 'auto',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
'font-weight': props.bold ? 'bolder' : 'inherit',
|
||||
'font-style': props.italic ? 'italic' : 'normal',
|
||||
'background-color': props.background ? props.background : undefined,
|
||||
color: props.color ? props.color : undefined,
|
||||
'text-decoration': textDecorations.length > 0 ? textDecorations : 'none',
|
||||
...inlineCodeStyle,
|
||||
...override,
|
||||
};
|
||||
}
|
||||
|
||||
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_SPACE,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { FootNote } from '@blocksuite/affine-model';
|
||||
import { type BlockStdScope, StdIdentifier } from '@blocksuite/block-std';
|
||||
import { createIdentifier } from '@blocksuite/global/di';
|
||||
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))
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { HoverController } from '@blocksuite/affine-components/hover';
|
||||
import type { FootNote } from '@blocksuite/affine-model';
|
||||
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import {
|
||||
BlockSelection,
|
||||
type BlockStdScope,
|
||||
ShadowlessElement,
|
||||
TextSelection,
|
||||
} from '@blocksuite/block-std';
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import {
|
||||
type DeltaInsert,
|
||||
INLINE_ROOT_ATTR,
|
||||
type InlineRootElement,
|
||||
ZERO_WIDTH_NON_JOINER,
|
||||
ZERO_WIDTH_SPACE,
|
||||
} from '@blocksuite/inline';
|
||||
import { 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;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
private readonly _FootNoteDefaultContent = (footnote: FootNote) => {
|
||||
return html`<span class="footnote-content-default"
|
||||
>${footnote.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}
|
||||
></footnote-popup>`;
|
||||
};
|
||||
|
||||
private readonly _whenHover: HoverController = new HoverController(
|
||||
this,
|
||||
({ abortController }) => {
|
||||
const footnote = this.delta.attributes?.footnote;
|
||||
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;
|
||||
}
|
||||
|
||||
return {
|
||||
template: this._FootNotePopup(footnote, abortController),
|
||||
container: this,
|
||||
computePosition: {
|
||||
referenceElement: this,
|
||||
placement: 'top',
|
||||
autoUpdate: true,
|
||||
middleware: [shift({ padding: POPUP_SHIFT_PADDING })],
|
||||
},
|
||||
};
|
||||
},
|
||||
{ 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_NON_JOINER}></v-text
|
||||
></span>`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor config: FootNoteNodeConfigProvider | undefined = undefined;
|
||||
|
||||
@property({ type: Object })
|
||||
accessor delta: DeltaInsert<AffineTextAttributes> = {
|
||||
insert: ZERO_WIDTH_SPACE,
|
||||
attributes: {},
|
||||
};
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor std!: BlockStdScope;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
|
||||
import { css, html, LitElement, nothing, type TemplateResult } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
export class FootNotePopupChip extends LitElement {
|
||||
static override styles = css`
|
||||
.popup-chip-container {
|
||||
display: flex;
|
||||
border-radius: 4px;
|
||||
max-width: 173px;
|
||||
height: 24px;
|
||||
padding: 2px 4px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
box-sizing: border-box;
|
||||
cursor: default;
|
||||
transition: width 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.prefix-icon,
|
||||
.suffix-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: ${unsafeCSSVarV2('icon/primary')};
|
||||
border-radius: 4px;
|
||||
|
||||
svg,
|
||||
object {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: ${unsafeCSSVarV2('icon/primary')};
|
||||
}
|
||||
}
|
||||
|
||||
.suffix-icon:hover {
|
||||
background-color: ${unsafeCSSVarV2('layer/background/hoverOverlay')};
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.popup-chip-label {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
color: ${unsafeCSSVarV2('text/primary')};
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
`;
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="popup-chip-container" @click=${this.onClick}>
|
||||
${this.prefixIcon
|
||||
? html`<div class="prefix-icon" @click=${this.onPrefixClick}>
|
||||
${this.prefixIcon}
|
||||
</div>`
|
||||
: nothing}
|
||||
<div class="popup-chip-label" title=${this.tooltip}>${this.label}</div>
|
||||
${this.suffixIcon
|
||||
? html`<div class="suffix-icon" @click=${this.onSuffixClick}>
|
||||
${this.suffixIcon}
|
||||
</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor prefixIcon: TemplateResult | undefined = undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor label: string = '';
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor suffixIcon: TemplateResult | undefined = undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor tooltip: string = '';
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onClick: (() => void) | undefined = undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onPrefixClick: (() => void) | undefined = undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onSuffixClick: (() => void) | undefined = undefined;
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import {
|
||||
DarkLoadingIcon,
|
||||
getAttachmentFileIcon,
|
||||
LightLoadingIcon,
|
||||
WebIcon16,
|
||||
} from '@blocksuite/affine-components/icons';
|
||||
import { PeekViewProvider } from '@blocksuite/affine-components/peek';
|
||||
import { ColorScheme, type FootNote } from '@blocksuite/affine-model';
|
||||
import {
|
||||
DocDisplayMetaProvider,
|
||||
LinkPreviewerService,
|
||||
ThemeProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
|
||||
import type { BlockStdScope } from '@blocksuite/block-std';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
import { DualLinkIcon, LinkIcon } from '@blocksuite/icons/lit';
|
||||
import { computed, signal } from '@preact/signals-core';
|
||||
import { css, html, LitElement, type TemplateResult } 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: 4px;
|
||||
box-shadow: ${unsafeCSSVar('overlayPanelShadow')};
|
||||
background-color: ${unsafeCSSVarV2('layer/background/primary')};
|
||||
border: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
|
||||
}
|
||||
`;
|
||||
|
||||
private readonly _isLoading$ = signal(false);
|
||||
|
||||
private readonly _linkPreview$ = signal<
|
||||
{ favicon: string | undefined; title?: string } | undefined
|
||||
>({ favicon: undefined, title: 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 this._LoadingIcon();
|
||||
}
|
||||
|
||||
const favicon = this._linkPreview$.value?.favicon;
|
||||
if (!favicon) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const titleIconType =
|
||||
favicon.split('.').pop() === 'svg'
|
||||
? 'svg+xml'
|
||||
: favicon.split('.').pop();
|
||||
const titleIcon = html`<object
|
||||
type="image/${titleIconType}"
|
||||
data=${favicon}
|
||||
draggable="false"
|
||||
>
|
||||
${WebIcon16}
|
||||
</object>`;
|
||||
return titleIcon;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
private readonly _suffixIcon = (): TemplateResult | undefined => {
|
||||
const referenceType = this.footnote.reference.type;
|
||||
if (referenceType === 'doc') {
|
||||
return DualLinkIcon({ width: '16px', height: '16px' });
|
||||
} else if (referenceType === 'url') {
|
||||
return LinkIcon({ width: '16px', height: '16px' });
|
||||
}
|
||||
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') {
|
||||
return this.footnote.reference.url ?? '';
|
||||
}
|
||||
return this._popupLabel$.value;
|
||||
});
|
||||
|
||||
private readonly _LoadingIcon = () => {
|
||||
const theme = this.std.get(ThemeProvider).theme;
|
||||
return theme === ColorScheme.Light ? LightLoadingIcon : DarkLoadingIcon;
|
||||
};
|
||||
|
||||
/**
|
||||
* When clicking the chip, we will navigate to the reference doc or open the url
|
||||
*/
|
||||
private readonly _handleDocReference = (docId: string) => {
|
||||
this.std
|
||||
.getOptional(PeekViewProvider)
|
||||
?.peek({
|
||||
docId,
|
||||
})
|
||||
.catch(console.error);
|
||||
};
|
||||
|
||||
private readonly _handleUrlReference = (url: string) => {
|
||||
window.open(url, '_blank');
|
||||
};
|
||||
|
||||
private readonly _handleReference = () => {
|
||||
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;
|
||||
}
|
||||
|
||||
this.abortController.abort();
|
||||
};
|
||||
|
||||
private readonly _onChipClick = () => {
|
||||
// If the onPopupClick is defined, use it
|
||||
if (this.onPopupClick) {
|
||||
this.onPopupClick(this.footnote, this.abortController);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, handle the reference by default
|
||||
this._handleReference();
|
||||
};
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
if (this.footnote.reference.type === 'url' && this.footnote.reference.url) {
|
||||
this._isLoading$.value = true;
|
||||
this.std.store
|
||||
.get(LinkPreviewerService)
|
||||
.query(this.footnote.reference.url)
|
||||
.then(data => {
|
||||
this._linkPreview$.value = {
|
||||
favicon: data.icon ?? undefined,
|
||||
title: data.title ?? undefined,
|
||||
};
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => {
|
||||
this._isLoading$.value = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="footnote-popup-container">
|
||||
<footnote-popup-chip
|
||||
.prefixIcon=${this._prefixIcon$.value}
|
||||
.label=${this._popupLabel$.value}
|
||||
.suffixIcon=${this._suffixIcon()}
|
||||
.onClick=${this._onChipClick}
|
||||
.tooltip=${this._tooltip$.value}
|
||||
></footnote-popup-chip>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor footnote!: FootNote;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor std!: BlockStdScope;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor abortController!: AbortController;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onPopupClick: FootNotePopupClickHandler | undefined = undefined;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { affineTextStyles } from './affine-text.js';
|
||||
export * from './footnote-node/footnote-config.js';
|
||||
export { AffineFootnoteNode } from './footnote-node/footnote-node.js';
|
||||
export { AffineLink, toggleLinkPopup } from './link-node/index.js';
|
||||
export * from './reference-node/index.js';
|
||||
@@ -0,0 +1,197 @@
|
||||
import { ColorScheme } from '@blocksuite/affine-model';
|
||||
import { ThemeProvider } from '@blocksuite/affine-shared/services';
|
||||
import { unsafeCSSVar } from '@blocksuite/affine-shared/theme';
|
||||
import { type BlockStdScope, ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
import { noop } from '@blocksuite/global/utils';
|
||||
import { DoneIcon } from '@blocksuite/icons/lit';
|
||||
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 { InlineManagerExtension } from '../../../../extension/index.js';
|
||||
import { LatexEditorUnitSpecExtension } from '../../affine-inline-specs.js';
|
||||
|
||||
export const LatexEditorInlineManagerExtension = InlineManagerExtension({
|
||||
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;
|
||||
}
|
||||
.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('rich-text');
|
||||
}
|
||||
|
||||
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">
|
||||
<rich-text
|
||||
.yText=${this.yText}
|
||||
.attributesSchema=${this.inlineManager.getSchema()}
|
||||
.attributeRenderer=${this.inlineManager.getRenderer()}
|
||||
></rich-text>
|
||||
</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;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import { ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { type DeltaInsert, ZERO_WIDTH_SPACE } from '@blocksuite/inline';
|
||||
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_SPACE,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import { createLitPortal } from '@blocksuite/affine-components/portal';
|
||||
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import {
|
||||
type BlockComponent,
|
||||
type BlockStdScope,
|
||||
ShadowlessElement,
|
||||
} from '@blocksuite/block-std';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
import {
|
||||
type DeltaInsert,
|
||||
type InlineEditor,
|
||||
ZERO_WIDTH_NON_JOINER,
|
||||
ZERO_WIDTH_SPACE,
|
||||
} from '@blocksuite/inline';
|
||||
import { signal } from '@preact/signals-core';
|
||||
import katex from 'katex';
|
||||
import { css, html, 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;
|
||||
|
||||
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: true,
|
||||
output: 'mathml',
|
||||
});
|
||||
} 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;
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`<span class="affine-latex" data-selected=${this.selected}
|
||||
><div class="latex-container"></div>
|
||||
<v-text .str=${ZERO_WIDTH_NON_JOINER}></v-text
|
||||
></span>`;
|
||||
}
|
||||
|
||||
toggleEditor() {
|
||||
const blockComponent = this.closest<BlockComponent>('[data-block-id]');
|
||||
if (!blockComponent) return;
|
||||
|
||||
this._editorAbortController?.abort();
|
||||
this._editorAbortController = new AbortController();
|
||||
|
||||
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._editorAbortController.signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
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_SPACE,
|
||||
};
|
||||
|
||||
@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;
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { whenHover } from '@blocksuite/affine-components/hover';
|
||||
import type { ReferenceInfo } from '@blocksuite/affine-model';
|
||||
import {
|
||||
ParseDocUrlProvider,
|
||||
ToolbarRegistryIdentifier,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import type { BlockComponent, BlockStdScope } from '@blocksuite/block-std';
|
||||
import { BLOCK_ID_ATTR, ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import {
|
||||
type DeltaInsert,
|
||||
INLINE_ROOT_ATTR,
|
||||
type InlineRootElement,
|
||||
ZERO_WIDTH_SPACE,
|
||||
} from '@blocksuite/inline';
|
||||
import { css, html } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { type StyleInfo, styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { RefNodeSlotsProvider } from '../../../../extension';
|
||||
import { affineTextStyles } from '../affine-text';
|
||||
|
||||
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.emit({
|
||||
...referenceInfo,
|
||||
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 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}
|
||||
@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)',
|
||||
'text-decoration': 'none',
|
||||
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_SPACE,
|
||||
};
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor std!: BlockStdScope;
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
import { toast } from '@blocksuite/affine-components/toast';
|
||||
import {
|
||||
ActionPlacement,
|
||||
EmbedOptionProvider,
|
||||
type ToolbarAction,
|
||||
type ToolbarActionGroup,
|
||||
type ToolbarModuleConfig,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { BlockSelection } from '@blocksuite/block-std';
|
||||
import {
|
||||
CopyIcon,
|
||||
DeleteIcon,
|
||||
EditIcon,
|
||||
UnlinkIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
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;
|
||||
|
||||
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',
|
||||
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 {
|
||||
block: { model },
|
||||
inlineEditor,
|
||||
selfInlineRange,
|
||||
} = target;
|
||||
const { parent } = model;
|
||||
|
||||
if (!inlineEditor || !selfInlineRange || !parent) return;
|
||||
|
||||
const url = inlineEditor.getFormat(selfInlineRange).link;
|
||||
if (!url) 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 {
|
||||
block: { model },
|
||||
inlineEditor,
|
||||
selfInlineRange,
|
||||
} = target;
|
||||
const { parent } = model;
|
||||
|
||||
if (!inlineEditor || !selfInlineRange || !parent) return false;
|
||||
|
||||
const url = inlineEditor.getFormat(selfInlineRange).link;
|
||||
if (!url) return false;
|
||||
|
||||
const options = ctx.std
|
||||
.get(EmbedOptionProvider)
|
||||
.getEmbedBlockOptions(url);
|
||||
return options?.viewType === 'embed';
|
||||
},
|
||||
run(ctx) {
|
||||
const target = ctx.message$.peek()?.element;
|
||||
if (!(target instanceof AffineLink)) return;
|
||||
if (!target.block) return;
|
||||
|
||||
const {
|
||||
block: { model },
|
||||
inlineEditor,
|
||||
selfInlineRange,
|
||||
} = target;
|
||||
const { parent } = model;
|
||||
|
||||
if (!inlineEditor || !selfInlineRange || !parent) return;
|
||||
|
||||
const url = inlineEditor.getFormat(selfInlineRange).link;
|
||||
if (!url) return;
|
||||
|
||||
// Clears
|
||||
ctx.reset();
|
||||
|
||||
const options = ctx.std
|
||||
.get(EmbedOptionProvider)
|
||||
.getEmbedBlockOptions(url);
|
||||
if (options?.viewType !== 'embed') return;
|
||||
|
||||
const flavour = options.flavour;
|
||||
const index = parent.children.indexOf(model);
|
||||
const props = { url };
|
||||
|
||||
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: '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 toggle = (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}
|
||||
.toggle=${toggle}
|
||||
.viewType$=${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;
|
||||
|
||||
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;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { AffineLink } from './affine-link.js';
|
||||
export { toggleLinkPopup } from './link-popup/toggle-link-popup.js';
|
||||
@@ -0,0 +1,305 @@
|
||||
import type { EditorIconButton } from '@blocksuite/affine-components/toolbar';
|
||||
import {
|
||||
isValidUrl,
|
||||
normalizeUrl,
|
||||
stopPropagation,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import { type BlockStdScope, TextSelection } from '@blocksuite/block-std';
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import { DoneIcon } from '@blocksuite/icons/lit';
|
||||
import type { InlineRange } from '@blocksuite/inline/types';
|
||||
import { computePosition, inline, offset, shift } from '@floating-ui/dom';
|
||||
import { html, LitElement } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { choose } from 'lit/directives/choose.js';
|
||||
|
||||
import type { AffineInlineEditor } from '../../../affine-inline-specs';
|
||||
import { linkPopupStyle } from './styles';
|
||||
|
||||
export class LinkPopup extends WithDisposable(LitElement) {
|
||||
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();
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
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>
|
||||
`;
|
||||
}
|
||||
|
||||
override updated() {
|
||||
const range = this.inlineEditor.toDomRange(this.targetInlineRange);
|
||||
if (!range) {
|
||||
return;
|
||||
}
|
||||
|
||||
const domRects = range.getClientRects();
|
||||
|
||||
Object.values(domRects).forEach(domRect => {
|
||||
if (!this.mockSelectionContainer) {
|
||||
return;
|
||||
}
|
||||
const mockSelection = document.createElement('div');
|
||||
mockSelection.classList.add('mock-selection');
|
||||
mockSelection.style.left = `${domRect.left}px`;
|
||||
mockSelection.style.top = `${domRect.top}px`;
|
||||
mockSelection.style.width = `${domRect.width}px`;
|
||||
mockSelection.style.height = `${domRect.height}px`;
|
||||
|
||||
this.mockSelectionContainer.append(mockSelection);
|
||||
});
|
||||
|
||||
const visualElement = {
|
||||
getBoundingClientRect: () => range.getBoundingClientRect(),
|
||||
getClientRects: () => range.getClientRects(),
|
||||
};
|
||||
const popover = this.popoverContainer;
|
||||
|
||||
computePosition(visualElement, popover, {
|
||||
middleware: [
|
||||
offset(10),
|
||||
inline(),
|
||||
shift({
|
||||
padding: 6,
|
||||
}),
|
||||
],
|
||||
})
|
||||
.then(({ x, y }) => {
|
||||
popover.style.left = `${x}px`;
|
||||
popover.style.top = `${y}px`;
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { FONT_XS, PANEL_BASE } from '@blocksuite/affine-shared/styles';
|
||||
import { css } from 'lit';
|
||||
|
||||
const editLinkStyle = css`
|
||||
.affine-link-edit-popover {
|
||||
${PANEL_BASE};
|
||||
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: 12px;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
.affine-link-edit-popover label {
|
||||
box-sizing: border-box;
|
||||
color: var(--affine-icon-color);
|
||||
${FONT_XS};
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.affine-link-edit-popover input {
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--affine-text-primary-color);
|
||||
${FONT_XS};
|
||||
}
|
||||
.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);
|
||||
}
|
||||
|
||||
.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-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: var(--affine-z-index-popover);
|
||||
}
|
||||
|
||||
.affine-link-popover.create {
|
||||
${PANEL_BASE};
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
|
||||
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);
|
||||
${FONT_XS};
|
||||
}
|
||||
.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}
|
||||
`;
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { BlockStdScope } from '@blocksuite/block-std';
|
||||
import type { InlineRange } from '@blocksuite/inline';
|
||||
|
||||
import type { AffineInlineEditor } from '../../../affine-inline-specs';
|
||||
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;
|
||||
|
||||
document.body.append(popup);
|
||||
|
||||
return popup;
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { notifyLinkedDocSwitchedToEmbed } from '@blocksuite/affine-components/notification';
|
||||
import {
|
||||
ActionPlacement,
|
||||
type ToolbarAction,
|
||||
type ToolbarActionGroup,
|
||||
type ToolbarModuleConfig,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
cloneReferenceInfoWithoutAliases,
|
||||
isInsideBlockByFlavour,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import { BlockSelection } from '@blocksuite/block-std';
|
||||
import { DeleteIcon } from '@blocksuite/icons/lit';
|
||||
import { signal } from '@preact/signals-core';
|
||||
import { html } from 'lit-html';
|
||||
import { keyed } from 'lit-html/directives/keyed.js';
|
||||
|
||||
import { AffineReference } from '../reference-node';
|
||||
|
||||
const trackBaseProps = {
|
||||
segment: 'doc',
|
||||
page: 'doc editor',
|
||||
module: 'toolbar',
|
||||
category: 'linked doc',
|
||||
type: 'inline view',
|
||||
};
|
||||
|
||||
export const builtinInlineReferenceToolbarConfig = {
|
||||
actions: [
|
||||
{
|
||||
id: 'a.doc-title',
|
||||
content(ctx) {
|
||||
const target = ctx.message$.peek()?.element;
|
||||
if (!(target instanceof AffineReference)) return null;
|
||||
if (!target.referenceInfo.title) return null;
|
||||
|
||||
return html`<affine-linked-doc-title
|
||||
.title=${target.docTitle}
|
||||
.open=${(event: MouseEvent) => target.open({ event })}
|
||||
></affine-linked-doc-title>`;
|
||||
},
|
||||
},
|
||||
{
|
||||
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 AffineReference)) return;
|
||||
if (!target.block) return;
|
||||
|
||||
const {
|
||||
block: { model },
|
||||
referenceInfo,
|
||||
inlineEditor,
|
||||
selfInlineRange,
|
||||
} = target;
|
||||
const { parent } = model;
|
||||
|
||||
if (!inlineEditor || !selfInlineRange || !parent) return;
|
||||
|
||||
// Clears
|
||||
ctx.reset();
|
||||
|
||||
const index = parent.children.indexOf(model);
|
||||
|
||||
const blockId = ctx.store.addBlock(
|
||||
'affine:embed-linked-doc',
|
||||
referenceInfo,
|
||||
parent,
|
||||
index + 1
|
||||
);
|
||||
|
||||
const totalTextLength = inlineEditor.yTextLength;
|
||||
const inlineTextLength = selfInlineRange.length;
|
||||
if (totalTextLength === inlineTextLength) {
|
||||
ctx.store.deleteBlock(model);
|
||||
} else {
|
||||
inlineEditor.insertText(selfInlineRange, target.docTitle);
|
||||
}
|
||||
|
||||
ctx.select('note', [
|
||||
ctx.selection.create(BlockSelection, { blockId }),
|
||||
]);
|
||||
|
||||
ctx.track('SelectedView', {
|
||||
...trackBaseProps,
|
||||
control: 'select view',
|
||||
type: 'card view',
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'embed',
|
||||
label: 'Embed view',
|
||||
disabled(ctx) {
|
||||
const target = ctx.message$.peek()?.element;
|
||||
if (!(target instanceof AffineReference)) return true;
|
||||
if (!target.block) return true;
|
||||
|
||||
if (
|
||||
isInsideBlockByFlavour(
|
||||
ctx.store,
|
||||
target.block.model,
|
||||
'affine:edgeless-text'
|
||||
)
|
||||
)
|
||||
return true;
|
||||
|
||||
// nesting is not supported
|
||||
if (target.closest('affine-embed-synced-doc-block')) return true;
|
||||
|
||||
// same doc
|
||||
if (target.referenceInfo.pageId === ctx.store.id) return true;
|
||||
|
||||
// linking to block
|
||||
if (target.referenceToNode()) return true;
|
||||
|
||||
return false;
|
||||
},
|
||||
run(ctx) {
|
||||
const target = ctx.message$.peek()?.element;
|
||||
if (!(target instanceof AffineReference)) return;
|
||||
if (!target.block) return;
|
||||
|
||||
const {
|
||||
block: { model },
|
||||
referenceInfo,
|
||||
inlineEditor,
|
||||
selfInlineRange,
|
||||
} = target;
|
||||
const { parent } = model;
|
||||
|
||||
if (!inlineEditor || !selfInlineRange || !parent) return;
|
||||
|
||||
// Clears
|
||||
ctx.reset();
|
||||
|
||||
const index = parent.children.indexOf(model);
|
||||
|
||||
const blockId = ctx.store.addBlock(
|
||||
'affine:embed-synced-doc',
|
||||
cloneReferenceInfoWithoutAliases(referenceInfo),
|
||||
parent,
|
||||
index + 1
|
||||
);
|
||||
|
||||
const totalTextLength = inlineEditor.yTextLength;
|
||||
const inlineTextLength = selfInlineRange.length;
|
||||
if (totalTextLength === inlineTextLength) {
|
||||
ctx.store.deleteBlock(model);
|
||||
} else {
|
||||
inlineEditor.insertText(selfInlineRange, target.docTitle);
|
||||
}
|
||||
|
||||
const hasTitleAlias = Boolean(referenceInfo.title);
|
||||
|
||||
if (hasTitleAlias) {
|
||||
notifyLinkedDocSwitchedToEmbed(ctx.std);
|
||||
}
|
||||
|
||||
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 AffineReference)) return null;
|
||||
|
||||
const actions = this.actions.map(action => ({ ...action }));
|
||||
const viewType$ = signal(actions[0].label);
|
||||
const toggle = (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}
|
||||
.toggle=${toggle}
|
||||
.viewType$=${viewType$}
|
||||
></affine-view-dropdown-menu>`
|
||||
)}`;
|
||||
},
|
||||
when(ctx) {
|
||||
const target = ctx.message$.peek()?.element;
|
||||
if (!(target instanceof AffineReference)) 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;
|
||||
|
||||
return true;
|
||||
},
|
||||
} satisfies ToolbarActionGroup<ToolbarAction>,
|
||||
{
|
||||
placement: ActionPlacement.More,
|
||||
id: 'c.delete',
|
||||
label: 'Delete',
|
||||
icon: DeleteIcon(),
|
||||
variant: 'destructive',
|
||||
run(ctx) {
|
||||
const target = ctx.message$.peek()?.element;
|
||||
if (!(target instanceof AffineReference)) return;
|
||||
|
||||
const { inlineEditor, selfInlineRange } = target;
|
||||
if (!inlineEditor || !selfInlineRange) return;
|
||||
|
||||
if (!inlineEditor.isValidInlineRange(selfInlineRange)) return;
|
||||
|
||||
inlineEditor.deleteText(selfInlineRange);
|
||||
},
|
||||
},
|
||||
],
|
||||
} as const satisfies ToolbarModuleConfig;
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './reference-config';
|
||||
export { AffineReference } from './reference-node';
|
||||
export { toggleReferencePopup } from './reference-popup/toggle-reference-popup';
|
||||
export type { DocLinkClickedEvent, RefNodeSlots } from './types';
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { BlockStdScope } from '@blocksuite/block-std';
|
||||
import { createIdentifier } from '@blocksuite/global/di';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
import type { TemplateResult } from 'lit';
|
||||
|
||||
import type { AffineReference } from './reference-node';
|
||||
|
||||
export interface ReferenceNodeConfig {
|
||||
customContent?: (reference: AffineReference) => TemplateResult;
|
||||
interactable?: boolean;
|
||||
hidePopup?: boolean;
|
||||
}
|
||||
|
||||
export const ReferenceNodeConfigIdentifier =
|
||||
createIdentifier<ReferenceNodeConfig>('AffineReferenceNodeConfig');
|
||||
|
||||
export function ReferenceNodeConfigExtension(
|
||||
config: ReferenceNodeConfig
|
||||
): ExtensionType {
|
||||
return {
|
||||
setup: di => {
|
||||
di.addImpl(ReferenceNodeConfigIdentifier, () => ({ ...config }));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export class ReferenceNodeConfigProvider {
|
||||
private _customContent:
|
||||
| ((reference: AffineReference) => TemplateResult)
|
||||
| undefined = undefined;
|
||||
|
||||
private _hidePopup = false;
|
||||
|
||||
private _interactable = true;
|
||||
|
||||
get customContent() {
|
||||
return this._customContent;
|
||||
}
|
||||
|
||||
get doc() {
|
||||
return this.std.store;
|
||||
}
|
||||
|
||||
get hidePopup() {
|
||||
return this._hidePopup;
|
||||
}
|
||||
|
||||
get interactable() {
|
||||
return this._interactable;
|
||||
}
|
||||
|
||||
constructor(readonly std: BlockStdScope) {}
|
||||
|
||||
setCustomContent(content: ReferenceNodeConfigProvider['_customContent']) {
|
||||
this._customContent = content;
|
||||
}
|
||||
|
||||
setHidePopup(hidePopup: boolean) {
|
||||
this._hidePopup = hidePopup;
|
||||
}
|
||||
|
||||
setInteractable(interactable: boolean) {
|
||||
this._interactable = interactable;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import { whenHover } from '@blocksuite/affine-components/hover';
|
||||
import { Peekable } from '@blocksuite/affine-components/peek';
|
||||
import type { ReferenceInfo } from '@blocksuite/affine-model';
|
||||
import {
|
||||
DEFAULT_DOC_NAME,
|
||||
REFERENCE_NODE,
|
||||
} from '@blocksuite/affine-shared/consts';
|
||||
import {
|
||||
DocDisplayMetaProvider,
|
||||
ToolbarRegistryIdentifier,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import {
|
||||
cloneReferenceInfo,
|
||||
referenceToNode,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import type { BlockComponent, BlockStdScope } from '@blocksuite/block-std';
|
||||
import { BLOCK_ID_ATTR, ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import { LinkedPageIcon } from '@blocksuite/icons/lit';
|
||||
import {
|
||||
type DeltaInsert,
|
||||
INLINE_ROOT_ATTR,
|
||||
type InlineRootElement,
|
||||
ZERO_WIDTH_NON_JOINER,
|
||||
ZERO_WIDTH_SPACE,
|
||||
} from '@blocksuite/inline';
|
||||
import type { DocMeta, Store } from '@blocksuite/store';
|
||||
import { css, html, nothing } from 'lit';
|
||||
import { property, state } from 'lit/decorators.js';
|
||||
import { choose } from 'lit/directives/choose.js';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { RefNodeSlotsProvider } from '../../../../extension';
|
||||
import { affineTextStyles } from '../affine-text';
|
||||
import type { ReferenceNodeConfigProvider } from './reference-config';
|
||||
import type { DocLinkClickedEvent } from './types';
|
||||
|
||||
@Peekable({ action: false })
|
||||
export class AffineReference extends WithDisposable(ShadowlessElement) {
|
||||
static override styles = css`
|
||||
.affine-reference {
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
color: var(--affine-text-primary-color);
|
||||
fill: var(--affine-icon-color);
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
padding: 1px 2px 1px 0;
|
||||
}
|
||||
.affine-reference:hover {
|
||||
background: var(--affine-hover-color);
|
||||
}
|
||||
|
||||
.affine-reference[data-selected='true'] {
|
||||
background: var(--affine-hover-color);
|
||||
}
|
||||
|
||||
.affine-reference-title {
|
||||
margin-left: 4px;
|
||||
border-bottom: 0.5px solid var(--affine-divider-color);
|
||||
transition: border 0.2s ease-out;
|
||||
}
|
||||
.affine-reference-title:hover {
|
||||
border-bottom: 0.5px solid var(--affine-icon-color);
|
||||
}
|
||||
`;
|
||||
|
||||
get docTitle() {
|
||||
return this.refMeta?.title ?? DEFAULT_DOC_NAME;
|
||||
}
|
||||
|
||||
private readonly _updateRefMeta = (doc: Store) => {
|
||||
const refAttribute = this.delta.attributes?.reference;
|
||||
if (!refAttribute) {
|
||||
return;
|
||||
}
|
||||
|
||||
const refMeta = doc.workspace.meta.docMetas.find(
|
||||
doc => doc.id === refAttribute.pageId
|
||||
);
|
||||
this.refMeta = refMeta
|
||||
? {
|
||||
...refMeta,
|
||||
}
|
||||
: undefined;
|
||||
};
|
||||
|
||||
// Since the linked doc may be deleted, the `_refMeta` could be undefined.
|
||||
@state()
|
||||
accessor refMeta: DocMeta | undefined = undefined;
|
||||
|
||||
get _icon() {
|
||||
const { pageId, params, title } = this.referenceInfo;
|
||||
return this.std
|
||||
.get(DocDisplayMetaProvider)
|
||||
.icon(pageId, { params, title, referenced: true }).value;
|
||||
}
|
||||
|
||||
get _title() {
|
||||
const { pageId, params, title } = this.referenceInfo;
|
||||
return (
|
||||
this.std
|
||||
.get(DocDisplayMetaProvider)
|
||||
.title(pageId, { params, title, referenced: true }).value || title
|
||||
);
|
||||
}
|
||||
|
||||
get block() {
|
||||
if (!this.inlineEditor?.rootElement) return null;
|
||||
const block = this.inlineEditor.rootElement.closest<BlockComponent>(
|
||||
`[${BLOCK_ID_ATTR}]`
|
||||
);
|
||||
return block;
|
||||
}
|
||||
|
||||
get customContent() {
|
||||
return this.config.customContent;
|
||||
}
|
||||
|
||||
get doc() {
|
||||
const doc = this.config.doc;
|
||||
return doc;
|
||||
}
|
||||
|
||||
get inlineEditor() {
|
||||
const inlineRoot = this.closest<InlineRootElement<AffineTextAttributes>>(
|
||||
`[${INLINE_ROOT_ATTR}]`
|
||||
);
|
||||
return inlineRoot?.inlineEditor;
|
||||
}
|
||||
|
||||
get referenceInfo(): ReferenceInfo {
|
||||
const reference = this.delta.attributes?.reference;
|
||||
const id = this.doc?.id ?? '';
|
||||
if (!reference) return { pageId: id };
|
||||
return cloneReferenceInfo(reference);
|
||||
}
|
||||
|
||||
get selfInlineRange() {
|
||||
const selfInlineRange = this.inlineEditor?.getInlineRangeFromElement(this);
|
||||
return selfInlineRange;
|
||||
}
|
||||
|
||||
readonly open = (event?: Partial<DocLinkClickedEvent>) => {
|
||||
if (!this.config.interactable) return;
|
||||
|
||||
this.std.getOptional(RefNodeSlotsProvider)?.docLinkClicked.emit({
|
||||
...this.referenceInfo,
|
||||
...event,
|
||||
host: this.std.host,
|
||||
});
|
||||
};
|
||||
|
||||
_whenHover = whenHover(
|
||||
hovered => {
|
||||
if (!this.config.interactable) return;
|
||||
|
||||
const message$ = this.std.get(ToolbarRegistryIdentifier).message$;
|
||||
|
||||
if (hovered) {
|
||||
message$.value = {
|
||||
flavour: 'affine:reference',
|
||||
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();
|
||||
});
|
||||
|
||||
if (!this.config) {
|
||||
console.error('`reference-node` need `ReferenceNodeConfig`.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.delta.insert !== REFERENCE_NODE) {
|
||||
console.error(
|
||||
`Reference node must be initialized with '${REFERENCE_NODE}', but got '${this.delta.insert}'`
|
||||
);
|
||||
}
|
||||
|
||||
const doc = this.doc;
|
||||
if (doc) {
|
||||
this._disposables.add(
|
||||
doc.workspace.slots.docListUpdated.on(() => this._updateRefMeta(doc))
|
||||
);
|
||||
}
|
||||
|
||||
this.updateComplete
|
||||
.then(() => {
|
||||
if (!this.inlineEditor || !doc) return;
|
||||
|
||||
// observe yText update
|
||||
this.disposables.add(
|
||||
this.inlineEditor.slots.textChange.on(() => this._updateRefMeta(doc))
|
||||
);
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
// reference to block/element
|
||||
referenceToNode() {
|
||||
return referenceToNode(this.referenceInfo);
|
||||
}
|
||||
|
||||
override render() {
|
||||
const refMeta = this.refMeta;
|
||||
const isDeleted = !refMeta;
|
||||
|
||||
const attributes = this.delta.attributes;
|
||||
const reference = attributes?.reference;
|
||||
const type = reference?.type;
|
||||
if (!attributes || !type) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const title = this._title;
|
||||
const icon = choose(type, [
|
||||
['LinkedPage', () => this._icon],
|
||||
[
|
||||
'Subpage',
|
||||
() =>
|
||||
LinkedPageIcon({
|
||||
width: '1.25em',
|
||||
height: '1.25em',
|
||||
style:
|
||||
'user-select:none;flex-shrink:0;vertical-align:middle;font-size:inherit;margin-bottom:0.1em;',
|
||||
}),
|
||||
],
|
||||
]);
|
||||
|
||||
const style = affineTextStyles(
|
||||
attributes,
|
||||
isDeleted
|
||||
? {
|
||||
color: 'var(--affine-text-disable-color)',
|
||||
textDecoration: 'line-through',
|
||||
fill: 'var(--affine-text-disable-color)',
|
||||
}
|
||||
: {}
|
||||
);
|
||||
|
||||
const content = this.customContent
|
||||
? this.customContent(this)
|
||||
: html`${icon}<span
|
||||
data-title=${ifDefined(title)}
|
||||
class="affine-reference-title"
|
||||
>${title}</span
|
||||
>`;
|
||||
|
||||
// we need to add `<v-text .str=${ZERO_WIDTH_NON_JOINER}></v-text>` in an
|
||||
// embed element to make sure inline range calculation is correct
|
||||
return html`<span
|
||||
data-selected=${this.selected}
|
||||
class="affine-reference"
|
||||
style=${styleMap(style)}
|
||||
@click=${(event: MouseEvent) => this.open({ event })}
|
||||
>${content}<v-text .str=${ZERO_WIDTH_NON_JOINER}></v-text
|
||||
></span>`;
|
||||
}
|
||||
|
||||
override willUpdate(_changedProperties: Map<PropertyKey, unknown>) {
|
||||
super.willUpdate(_changedProperties);
|
||||
|
||||
const doc = this.doc;
|
||||
if (doc) {
|
||||
this._updateRefMeta(doc);
|
||||
}
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor config!: ReferenceNodeConfigProvider;
|
||||
|
||||
@property({ type: Object })
|
||||
accessor delta: DeltaInsert<AffineTextAttributes> = {
|
||||
insert: ZERO_WIDTH_SPACE,
|
||||
attributes: {},
|
||||
};
|
||||
|
||||
@property({ type: Boolean })
|
||||
accessor selected = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor std!: BlockStdScope;
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import type { EditorIconButton } from '@blocksuite/affine-components/toolbar';
|
||||
import type { ReferenceInfo } from '@blocksuite/affine-model';
|
||||
import { REFERENCE_NODE } from '@blocksuite/affine-shared/consts';
|
||||
import {
|
||||
type LinkEventType,
|
||||
type TelemetryEvent,
|
||||
TelemetryProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { FONT_XS, PANEL_BASE } from '@blocksuite/affine-shared/styles';
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import { stopPropagation } from '@blocksuite/affine-shared/utils';
|
||||
import { type BlockStdScope, ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
import { DoneIcon, ResetIcon } from '@blocksuite/icons/lit';
|
||||
import type { InlineRange } from '@blocksuite/inline';
|
||||
import { computePosition, inline, offset, shift } from '@floating-ui/dom';
|
||||
import { signal } from '@preact/signals-core';
|
||||
import { css, html } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { live } from 'lit/directives/live.js';
|
||||
|
||||
import type { AffineInlineEditor } from '../../../affine-inline-specs';
|
||||
|
||||
export class ReferencePopup extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
static override styles = css`
|
||||
:host {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.overlay-mask {
|
||||
position: fixed;
|
||||
z-index: var(--affine-z-index-popover);
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.popover-container {
|
||||
${PANEL_BASE};
|
||||
position: absolute;
|
||||
display: flex;
|
||||
width: 321px;
|
||||
height: 37px;
|
||||
gap: 8px;
|
||||
box-sizing: content-box;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
animation: affine-popover-fade-in 0.2s ease;
|
||||
z-index: var(--affine-z-index-popover);
|
||||
}
|
||||
|
||||
@keyframes affine-popover-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--affine-text-primary-color);
|
||||
${FONT_XS};
|
||||
}
|
||||
input::placeholder {
|
||||
color: var(--affine-placeholder-color);
|
||||
}
|
||||
input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
editor-icon-button.save .label {
|
||||
${FONT_XS};
|
||||
color: inherit;
|
||||
text-transform: none;
|
||||
}
|
||||
`;
|
||||
|
||||
private readonly _onSave = () => {
|
||||
const title = this.title$.value.trim();
|
||||
if (!title) {
|
||||
this.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
this._setTitle(title);
|
||||
|
||||
track(this.std, 'SavedAlias', { control: 'save' });
|
||||
|
||||
this.remove();
|
||||
};
|
||||
|
||||
private readonly _updateTitle = (e: InputEvent) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
const value = target.value;
|
||||
this.title$.value = value;
|
||||
};
|
||||
|
||||
private _onKeydown(e: KeyboardEvent) {
|
||||
e.stopPropagation();
|
||||
if (!e.isComposing) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
this.remove();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this._onSave();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _onReset() {
|
||||
this.title$.value = this.docTitle;
|
||||
|
||||
this._setTitle();
|
||||
|
||||
track(this.std, 'ResetedAlias', { control: 'reset' });
|
||||
|
||||
this.remove();
|
||||
}
|
||||
|
||||
private _setTitle(title?: string) {
|
||||
const reference: AffineTextAttributes['reference'] = {
|
||||
type: 'LinkedPage',
|
||||
...this.referenceInfo,
|
||||
};
|
||||
|
||||
if (title) {
|
||||
reference.title = title;
|
||||
} else {
|
||||
delete reference.title;
|
||||
delete reference.description;
|
||||
}
|
||||
|
||||
this.inlineEditor.insertText(this.inlineRange, REFERENCE_NODE, {
|
||||
reference,
|
||||
});
|
||||
this.inlineEditor.setInlineRange({
|
||||
index: this.inlineRange.index + REFERENCE_NODE.length,
|
||||
length: 0,
|
||||
});
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
this.title$.value = this.referenceInfo.title ?? this.docTitle;
|
||||
}
|
||||
|
||||
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.remove();
|
||||
});
|
||||
|
||||
this.inputElement.focus();
|
||||
this.inputElement.select();
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="overlay-root">
|
||||
<div class="overlay-mask"></div>
|
||||
<div class="popover-container">
|
||||
<input
|
||||
id="alias-title"
|
||||
type="text"
|
||||
placeholder="Add a custom title"
|
||||
.value=${live(this.title$.value)}
|
||||
@input=${this._updateTitle}
|
||||
/>
|
||||
<editor-icon-button
|
||||
aria-label="Reset"
|
||||
class="reset"
|
||||
.iconContainerPadding=${4}
|
||||
.tooltip=${'Reset'}
|
||||
@click=${this._onReset}
|
||||
>
|
||||
${ResetIcon({ width: '16px', height: '16px' })}
|
||||
</editor-icon-button>
|
||||
<editor-toolbar-separator></editor-toolbar-separator>
|
||||
<editor-icon-button
|
||||
aria-label="Save"
|
||||
class="save"
|
||||
.active=${true}
|
||||
@click=${this._onSave}
|
||||
>
|
||||
${DoneIcon({ width: '16px', height: '16px' })}
|
||||
<span class="label">Save</span>
|
||||
</editor-icon-button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
override updated() {
|
||||
const range = this.inlineEditor.toDomRange(this.inlineRange);
|
||||
if (!range) return;
|
||||
|
||||
const visualElement = {
|
||||
getBoundingClientRect: () => range.getBoundingClientRect(),
|
||||
getClientRects: () => range.getClientRects(),
|
||||
};
|
||||
const popover = this.popoverContainer;
|
||||
|
||||
computePosition(visualElement, popover, {
|
||||
middleware: [
|
||||
offset(10),
|
||||
inline(),
|
||||
shift({
|
||||
padding: 6,
|
||||
}),
|
||||
],
|
||||
})
|
||||
.then(({ x, y }) => {
|
||||
popover.style.left = `${x}px`;
|
||||
popover.style.top = `${y}px`;
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor abortController!: AbortController;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor docTitle!: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor inlineEditor!: AffineInlineEditor;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor inlineRange!: InlineRange;
|
||||
|
||||
@query('input#alias-title')
|
||||
accessor inputElement!: HTMLInputElement;
|
||||
|
||||
@query('.overlay-mask')
|
||||
accessor overlayMask!: HTMLDivElement;
|
||||
|
||||
@query('.popover-container')
|
||||
accessor popoverContainer!: HTMLDivElement;
|
||||
|
||||
@property({ type: Object })
|
||||
accessor referenceInfo!: ReferenceInfo;
|
||||
|
||||
@query('editor-icon-button.save')
|
||||
accessor saveButton!: EditorIconButton;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor std!: BlockStdScope;
|
||||
|
||||
accessor title$ = signal<string>('');
|
||||
}
|
||||
|
||||
function track(
|
||||
std: BlockStdScope,
|
||||
event: LinkEventType,
|
||||
props: Partial<TelemetryEvent>
|
||||
) {
|
||||
std.getOptional(TelemetryProvider)?.track(event, {
|
||||
segment: 'doc',
|
||||
page: 'doc editor',
|
||||
module: 'toolbar',
|
||||
category: 'linked doc',
|
||||
type: 'inline view',
|
||||
...props,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ReferenceInfo } from '@blocksuite/affine-model';
|
||||
import type { BlockStdScope } from '@blocksuite/block-std';
|
||||
import type { InlineRange } from '@blocksuite/inline';
|
||||
|
||||
import type { AffineInlineEditor } from '../../../affine-inline-specs';
|
||||
import { ReferencePopup } from './reference-popup';
|
||||
|
||||
export function toggleReferencePopup(
|
||||
std: BlockStdScope,
|
||||
docTitle: string,
|
||||
referenceInfo: ReferenceInfo,
|
||||
inlineEditor: AffineInlineEditor,
|
||||
inlineRange: InlineRange,
|
||||
abortController: AbortController
|
||||
): ReferencePopup {
|
||||
const popup = new ReferencePopup();
|
||||
popup.std = std;
|
||||
popup.docTitle = docTitle;
|
||||
popup.referenceInfo = referenceInfo;
|
||||
popup.inlineEditor = inlineEditor;
|
||||
popup.inlineRange = inlineRange;
|
||||
popup.abortController = abortController;
|
||||
|
||||
document.body.append(popup);
|
||||
|
||||
return popup;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ReferenceInfo } from '@blocksuite/affine-model';
|
||||
import type { OpenDocMode } from '@blocksuite/affine-shared/services';
|
||||
import type { EditorHost } from '@blocksuite/block-std';
|
||||
import type { Slot } from '@blocksuite/global/slot';
|
||||
|
||||
export type DocLinkClickedEvent = ReferenceInfo & {
|
||||
// default is active view
|
||||
openMode?: OpenDocMode;
|
||||
event?: MouseEvent;
|
||||
host: EditorHost;
|
||||
};
|
||||
|
||||
export type RefNodeSlots = {
|
||||
docLinkClicked: Slot<DocLinkClickedEvent>;
|
||||
};
|
||||
79
blocksuite/affine/rich-text/src/keymap/basic.ts
Normal file
79
blocksuite/affine/rich-text/src/keymap/basic.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
BlockSelection,
|
||||
type BlockStdScope,
|
||||
TextSelection,
|
||||
type UIEventHandler,
|
||||
} from '@blocksuite/block-std';
|
||||
|
||||
import {
|
||||
focusTextModel,
|
||||
getInlineEditorByModel,
|
||||
selectTextModel,
|
||||
} from '../dom.js';
|
||||
|
||||
export const textCommonKeymap = (
|
||||
std: BlockStdScope
|
||||
): Record<string, UIEventHandler> => {
|
||||
return {
|
||||
ArrowUp: () => {
|
||||
const text = std.selection.find(TextSelection);
|
||||
if (!text) return;
|
||||
const inline = getInlineEditorByModel(std.host, 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.host, 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 }),
|
||||
]);
|
||||
}
|
||||
163
blocksuite/affine/rich-text/src/keymap/bracket.ts
Normal file
163
blocksuite/affine/rich-text/src/keymap/bracket.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { CodeBlockModel } from '@blocksuite/affine-model';
|
||||
import { BRACKET_PAIRS } from '@blocksuite/affine-shared/consts';
|
||||
import { createDefaultDoc, matchModels } from '@blocksuite/affine-shared/utils';
|
||||
import {
|
||||
type BlockStdScope,
|
||||
TextSelection,
|
||||
type UIEventHandler,
|
||||
} from '@blocksuite/block-std';
|
||||
import type { InlineEditor } from '@blocksuite/inline';
|
||||
|
||||
import { getInlineEditorByModel } from '../dom.js';
|
||||
import { insertLinkedNode } from '../linked-node.js';
|
||||
|
||||
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.host,
|
||||
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.host,
|
||||
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.host,
|
||||
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;
|
||||
}
|
||||
36
blocksuite/affine/rich-text/src/keymap/format.ts
Normal file
36
blocksuite/affine/rich-text/src/keymap/format.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
type BlockStdScope,
|
||||
TextSelection,
|
||||
type UIEventHandler,
|
||||
} from '@blocksuite/block-std';
|
||||
|
||||
import { textFormatConfigs } from '../format/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, selection } = std;
|
||||
if (doc.readonly) return;
|
||||
|
||||
const textSelection = selection.find(TextSelection);
|
||||
if (!textSelection) 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>
|
||||
);
|
||||
15
blocksuite/affine/rich-text/src/keymap/index.ts
Normal file
15
blocksuite/affine/rich-text/src/keymap/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { BlockStdScope, UIEventHandler } from '@blocksuite/block-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),
|
||||
...textFormatKeymap(std),
|
||||
...bracketKeymap(std),
|
||||
};
|
||||
};
|
||||
22
blocksuite/affine/rich-text/src/linked-node.ts
Normal file
22
blocksuite/affine/rich-text/src/linked-node.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { REFERENCE_NODE } from '@blocksuite/affine-shared/consts';
|
||||
|
||||
import { type AffineInlineEditor } from './inline/index.js';
|
||||
|
||||
export function insertLinkedNode({
|
||||
inlineEditor,
|
||||
docId,
|
||||
}: {
|
||||
inlineEditor: AffineInlineEditor;
|
||||
docId: string;
|
||||
}) {
|
||||
if (!inlineEditor) return;
|
||||
const inlineRange = inlineEditor.getInlineRange();
|
||||
if (!inlineRange) return;
|
||||
inlineEditor.insertText(inlineRange, REFERENCE_NODE, {
|
||||
reference: { type: 'LinkedPage', pageId: docId },
|
||||
});
|
||||
inlineEditor.setInlineRange({
|
||||
index: inlineRange.index + 1,
|
||||
length: 0,
|
||||
});
|
||||
}
|
||||
42
blocksuite/affine/rich-text/src/markdown/divider.ts
Normal file
42
blocksuite/affine/rich-text/src/markdown/divider.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
DividerBlockModel,
|
||||
ParagraphBlockModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { matchModels } from '@blocksuite/affine-shared/utils';
|
||||
import type { BlockStdScope } from '@blocksuite/block-std';
|
||||
import type { BlockModel } from '@blocksuite/store';
|
||||
|
||||
import { focusTextModel } from '../dom.js';
|
||||
import { beforeConvert } from './utils.js';
|
||||
|
||||
export function toDivider(
|
||||
std: BlockStdScope,
|
||||
model: BlockModel,
|
||||
prefix: string
|
||||
) {
|
||||
const { store: doc } = std;
|
||||
if (
|
||||
matchModels(model, [DividerBlockModel]) ||
|
||||
(matchModels(model, [ParagraphBlockModel]) && model.type === 'quote')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = doc.getParent(model);
|
||||
if (!parent) return;
|
||||
|
||||
const index = parent.children.indexOf(model);
|
||||
beforeConvert(std, model, prefix.length);
|
||||
const blockProps = {
|
||||
children: model.children,
|
||||
};
|
||||
doc.addBlock('affine:divider', blockProps, parent, index);
|
||||
|
||||
const nextBlock = parent.children[index + 1];
|
||||
let id = nextBlock?.id;
|
||||
if (!id) {
|
||||
id = doc.addBlock('affine:paragraph', {}, parent);
|
||||
}
|
||||
focusTextModel(std, id);
|
||||
return id;
|
||||
}
|
||||
1
blocksuite/affine/rich-text/src/markdown/index.ts
Normal file
1
blocksuite/affine/rich-text/src/markdown/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { markdownInput } from './markdown-input.js';
|
||||
54
blocksuite/affine/rich-text/src/markdown/list.ts
Normal file
54
blocksuite/affine/rich-text/src/markdown/list.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
type ListProps,
|
||||
type ListType,
|
||||
ParagraphBlockModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { matchModels, toNumberedList } from '@blocksuite/affine-shared/utils';
|
||||
import type { BlockStdScope } from '@blocksuite/block-std';
|
||||
import type { BlockModel } from '@blocksuite/store';
|
||||
|
||||
import { focusTextModel } from '../dom.js';
|
||||
import { beforeConvert } from './utils.js';
|
||||
|
||||
export function toList(
|
||||
std: BlockStdScope,
|
||||
model: BlockModel,
|
||||
listType: ListType,
|
||||
prefix: string,
|
||||
otherProperties?: Partial<ListProps>
|
||||
) {
|
||||
if (!matchModels(model, [ParagraphBlockModel])) {
|
||||
return;
|
||||
}
|
||||
const { store: doc } = std;
|
||||
const parent = doc.getParent(model);
|
||||
if (!parent) return;
|
||||
|
||||
beforeConvert(std, model, prefix.length);
|
||||
|
||||
if (listType !== 'numbered') {
|
||||
const index = parent.children.indexOf(model);
|
||||
const blockProps = {
|
||||
type: listType,
|
||||
text: model.text?.clone(),
|
||||
children: model.children,
|
||||
...otherProperties,
|
||||
};
|
||||
doc.deleteBlock(model, {
|
||||
deleteChildren: false,
|
||||
});
|
||||
|
||||
const id = doc.addBlock('affine:list', blockProps, parent, index);
|
||||
focusTextModel(std, id);
|
||||
return id;
|
||||
}
|
||||
|
||||
let order = parseInt(prefix.slice(0, -1));
|
||||
if (!Number.isInteger(order)) order = 1;
|
||||
|
||||
const id = toNumberedList(std, model, order);
|
||||
if (!id) return;
|
||||
|
||||
focusTextModel(std, id);
|
||||
return id;
|
||||
}
|
||||
98
blocksuite/affine/rich-text/src/markdown/markdown-input.ts
Normal file
98
blocksuite/affine/rich-text/src/markdown/markdown-input.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
CalloutBlockModel,
|
||||
CodeBlockModel,
|
||||
ParagraphBlockModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
isHorizontalRuleMarkdown,
|
||||
isMarkdownPrefix,
|
||||
matchModels,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import { type BlockStdScope, TextSelection } from '@blocksuite/block-std';
|
||||
|
||||
import { getInlineEditorByModel } from '../dom.js';
|
||||
import { toDivider } from './divider.js';
|
||||
import { toList } from './list.js';
|
||||
import { toParagraph } from './paragraph.js';
|
||||
import { toCode } from './to-code.js';
|
||||
import { getPrefixText } from './utils.js';
|
||||
|
||||
export function markdownInput(
|
||||
std: BlockStdScope,
|
||||
id?: string
|
||||
): string | undefined {
|
||||
if (!id) {
|
||||
const selection = std.selection;
|
||||
const text = selection.find(TextSelection);
|
||||
id = text?.from.blockId;
|
||||
}
|
||||
if (!id) return;
|
||||
const model = std.store.getBlock(id)?.model;
|
||||
if (!model) return;
|
||||
const inline = getInlineEditorByModel(std.host, model);
|
||||
if (!inline) return;
|
||||
const range = inline.getInlineRange();
|
||||
if (!range) return;
|
||||
|
||||
const prefixText = getPrefixText(inline);
|
||||
if (!isMarkdownPrefix(prefixText)) return;
|
||||
|
||||
const isParagraph = matchModels(model, [ParagraphBlockModel]);
|
||||
const isHeading = isParagraph && model.type.startsWith('h');
|
||||
const isParagraphQuoteBlock = isParagraph && model.type === 'quote';
|
||||
const isCodeBlock = matchModels(model, [CodeBlockModel]);
|
||||
if (
|
||||
isHeading ||
|
||||
isParagraphQuoteBlock ||
|
||||
isCodeBlock ||
|
||||
matchModels(model.parent, [CalloutBlockModel])
|
||||
)
|
||||
return;
|
||||
|
||||
const lineInfo = inline.getLine(range.index);
|
||||
if (!lineInfo) return;
|
||||
|
||||
const { lineIndex, rangeIndexRelatedToLine } = lineInfo;
|
||||
if (lineIndex !== 0 || rangeIndexRelatedToLine > prefixText.length) return;
|
||||
|
||||
// try to add code block
|
||||
const codeMatch = prefixText.match(/^```([a-zA-Z0-9]*)$/g);
|
||||
if (codeMatch) {
|
||||
return toCode(std, model, prefixText, codeMatch[0].slice(3));
|
||||
}
|
||||
|
||||
if (isHorizontalRuleMarkdown(prefixText.trim())) {
|
||||
return toDivider(std, model, prefixText);
|
||||
}
|
||||
|
||||
switch (prefixText.trim()) {
|
||||
case '[]':
|
||||
case '[ ]':
|
||||
return toList(std, model, 'todo', prefixText, {
|
||||
checked: false,
|
||||
});
|
||||
case '[x]':
|
||||
return toList(std, model, 'todo', prefixText, {
|
||||
checked: true,
|
||||
});
|
||||
case '-':
|
||||
case '*':
|
||||
return toList(std, model, 'bulleted', prefixText);
|
||||
case '#':
|
||||
return toParagraph(std, model, 'h1', prefixText);
|
||||
case '##':
|
||||
return toParagraph(std, model, 'h2', prefixText);
|
||||
case '###':
|
||||
return toParagraph(std, model, 'h3', prefixText);
|
||||
case '####':
|
||||
return toParagraph(std, model, 'h4', prefixText);
|
||||
case '#####':
|
||||
return toParagraph(std, model, 'h5', prefixText);
|
||||
case '######':
|
||||
return toParagraph(std, model, 'h6', prefixText);
|
||||
case '>':
|
||||
return toParagraph(std, model, 'quote', prefixText);
|
||||
default:
|
||||
return toList(std, model, 'numbered', prefixText);
|
||||
}
|
||||
}
|
||||
49
blocksuite/affine/rich-text/src/markdown/paragraph.ts
Normal file
49
blocksuite/affine/rich-text/src/markdown/paragraph.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
ParagraphBlockModel,
|
||||
type ParagraphType,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { matchModels } from '@blocksuite/affine-shared/utils';
|
||||
import type { BlockStdScope } from '@blocksuite/block-std';
|
||||
import type { BlockModel } from '@blocksuite/store';
|
||||
|
||||
import { focusTextModel } from '../dom.js';
|
||||
import { beforeConvert } from './utils.js';
|
||||
|
||||
export function toParagraph(
|
||||
std: BlockStdScope,
|
||||
model: BlockModel,
|
||||
type: ParagraphType,
|
||||
prefix: string
|
||||
) {
|
||||
const { store: doc } = std;
|
||||
if (!matchModels(model, [ParagraphBlockModel])) {
|
||||
const parent = doc.getParent(model);
|
||||
if (!parent) return;
|
||||
|
||||
const index = parent.children.indexOf(model);
|
||||
|
||||
beforeConvert(std, model, prefix.length);
|
||||
|
||||
const blockProps = {
|
||||
type: type,
|
||||
text: model.text?.clone(),
|
||||
children: model.children,
|
||||
};
|
||||
doc.deleteBlock(model, { deleteChildren: false });
|
||||
const id = doc.addBlock('affine:paragraph', blockProps, parent, index);
|
||||
|
||||
focusTextModel(std, id);
|
||||
return id;
|
||||
}
|
||||
|
||||
if (matchModels(model, [ParagraphBlockModel]) && model.type !== type) {
|
||||
beforeConvert(std, model, prefix.length);
|
||||
|
||||
doc.updateBlock(model, { type });
|
||||
|
||||
focusTextModel(std, model.id);
|
||||
}
|
||||
|
||||
// If the model is already a paragraph with the same type, do nothing
|
||||
return model.id;
|
||||
}
|
||||
39
blocksuite/affine/rich-text/src/markdown/to-code.ts
Normal file
39
blocksuite/affine/rich-text/src/markdown/to-code.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { ParagraphBlockModel } from '@blocksuite/affine-model';
|
||||
import { matchModels } from '@blocksuite/affine-shared/utils';
|
||||
import type { BlockStdScope } from '@blocksuite/block-std';
|
||||
import type { BlockModel } from '@blocksuite/store';
|
||||
|
||||
import { focusTextModel } from '../dom.js';
|
||||
|
||||
export function toCode(
|
||||
std: BlockStdScope,
|
||||
model: BlockModel,
|
||||
prefixText: string,
|
||||
language: string | null
|
||||
) {
|
||||
if (matchModels(model, [ParagraphBlockModel]) && model.type === 'quote') {
|
||||
return;
|
||||
}
|
||||
|
||||
const doc = model.doc;
|
||||
const parent = doc.getParent(model);
|
||||
if (!parent) {
|
||||
return;
|
||||
}
|
||||
|
||||
doc.captureSync();
|
||||
const index = parent.children.indexOf(model);
|
||||
|
||||
const codeId = doc.addBlock('affine:code', { language }, parent, index);
|
||||
|
||||
if (model.text && model.text.length > prefixText.length) {
|
||||
const text = model.text.clone();
|
||||
doc.addBlock('affine:paragraph', { text }, parent, index + 1);
|
||||
text.delete(0, prefixText.length);
|
||||
}
|
||||
doc.deleteBlock(model, { bringChildrenTo: parent });
|
||||
|
||||
focusTextModel(std, codeId);
|
||||
|
||||
return codeId;
|
||||
}
|
||||
39
blocksuite/affine/rich-text/src/markdown/utils.ts
Normal file
39
blocksuite/affine/rich-text/src/markdown/utils.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { BlockStdScope } from '@blocksuite/block-std';
|
||||
import type { InlineEditor } from '@blocksuite/inline';
|
||||
import type { BlockModel } from '@blocksuite/store';
|
||||
|
||||
import { focusTextModel } from '../dom.js';
|
||||
|
||||
export function getPrefixText(inlineEditor: InlineEditor) {
|
||||
const inlineRange = inlineEditor.getInlineRange();
|
||||
if (!inlineRange) return '';
|
||||
const firstLineEnd = inlineEditor.yTextString.search(/\n/);
|
||||
if (firstLineEnd !== -1 && inlineRange.index > firstLineEnd) {
|
||||
return '';
|
||||
}
|
||||
const textPoint = inlineEditor.getTextPoint(inlineRange.index);
|
||||
if (!textPoint) return '';
|
||||
const [leafStart, offsetStart] = textPoint;
|
||||
return leafStart.textContent
|
||||
? leafStart.textContent.slice(0, offsetStart)
|
||||
: '';
|
||||
}
|
||||
|
||||
export function beforeConvert(
|
||||
std: BlockStdScope,
|
||||
model: BlockModel,
|
||||
index: number
|
||||
) {
|
||||
const { text } = model;
|
||||
if (!text) return;
|
||||
// Add a space after the text, then stop capturing
|
||||
// So when the user undo, the prefix will be restored with a `space`
|
||||
// Ex. (| is the cursor position)
|
||||
// *| <- user input
|
||||
// <space> -> bullet list
|
||||
// *<space>| -> undo
|
||||
text.insert(' ', index);
|
||||
focusTextModel(std, model.id, index + 1);
|
||||
std.store.captureSync();
|
||||
text.delete(0, index + 1);
|
||||
}
|
||||
452
blocksuite/affine/rich-text/src/rich-text.ts
Normal file
452
blocksuite/affine/rich-text/src/rich-text.ts
Normal file
@@ -0,0 +1,452 @@
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import { ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import {
|
||||
type AttributeRenderer,
|
||||
type DeltaInsert,
|
||||
InlineEditor,
|
||||
type InlineRange,
|
||||
type InlineRangeProvider,
|
||||
type VLine,
|
||||
} from '@blocksuite/inline';
|
||||
import { Text } from '@blocksuite/store';
|
||||
import { effect } 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 type { InlineMarkdownMatch } from './extension/type.js';
|
||||
import { onVBeforeinput, onVCompositionEnd } from './hooks.js';
|
||||
import type { AffineInlineEditor } from './inline/index.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 _inlineEditor: 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;
|
||||
}
|
||||
|
||||
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 = new InlineEditor<AffineTextAttributes>(this._yText, {
|
||||
isEmbed: delta => this.embedChecker(delta),
|
||||
hooks: {
|
||||
beforeinput: onVBeforeinput,
|
||||
compositionEnd: onVCompositionEnd,
|
||||
},
|
||||
inlineRangeProvider: this.inlineRangeProvider,
|
||||
vLineRenderer: this.vLineRenderer,
|
||||
});
|
||||
if (this.attributesSchema) {
|
||||
this._inlineEditor.setAttributeSchema(this.attributesSchema);
|
||||
}
|
||||
if (this.attributeRenderer) {
|
||||
this._inlineEditor.setAttributeRenderer(this.attributeRenderer);
|
||||
}
|
||||
const inlineEditor = this._inlineEditor;
|
||||
|
||||
const markdownMatches = this.markdownMatches;
|
||||
if (markdownMatches) {
|
||||
inlineEditor.disposables.addFromEvent(
|
||||
this.inlineEventSource ?? this.inlineEditorContainer,
|
||||
'keydown',
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key !== ' ' && e.key !== 'Enter') return;
|
||||
|
||||
const inlineRange = inlineEditor.getInlineRange();
|
||||
if (!inlineRange || inlineRange.length > 0) return;
|
||||
|
||||
const nearestLineBreakIndex = inlineEditor.yTextString
|
||||
.slice(0, inlineRange.index)
|
||||
.lastIndexOf('\n');
|
||||
const prefixText = inlineEditor.yTextString.slice(
|
||||
nearestLineBreakIndex + 1,
|
||||
inlineRange.index
|
||||
);
|
||||
|
||||
for (const match of markdownMatches) {
|
||||
const { pattern, action } = match;
|
||||
if (prefixText.match(pattern)) {
|
||||
action({
|
||||
inlineEditor,
|
||||
prefixText,
|
||||
inlineRange,
|
||||
pattern,
|
||||
undoManager: this.undoManager,
|
||||
});
|
||||
e.preventDefault();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// 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 = 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) => {
|
||||
// eslint-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;
|
||||
}
|
||||
Reference in New Issue
Block a user