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:
Saul-Mirone
2025-03-07 04:08:47 +00:00
parent 8da12025af
commit fe5f0f62ec
176 changed files with 503 additions and 180 deletions

View File

@@ -50,7 +50,6 @@
"./icon-button": "./src/icon-button/index.ts",
"./toolbar": "./src/toolbar/index.ts",
"./toast": "./src/toast/index.ts",
"./rich-text": "./src/rich-text/index.ts",
"./smooth-corner": "./src/smooth-corner/index.ts",
"./caption": "./src/caption/index.ts",
"./context-menu": "./src/context-menu/index.ts",
@@ -62,7 +61,6 @@
"./notification": "./src/notification/index.ts",
"./block-zero-width": "./src/block-zero-width/index.ts",
"./block-selection": "./src/block-selection/index.ts",
"./doc-title": "./src/doc-title/index.ts",
"./embed-card-modal": "./src/embed-card-modal/index.ts",
"./link-preview": "./src/link-preview/index.ts",
"./linked-doc-title": "./src/linked-doc-title/index.ts",

View File

@@ -1,10 +1,8 @@
import { stopPropagation } from '@blocksuite/affine-shared/utils';
import type { BlockComponent } from '@blocksuite/block-std';
import { type BlockComponent, TextSelection } from '@blocksuite/block-std';
import { css, html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import { focusTextModel } from '../rich-text';
export class BlockZeroWidth extends LitElement {
static override styles = css`
.block-zero-width {
@@ -25,7 +23,13 @@ export class BlockZeroWidth extends LitElement {
const [paragraphId] = this.block.doc.addSiblingBlocks(this.block.model, [
{ flavour: 'affine:paragraph' },
]);
focusTextModel(this.block.host.std, paragraphId);
const std = this.block.std;
std.selection.setGroup('note', [
std.selection.create(TextSelection, {
from: { blockId: paragraphId, index: 0, length: 0 },
to: null,
}),
]);
}
};

View File

@@ -6,6 +6,7 @@ import {
modelContext,
ShadowlessElement,
stdContext,
TextSelection,
} from '@blocksuite/block-std';
import { WithDisposable } from '@blocksuite/global/lit';
import type { BlockModel, Store } from '@blocksuite/store';
@@ -14,8 +15,6 @@ import { consume } from '@lit/context';
import { css, html, nothing } from 'lit';
import { query, state } from 'lit/decorators.js';
import { focusTextModel } from '../rich-text/index.js';
export interface BlockCaptionProps {
caption: string | null | undefined;
}
@@ -92,7 +91,13 @@ export class BlockCaptionEditor<
index + 1
);
focusTextModel(this.std, id);
const std = this.std;
std.selection.setGroup('note', [
std.selection.create(TextSelection, {
from: { blockId: id, index: 0, length: 0 },
to: null,
}),
]);
}
}

View File

@@ -1,234 +0,0 @@
import {
CodeBlockModel,
ListBlockModel,
NoteBlockModel,
NoteDisplayMode,
ParagraphBlockModel,
type RootBlockModel,
} from '@blocksuite/affine-model';
import { matchModels } from '@blocksuite/affine-shared/utils';
import { ShadowlessElement } from '@blocksuite/block-std';
import { WithDisposable } from '@blocksuite/global/lit';
import type { Store } from '@blocksuite/store';
import { effect } from '@preact/signals-core';
import { css, html } from 'lit';
import { property, query, state } from 'lit/decorators.js';
import { focusTextModel, type RichText } from '../rich-text';
const DOC_BLOCK_CHILD_PADDING = 24;
export class DocTitle extends WithDisposable(ShadowlessElement) {
static override styles = css`
.doc-title-container {
box-sizing: border-box;
font-family: var(--affine-font-family);
font-size: var(--affine-font-base);
line-height: var(--affine-line-height);
color: var(--affine-text-primary-color);
font-size: 40px;
line-height: 50px;
font-weight: 700;
outline: none;
resize: none;
border: 0;
width: 100%;
max-width: var(--affine-editor-width);
margin-left: auto;
margin-right: auto;
padding: 38px 0;
padding-left: var(
--affine-editor-side-padding,
${DOC_BLOCK_CHILD_PADDING}px
);
padding-right: var(
--affine-editor-side-padding,
${DOC_BLOCK_CHILD_PADDING}px
);
}
/* Extra small devices (phones, 640px and down) */
@container viewport (width <= 640px) {
.doc-title-container {
padding-left: ${DOC_BLOCK_CHILD_PADDING}px;
padding-right: ${DOC_BLOCK_CHILD_PADDING}px;
}
}
.doc-title-container-empty::before {
content: 'Title';
color: var(--affine-placeholder-color);
position: absolute;
opacity: 0.5;
pointer-events: none;
}
.doc-title-container:disabled {
background-color: transparent;
}
`;
private _getOrCreateFirstPageVisibleNote() {
const note = this._rootModel.children.find(
(child): child is NoteBlockModel =>
matchModels(child, [NoteBlockModel]) &&
child.displayMode !== NoteDisplayMode.EdgelessOnly
);
if (note) return note;
const noteId = this.doc.addBlock('affine:note', {}, this._rootModel, 0);
return this.doc.getBlock(noteId)?.model as NoteBlockModel;
}
private readonly _onTitleKeyDown = (event: KeyboardEvent) => {
if (event.isComposing || this.doc.readonly) return;
if (event.key === 'Enter') {
event.preventDefault();
event.stopPropagation();
const inlineRange = this.inlineEditor?.getInlineRange();
if (inlineRange) {
const rightText = this._rootModel.title.split(inlineRange.index);
const newFirstParagraphId = this.doc.addBlock(
'affine:paragraph',
{ text: rightText },
this._getOrCreateFirstPageVisibleNote(),
0
);
if (this._std) focusTextModel(this._std, newFirstParagraphId);
}
} else if (event.key === 'ArrowDown') {
event.preventDefault();
event.stopPropagation();
const note = this._getOrCreateFirstPageVisibleNote();
const firstText = note?.children.find(block =>
matchModels(block, [
ParagraphBlockModel,
ListBlockModel,
CodeBlockModel,
])
);
if (firstText) {
if (this._std) focusTextModel(this._std, firstText.id);
} else {
const newFirstParagraphId = this.doc.addBlock(
'affine:paragraph',
{},
note,
0
);
if (this._std) focusTextModel(this._std, newFirstParagraphId);
}
} else if (event.key === 'Tab') {
event.preventDefault();
event.stopPropagation();
}
};
private readonly _updateTitleInMeta = () => {
this.doc.workspace.meta.setDocMeta(this.doc.id, {
title: this._rootModel.title.toString(),
});
};
private get _std() {
return this._viewport?.querySelector('editor-host')?.std;
}
private get _rootModel() {
return this.doc.root as RootBlockModel;
}
private get _viewport() {
return (
this.closest<HTMLElement>('.affine-page-viewport') ??
this.closest<HTMLElement>('.affine-edgeless-viewport')
);
}
get inlineEditor() {
return this._richTextElement.inlineEditor;
}
get inlineEditorContainer() {
return this._richTextElement.inlineEditorContainer;
}
override connectedCallback() {
super.connectedCallback();
this._isReadonly = this.doc.readonly;
this._disposables.add(
effect(() => {
if (this._isReadonly !== this.doc.readonly) {
this._isReadonly = this.doc.readonly;
this.requestUpdate();
}
})
);
this._disposables.addFromEvent(this, 'keydown', this._onTitleKeyDown);
// Workaround for inline editor skips composition event
this._disposables.addFromEvent(
this,
'compositionstart',
() => (this._isComposing = true)
);
this._disposables.addFromEvent(
this,
'compositionend',
() => (this._isComposing = false)
);
const updateMetaTitle = () => {
this._updateTitleInMeta();
this.requestUpdate();
};
this._rootModel.title.yText.observe(updateMetaTitle);
this._disposables.add(() => {
this._rootModel.title.yText.unobserve(updateMetaTitle);
});
}
override render() {
const isEmpty = !this._rootModel.title.length && !this._isComposing;
return html`
<div
class="doc-title-container ${isEmpty
? 'doc-title-container-empty'
: ''}"
data-block-is-title="true"
>
<rich-text
.yText=${this._rootModel.title.yText}
.undoManager=${this.doc.history}
.verticalScrollContainerGetter=${() => this._viewport}
.readonly=${this.doc.readonly}
.enableFormat=${false}
.wrapText=${this.wrapText}
></rich-text>
</div>
`;
}
@state()
private accessor _isComposing = false;
@state()
private accessor _isReadonly = false;
@query('rich-text')
private accessor _richTextElement!: RichText;
@property({ attribute: false })
accessor doc!: Store;
@property({ attribute: false })
accessor wrapText = true;
}

View File

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

View File

@@ -1,3 +0,0 @@
export { DocTitle } from './doc-title';
export { effects } from './effects';
export { getDocTitleByEditorHost } from './utils';

View File

@@ -1,11 +0,0 @@
import type { EditorHost } from '@blocksuite/block-std';
import type { DocTitle } from './doc-title';
export function getDocTitleByEditorHost(
editorHost: EditorHost
): DocTitle | null {
const docViewport = editorHost.closest('.affine-page-viewport');
if (!docViewport) return null;
return docViewport.querySelector('doc-title');
}

View File

@@ -1,45 +0,0 @@
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();

View File

@@ -1,137 +0,0 @@
import type { TemplateResult } from 'lit';
import {
BulletedListIcon,
CheckBoxIcon,
CodeBlockIcon,
DividerIcon,
Heading1Icon,
Heading2Icon,
Heading3Icon,
Heading4Icon,
Heading5Icon,
Heading6Icon,
NumberedListIcon,
QuoteIcon,
TextIcon,
} from '../icons';
/**
* 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,
},
];

View File

@@ -1,177 +0,0 @@
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);
}

View File

@@ -1,46 +0,0 @@
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;
}
}

View File

@@ -1,5 +0,0 @@
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';

View File

@@ -1,104 +0,0 @@
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,
};
}

View File

@@ -1,47 +0,0 @@
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);
},
};
}

View File

@@ -1,27 +0,0 @@
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,
};
}

View File

@@ -1,18 +0,0 @@
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);
},
};

View File

@@ -1,38 +0,0 @@
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>;
};

View File

@@ -1,138 +0,0 @@
import { type EditorHost, TextSelection } from '@blocksuite/block-std';
import type { TemplateResult } from 'lit';
import {
BoldIcon,
CodeIcon,
ItalicIcon,
LinkIcon,
StrikethroughIcon,
UnderlineIcon,
} from '../../icons/index.js';
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)
);
},
},
];

View File

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

View File

@@ -1,81 +0,0 @@
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();
};

View File

@@ -1,72 +0,0 @@
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();
};

View File

@@ -1,50 +0,0 @@
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();
};

View File

@@ -1,88 +0,0 @@
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();
};

View File

@@ -1,28 +0,0 @@
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';

View File

@@ -1,55 +0,0 @@
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();
};

View File

@@ -1,141 +0,0 @@
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();
};

View File

@@ -1,258 +0,0 @@
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,
});
}

View File

@@ -1,116 +0,0 @@
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);
};

View File

@@ -1,21 +0,0 @@
export * from './all-extensions';
export { type TextConversionConfig, textConversionConfigs } from './conversion';
export {
asyncGetRichText,
asyncSetInlineRange,
cleanSpecifiedTail,
focusTextModel,
getInlineEditorByModel,
getRichTextByModel,
getTextContentFromInlineRange,
onModelTextUpdated,
selectTextModel,
} from './dom';
export * from './effects';
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';

View File

@@ -1,17 +0,0 @@
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();

View File

@@ -1,235 +0,0 @@
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,
];

View File

@@ -1,219 +0,0 @@
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,
];

View File

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

View File

@@ -1,199 +0,0 @@
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,
];

View File

@@ -1,192 +0,0 @@
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,
];

View File

@@ -1,300 +0,0 @@
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,
];

View File

@@ -1,78 +0,0 @@
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,
];

View File

@@ -1,9 +0,0 @@
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';

View File

@@ -1,236 +0,0 @@
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,
}),
];

View File

@@ -1,583 +0,0 @@
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,
];

View File

@@ -1,69 +0,0 @@
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,
};
}

View File

@@ -1,120 +0,0 @@
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))
);
},
};
}

View File

@@ -1,204 +0,0 @@
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 { HoverController } from '../../../../../hover/controller';
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;
}

View File

@@ -1,95 +0,0 @@
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;
}

View File

@@ -1,221 +0,0 @@
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 {
DarkLoadingIcon,
getAttachmentFileIcon,
LightLoadingIcon,
WebIcon16,
} from '../../../../../icons';
import { PeekViewProvider } from '../../../../../peek/service';
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;
}

View File

@@ -1,5 +0,0 @@
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';

View File

@@ -1,197 +0,0 @@
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;
}

View File

@@ -1,54 +0,0 @@
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,
};
}

View File

@@ -1,265 +0,0 @@
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';
import { createLitPortal } from '../../../../../portal/helper.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;
}

View File

@@ -1,186 +0,0 @@
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 { whenHover } from '../../../../../hover/index';
import { RefNodeSlotsProvider } from '../../../../extension/index';
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;
}

View File

@@ -1,351 +0,0 @@
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 { toast } from '../../../../../../toast';
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;

View File

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

View File

@@ -1,305 +0,0 @@
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 { EditorIconButton } from '../../../../../../toolbar/index';
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;
}

View File

@@ -1,158 +0,0 @@
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}
`;

View File

@@ -1,24 +0,0 @@
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;
}

View File

@@ -1,241 +0,0 @@
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 { notifyLinkedDocSwitchedToEmbed } from '../../../../../../notification';
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;

View File

@@ -1,4 +0,0 @@
export * from './reference-config';
export { AffineReference } from './reference-node';
export { toggleReferencePopup } from './reference-popup/toggle-reference-popup';
export type { DocLinkClickedEvent, RefNodeSlots } from './types';

View File

@@ -1,65 +0,0 @@
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;
}
}

View File

@@ -1,308 +0,0 @@
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 { whenHover } from '../../../../../hover/index';
import { Peekable } from '../../../../../peek/index';
import { RefNodeSlotsProvider } from '../../../../extension/index';
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;
}

View File

@@ -1,286 +0,0 @@
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 { EditorIconButton } from '../../../../../../toolbar/index';
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,
});
}

View File

@@ -1,27 +0,0 @@
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;
}

View File

@@ -1,15 +0,0 @@
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>;
};

View File

@@ -1,79 +0,0 @@
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 }),
]);
}

View File

@@ -1,163 +0,0 @@
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;
}

View File

@@ -1,36 +0,0 @@
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>
);

View File

@@ -1,15 +0,0 @@
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),
};
};

View File

@@ -1,22 +0,0 @@
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,
});
}

View File

@@ -1,42 +0,0 @@
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;
}

View File

@@ -1 +0,0 @@
export { markdownInput } from './markdown-input.js';

View File

@@ -1,54 +0,0 @@
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;
}

View File

@@ -1,98 +0,0 @@
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);
}
}

View File

@@ -1,49 +0,0 @@
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;
}

View File

@@ -1,39 +0,0 @@
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;
}

View File

@@ -1,39 +0,0 @@
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);
}

View File

@@ -1,452 +0,0 @@
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;
}