chore: merge blocksuite source code (#9213)

This commit is contained in:
Mirone
2024-12-20 15:38:06 +08:00
committed by GitHub
parent 2c9ef916f4
commit 30200ff86d
2031 changed files with 238888 additions and 229 deletions
@@ -0,0 +1,41 @@
import type { ExtensionType } from '@blocksuite/block-std';
import { InlineManagerExtension } from './extension/index.js';
import {
BackgroundInlineSpecExtension,
BoldInlineSpecExtension,
CodeInlineSpecExtension,
ColorInlineSpecExtension,
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,
],
});
export const RichTextExtensions: ExtensionType[] = [
InlineSpecExtensions,
MarkdownExtensions,
LatexEditorInlineManagerExtension,
DefaultInlineManagerExtension,
].flat();
@@ -0,0 +1,88 @@
import {
asyncGetBlockComponent,
matchFlavours,
} from '@blocksuite/affine-shared/utils';
import type { BlockStdScope, EditorHost } from '@blocksuite/block-std';
import type { InlineRange } from '@blocksuite/inline';
import type { BlockModel } from '@blocksuite/store';
import type { RichText } from './rich-text.js';
/**
* In most cases, you not need RichText, you can use {@link getInlineEditorByModel} instead.
*/
export function getRichTextByModel(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.doc.getBlock(model)?.model
: model;
// @ts-expect-error TODO: migrate database model to `@blocksuite/affine-model`
if (!blockModel || matchFlavours(blockModel, ['affine:database'])) {
// 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('text', {
from: { blockId: id, index, length },
to: null,
}),
]);
}
@@ -0,0 +1,76 @@
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import type { deleteTextCommand } from './format/delete-text.js';
import type { formatBlockCommand } from './format/format-block.js';
import type { formatNativeCommand } from './format/format-native.js';
import type { formatTextCommand } from './format/format-text.js';
import type { insertInlineLatex } from './format/insert-inline-latex.js';
import type {
getTextStyle,
isTextStyleActive,
toggleBold,
toggleCode,
toggleItalic,
toggleLink,
toggleStrike,
toggleTextStyleCommand,
toggleUnderline,
} from './format/text-style.js';
import { AffineLink, AffineReference } from './inline/index.js';
import { AffineText } from './inline/presets/nodes/affine-text.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 { ReferenceAliasPopup } from './inline/presets/nodes/reference-node/reference-alias-popup.js';
import { ReferencePopup } from './inline/presets/nodes/reference-node/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('reference-alias-popup', ReferenceAliasPopup);
customElements.define('affine-reference', AffineReference);
}
declare global {
interface HTMLElementTagNameMap {
'affine-latex-node': AffineLatexNode;
'affine-reference': AffineReference;
'affine-link': AffineLink;
'affine-text': AffineText;
'rich-text': RichText;
'reference-popup': ReferencePopup;
'reference-alias-popup': ReferenceAliasPopup;
'latex-editor-unit': LatexEditorUnit;
'latex-editor-menu': LatexEditorMenu;
'link-popup': LinkPopup;
}
namespace BlockSuite {
interface CommandContext {
textStyle?: AffineTextAttributes;
}
interface Commands {
deleteText: typeof deleteTextCommand;
formatBlock: typeof formatBlockCommand;
formatNative: typeof formatNativeCommand;
formatText: typeof formatTextCommand;
toggleBold: typeof toggleBold;
toggleItalic: typeof toggleItalic;
toggleUnderline: typeof toggleUnderline;
toggleStrike: typeof toggleStrike;
toggleCode: typeof toggleCode;
toggleLink: typeof toggleLink;
toggleTextStyle: typeof toggleTextStyleCommand;
getTextStyle: typeof getTextStyle;
isTextStyleActive: typeof isTextStyleActive;
insertInlineLatex: typeof insertInlineLatex;
}
}
}
@@ -0,0 +1,5 @@
export * from './inline-manager.js';
export * from './inline-spec.js';
export * from './markdown-matcher.js';
export * from './ref-node-slots.js';
export * from './type.js';
@@ -0,0 +1,131 @@
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import {
type BlockStdScope,
type ExtensionType,
StdIdentifier,
} from '@blocksuite/block-std';
import {
createIdentifier,
type ServiceIdentifier,
} from '@blocksuite/global/di';
import {
type AttributeRenderer,
baseTextAttributes,
type DeltaInsert,
getDefaultAttributeRenderer,
KEYBOARD_ALLOW_DEFAULT,
type KeyboardBindingContext,
} from '@blocksuite/inline';
import type { Y } 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;
};
markdownShortcutHandler = (
context: KeyboardBindingContext<AffineTextAttributes>,
undoManager: Y.UndoManager
) => {
const { inlineEditor, prefixText, inlineRange } = context;
for (const match of this.markdownMatches) {
const matchedText = prefixText.match(match.pattern);
if (matchedText) {
return match.action({
inlineEditor,
prefixText,
inlineRange,
pattern: match.pattern,
undoManager,
});
}
}
return KEYBOARD_ALLOW_DEFAULT;
};
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,
};
}
@@ -0,0 +1,47 @@
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import type { ExtensionType } from '@blocksuite/block-std';
import {
createIdentifier,
type ServiceIdentifier,
type ServiceProvider,
} from '@blocksuite/global/di';
import type { InlineSpecs } from './type.js';
export const InlineSpecIdentifier =
createIdentifier<InlineSpecs<AffineTextAttributes>>('AffineInlineSpec');
export function InlineSpecExtension(
name: string,
getSpec: (provider: ServiceProvider) => InlineSpecs<AffineTextAttributes>
): ExtensionType & {
identifier: ServiceIdentifier<InlineSpecs<AffineTextAttributes>>;
};
export function InlineSpecExtension(
spec: InlineSpecs<AffineTextAttributes>
): ExtensionType & {
identifier: ServiceIdentifier<InlineSpecs<AffineTextAttributes>>;
};
export function InlineSpecExtension(
nameOrSpec: string | InlineSpecs<AffineTextAttributes>,
getSpec?: (provider: ServiceProvider) => InlineSpecs<AffineTextAttributes>
): ExtensionType & {
identifier: ServiceIdentifier<InlineSpecs<AffineTextAttributes>>;
} {
if (typeof nameOrSpec === 'string') {
const identifier = InlineSpecIdentifier(nameOrSpec);
return {
identifier,
setup: di => {
di.addImpl(identifier, provider => getSpec!(provider));
},
};
}
const identifier = InlineSpecIdentifier(nameOrSpec.name);
return {
identifier,
setup: di => {
di.addImpl(identifier, nameOrSpec);
},
};
}
@@ -0,0 +1,27 @@
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import type { ExtensionType } from '@blocksuite/block-std';
import {
createIdentifier,
type ServiceIdentifier,
} from '@blocksuite/global/di';
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,
};
}
@@ -0,0 +1,20 @@
import type { ExtensionType } from '@blocksuite/block-std';
import { createIdentifier } from '@blocksuite/global/di';
import { Slot } from '@blocksuite/global/utils';
import type { RefNodeSlots } from '../inline/index.js';
export const RefNodeSlotsProvider =
createIdentifier<RefNodeSlots>('AffineRefNodeSlots');
export function RefNodeSlotsExtension(
slots: RefNodeSlots = {
docLinkClicked: new Slot(),
}
): ExtensionType {
return {
setup: di => {
di.addImpl(RefNodeSlotsProvider, () => slots);
},
};
}
@@ -0,0 +1,39 @@
import type {
AttributeRenderer,
BaseTextAttributes,
DeltaInsert,
InlineEditor,
InlineRange,
KeyboardBindingHandler,
} from '@blocksuite/inline';
import type { Y } from '@blocksuite/store';
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;
}) => ReturnType<KeyboardBindingHandler>;
export type InlineMarkdownMatch<
AffineTextAttributes extends BaseTextAttributes = BaseTextAttributes,
> = {
name: string;
pattern: RegExp;
action: InlineMarkdownMatchAction<AffineTextAttributes>;
};
@@ -0,0 +1,119 @@
import type { EditorHost } from '@blocksuite/block-std';
import type { TemplateResult } from 'lit';
import {
BoldIcon,
CodeIcon,
ItalicIcon,
LinkIcon,
StrikethroughIcon,
UnderlineIcon,
} from '../../icons/index.js';
export interface TextFormatConfig {
id: string;
name: string;
icon: TemplateResult<1>;
hotkey?: string;
activeWhen: (host: EditorHost) => boolean;
action: (host: EditorHost) => void;
}
export const textFormatConfigs: TextFormatConfig[] = [
{
id: 'bold',
name: 'Bold',
icon: BoldIcon,
hotkey: 'Mod-b',
activeWhen: host => {
const [result] = host.std.command
.chain()
.isTextStyleActive({ key: 'bold' })
.run();
return result;
},
action: host => {
host.std.command.chain().toggleBold().run();
},
},
{
id: 'italic',
name: 'Italic',
icon: ItalicIcon,
hotkey: 'Mod-i',
activeWhen: host => {
const [result] = host.std.command
.chain()
.isTextStyleActive({ key: 'italic' })
.run();
return result;
},
action: host => {
host.std.command.chain().toggleItalic().run();
},
},
{
id: 'underline',
name: 'Underline',
icon: UnderlineIcon,
hotkey: 'Mod-u',
activeWhen: host => {
const [result] = host.std.command
.chain()
.isTextStyleActive({ key: 'underline' })
.run();
return result;
},
action: host => {
host.std.command.chain().toggleUnderline().run();
},
},
{
id: 'strike',
name: 'Strikethrough',
icon: StrikethroughIcon,
hotkey: 'Mod-shift-s',
activeWhen: host => {
const [result] = host.std.command
.chain()
.isTextStyleActive({ key: 'strike' })
.run();
return result;
},
action: host => {
host.std.command.chain().toggleStrike().run();
},
},
{
id: 'code',
name: 'Code',
icon: CodeIcon,
hotkey: 'Mod-e',
activeWhen: host => {
const [result] = host.std.command
.chain()
.isTextStyleActive({ key: 'code' })
.run();
return result;
},
action: host => {
host.std.command.chain().toggleCode().run();
},
},
{
id: 'link',
name: 'Link',
icon: LinkIcon,
hotkey: 'Mod-k',
activeWhen: host => {
const [result] = host.std.command
.chain()
.isTextStyleActive({ key: 'link' })
.run();
return result;
},
action: host => {
host.std.command.chain().toggleLink().run();
},
},
];
@@ -0,0 +1,14 @@
// corresponding to `formatText` command
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'];
@@ -0,0 +1,83 @@
import { matchFlavours } from '@blocksuite/affine-shared/utils';
import type { Command, TextSelection } from '@blocksuite/block-std';
import type { Text } from '@blocksuite/store';
export const deleteTextCommand: Command<
'currentTextSelection',
never,
{
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 (matchFlavours(fromElement.model, ['affine:page'])) {
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('text', {
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.doc.deleteBlock(el.model, {
bringChildrenTo:
el.model.id === toElement.model.id ? fromElement.model : undefined,
});
});
ctx.std.selection.setGroup('note', [
ctx.std.selection.create('text', {
from: {
blockId: from.blockId,
index: from.index,
length: 0,
},
to: null,
}),
]);
next();
};
@@ -0,0 +1,71 @@
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import type { BlockSelection, Command } from '@blocksuite/block-std';
import { assertExists } from '@blocksuite/global/utils';
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',
never,
{
blockSelections?: BlockSelection[];
styles: AffineTextAttributes;
mode?: 'replace' | 'merge';
}
> = (ctx, next) => {
const blockSelections = ctx.blockSelections ?? ctx.currentBlockSelections;
assertExists(
blockSelections,
'`blockSelections` is required, you need to pass it in args or use `getBlockSelections` command before adding this command to the pipeline.'
);
if (blockSelections.length === 0) return;
const styles = ctx.styles;
const mode = ctx.mode ?? 'merge';
const success = ctx.std.command
.chain()
.getSelectedBlocks({
blockSelections,
filter: el =>
FORMAT_BLOCK_SUPPORT_FLAVOURS.includes(
el.model.flavour as BlockSuite.Flavour
),
types: ['block'],
})
.inline((ctx, next) => {
const { selectedBlocks } = ctx;
assertExists(selectedBlocks);
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();
};
@@ -0,0 +1,56 @@
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<
never,
never,
{
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 as BlockSuite.Flavour
);
}
return false;
})
.map(el => el.inlineEditor);
selectedInlineEditors.forEach(inlineEditor => {
const inlineRange = inlineEditor.getInlineRange();
if (!inlineRange) return;
inlineEditor.formatText(inlineRange, styles, {
mode,
});
});
next();
};
@@ -0,0 +1,93 @@
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',
never,
{
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()
.getSelectedBlocks({
textSelection,
filter: el =>
FORMAT_TEXT_SUPPORT_FLAVOURS.includes(
el.model.flavour as BlockSuite.Flavour
),
types: ['text'],
})
.inline((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();
};
@@ -0,0 +1,49 @@
import { getTextSelectionCommand } from '@blocksuite/affine-shared/commands';
import type { BlockCommands } from '@blocksuite/block-std';
import { deleteTextCommand } from './delete-text.js';
export type { TextFormatConfig } from './config.js';
export { textFormatConfigs } from './config.js';
import { formatBlockCommand } from './format-block.js';
export {
FORMAT_BLOCK_SUPPORT_FLAVOURS,
FORMAT_NATIVE_SUPPORT_FLAVOURS,
FORMAT_TEXT_SUPPORT_FLAVOURS,
} from './consts.js';
import { formatNativeCommand } from './format-native.js';
import { formatTextCommand } from './format-text.js';
import { insertInlineLatex } from './insert-inline-latex.js';
import {
getTextStyle,
isTextStyleActive,
toggleBold,
toggleCode,
toggleItalic,
toggleLink,
toggleStrike,
toggleTextStyleCommand,
toggleUnderline,
} from './text-style.js';
export {
clearMarksOnDiscontinuousInput,
insertContent,
isFormatSupported,
} from './utils.js';
export const textCommands: BlockCommands = {
deleteText: deleteTextCommand,
formatBlock: formatBlockCommand,
formatNative: formatNativeCommand,
formatText: formatTextCommand,
toggleBold: toggleBold,
toggleItalic: toggleItalic,
toggleUnderline: toggleUnderline,
toggleStrike: toggleStrike,
toggleCode: toggleCode,
toggleLink: toggleLink,
toggleTextStyle: toggleTextStyleCommand,
isTextStyleActive: isTextStyleActive,
getTextStyle: getTextStyle,
getTextSelection: getTextSelectionCommand,
insertInlineLatex: insertInlineLatex,
};
@@ -0,0 +1,58 @@
import type { Command, TextSelection } from '@blocksuite/block-std';
export const insertInlineLatex: Command<
'currentTextSelection',
never,
{
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();
};
@@ -0,0 +1,132 @@
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 { getCombinedTextStyle } from './utils.js';
export const toggleTextStyleCommand: Command<
never,
never,
{
key: Extract<
keyof AffineTextAttributes,
'bold' | 'italic' | 'underline' | 'strike' | 'code'
>;
}
> = (ctx, next) => {
const { std, key } = ctx;
const [active] = std.command.chain().isTextStyleActive({ key }).run();
const payload: {
styles: AffineTextAttributes;
mode?: 'replace' | 'merge';
} = {
styles: {
[key]: active ? null : true,
},
};
const [result] = std.command
.chain()
.try(chain => [
chain.getTextSelection().formatText(payload),
chain.getBlockSelections().formatBlock(payload),
chain.formatNative(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.exec('toggleTextStyle', { key });
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(
inlineEditor,
'create',
targetInlineRange,
abortController
);
abortController.signal.addEventListener('abort', () => popup.remove());
return next();
};
export const getTextStyle: Command<never, 'textStyle'> = (ctx, next) => {
const [result, innerCtx] = getCombinedTextStyle(
ctx.std.command.chain()
).run();
if (!result) {
return false;
}
return next({ textStyle: innerCtx.textStyle });
};
export const isTextStyleActive: Command<
never,
never,
{ key: keyof AffineTextAttributes }
> = (ctx, next) => {
const key = ctx.key;
const [result] = getCombinedTextStyle(ctx.std.command.chain())
.inline((ctx, next) => {
const { textStyle } = ctx;
if (textStyle && key in textStyle) {
return next();
}
return false;
})
.run();
if (!result) {
return false;
}
return next();
};
@@ -0,0 +1,247 @@
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import {
BLOCK_ID_ATTR,
type BlockComponent,
type Chain,
type CommandKeyToData,
type EditorHost,
type InitCommandCtx,
} from '@blocksuite/block-std';
import { assertExists } from '@blocksuite/global/utils';
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<
InlineOut extends BlockSuite.CommandDataName = never,
>(
chain: Chain<InitCommandCtx>,
handler: (
type: 'text' | 'block' | 'native',
inlineEditors: InlineEditor<AffineTextAttributes>[]
) => CommandKeyToData<InlineOut> | boolean | void
) {
return chain.try<InlineOut>(chain => [
// text selection, corresponding to `formatText` command
chain
.getTextSelection()
.getSelectedBlocks({
types: ['text'],
filter: el => FORMAT_TEXT_SUPPORT_FLAVOURS.includes(el.model.flavour),
})
.inline<InlineOut>((ctx, next) => {
const { selectedBlocks } = ctx;
assertExists(selectedBlocks);
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
.getBlockSelections()
.getSelectedBlocks({
types: ['block'],
filter: el => FORMAT_BLOCK_SUPPORT_FLAVOURS.includes(el.model.flavour),
})
.inline<InlineOut>((ctx, next) => {
const { selectedBlocks } = ctx;
assertExists(selectedBlocks);
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.inline<InlineOut>((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<'textStyle'>(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,
});
}
@@ -0,0 +1,116 @@
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { isStrictUrl } from '@blocksuite/affine-shared/utils';
import type {
BeforeinputHookCtx,
CompositionEndHookCtx,
HookContext,
} from '@blocksuite/inline';
const EDGE_IGNORED_ATTRIBUTES = ['code', 'link'] as const;
const GLOBAL_IGNORED_ATTRIBUTES = [] as const;
const autoIdentifyLink = (ctx: HookContext<AffineTextAttributes>) => {
// auto identify link only on pressing space
if (ctx.data !== ' ') {
return;
}
// space is typed at the end of link, remove the link attribute on typed space
if (ctx.attributes?.link) {
if (ctx.inlineRange.index === ctx.inlineEditor.yText.length) {
delete ctx.attributes['link'];
}
return;
}
const lineInfo = ctx.inlineEditor.getLine(ctx.inlineRange.index);
if (!lineInfo) {
return;
}
const { line, lineIndex, rangeIndexRelatedToLine } = lineInfo;
if (lineIndex !== 0) {
return;
}
const verifyData = line.vTextContent
.slice(0, rangeIndexRelatedToLine)
.split(' ');
const verifyStr = verifyData[verifyData.length - 1];
const isUrl = isStrictUrl(verifyStr);
if (!isUrl) {
return;
}
const startIndex = ctx.inlineRange.index - verifyStr.length;
ctx.inlineEditor.formatText(
{
index: startIndex,
length: verifyStr.length,
},
{
link: verifyStr,
}
);
};
function handleExtendedAttributes(
ctx:
| BeforeinputHookCtx<AffineTextAttributes>
| CompositionEndHookCtx<AffineTextAttributes>
) {
const { data, inlineEditor, inlineRange } = ctx;
const deltas = inlineEditor.getDeltasByInlineRange(inlineRange);
// eslint-disable-next-line sonarjs/no-collapsible-if
if (data && data.length > 0 && data !== '\n') {
if (
// cursor is in the between of two deltas
(deltas.length > 1 ||
// cursor is in the end of line or in the middle of a delta
(deltas.length === 1 && inlineRange.index !== 0)) &&
!inlineEditor.isEmbed(deltas[0][0]) // embeds should not be extended
) {
// each new text inserted by inline editor will not contain any attributes,
// but we want to keep the attributes of previous text or current text where the cursor is in
// here are two cases:
// 1. aaa**b|bb**ccc --input 'd'--> aaa**bdbb**ccc, d should extend the bold attribute
// 2. aaa**bbb|**ccc --input 'd'--> aaa**bbbd**ccc, d should extend the bold attribute
const { attributes } = deltas[0][0];
if (
deltas.length !== 1 ||
inlineRange.index === inlineEditor.yText.length
) {
// `EDGE_IGNORED_ATTRIBUTES` is which attributes should be ignored in case 2
EDGE_IGNORED_ATTRIBUTES.forEach(attr => {
delete attributes?.[attr];
});
}
// `GLOBAL_IGNORED_ATTRIBUTES` is which attributes should be ignored in case 1, 2
GLOBAL_IGNORED_ATTRIBUTES.forEach(attr => {
delete attributes?.[attr];
});
ctx.attributes = attributes ?? {};
}
}
return ctx;
}
export const onVBeforeinput = (
ctx: BeforeinputHookCtx<AffineTextAttributes>
) => {
handleExtendedAttributes(ctx);
autoIdentifyLink(ctx);
};
export const onVCompositionEnd = (
ctx: CompositionEndHookCtx<AffineTextAttributes>
) => {
handleExtendedAttributes(ctx);
};
@@ -0,0 +1,27 @@
export * from './all-extensions.js';
export {
asyncGetRichText,
asyncSetInlineRange,
focusTextModel,
getInlineEditorByModel,
getRichTextByModel,
selectTextModel,
} from './dom.js';
export * from './effects.js';
export * from './extension/index.js';
export {
clearMarksOnDiscontinuousInput,
FORMAT_BLOCK_SUPPORT_FLAVOURS,
FORMAT_NATIVE_SUPPORT_FLAVOURS,
FORMAT_TEXT_SUPPORT_FLAVOURS,
insertContent,
isFormatSupported,
textCommands,
type TextFormatConfig,
textFormatConfigs,
} from './format/index.js';
export * from './inline/index.js';
export { textKeymap } from './keymap/index.js';
export { insertLinkedNode } from './linked-node.js';
export { markdownInput } from './markdown/index.js';
export { RichText } from './rich-text.js';
@@ -0,0 +1,3 @@
export * from './presets/affine-inline-specs.js';
export * from './presets/markdown.js';
export * from './presets/nodes/index.js';
@@ -0,0 +1,193 @@
import { ReferenceInfoSchema } from '@blocksuite/affine-model';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { 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 {
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
.delta=${delta}
.selected=${selected}
.config=${configProvider}
></affine-reference>`;
},
embed: true,
};
}
);
export const LinkInlineSpecExtension = InlineSpecExtension({
name: 'link',
schema: z.string().optional().nullable().catch(undefined),
match: delta => {
return !!delta.attributes?.link;
},
renderer: ({ delta }) => {
return html`<affine-link .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 InlineSpecExtensions = [
BoldInlineSpecExtension,
ItalicInlineSpecExtension,
UnderlineInlineSpecExtension,
StrikeInlineSpecExtension,
CodeInlineSpecExtension,
BackgroundInlineSpecExtension,
ColorInlineSpecExtension,
LatexInlineSpecExtension,
ReferenceInlineSpecExtension,
LinkInlineSpecExtension,
LatexEditorUnitSpecExtension,
];
@@ -0,0 +1,608 @@
/* eslint-disable no-useless-escape */
import type { BlockComponent, ExtensionType } from '@blocksuite/block-std';
import {
KEYBOARD_ALLOW_DEFAULT,
KEYBOARD_PREVENT_DEFAULT,
} from '@blocksuite/inline';
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: /(?:\*\*\*)([^\s\*](?:[^*]*?[^\s\*])?)(?:\*\*\*)$/g,
action: ({ inlineEditor, prefixText, inlineRange, pattern, undoManager }) => {
const match = pattern.exec(prefixText);
if (!match) {
return KEYBOARD_ALLOW_DEFAULT;
}
const annotatedText = match[0];
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,
});
return KEYBOARD_PREVENT_DEFAULT;
},
});
export const BoldMarkdown = InlineMarkdownExtension({
name: 'bold',
pattern: /(?:\*\*)([^\s\*](?:[^*]*?[^\s\*])?)(?:\*\*)$/g,
action: ({ inlineEditor, prefixText, inlineRange, pattern, undoManager }) => {
const match = pattern.exec(prefixText);
if (!match) {
return KEYBOARD_ALLOW_DEFAULT;
}
const annotatedText = match[0];
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,
});
return KEYBOARD_PREVENT_DEFAULT;
},
});
export const ItalicExtension = InlineMarkdownExtension({
name: 'italic',
pattern: /(?:\*)([^\s\*](?:[^*]*?[^\s\*])?)(?:\*)$/g,
action: ({ inlineEditor, prefixText, inlineRange, pattern, undoManager }) => {
const match = pattern.exec(prefixText);
if (!match) {
return KEYBOARD_ALLOW_DEFAULT;
}
const annotatedText = match[0];
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,
});
return KEYBOARD_PREVENT_DEFAULT;
},
});
export const StrikethroughExtension = InlineMarkdownExtension({
name: 'strikethrough',
pattern: /(?:~~)([^\s~](?:[^~]*?[^\s~])?)(?:~~)$/g,
action: ({ inlineEditor, prefixText, inlineRange, pattern, undoManager }) => {
const match = pattern.exec(prefixText);
if (!match) {
return KEYBOARD_ALLOW_DEFAULT;
}
const annotatedText = match[0];
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,
});
return KEYBOARD_PREVENT_DEFAULT;
},
});
export const UnderthroughExtension = InlineMarkdownExtension({
name: 'underthrough',
pattern: /(?:~)([^\s~](?:[^~]*?[^\s~])?)(?:~)$/g,
action: ({ inlineEditor, prefixText, inlineRange, pattern, undoManager }) => {
const match = pattern.exec(prefixText);
if (!match) {
return KEYBOARD_ALLOW_DEFAULT;
}
const annotatedText = match[0];
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,
});
return KEYBOARD_PREVENT_DEFAULT;
},
});
export const CodeExtension = InlineMarkdownExtension({
name: 'code',
pattern: /(?:`)([^\s`](?:[^`]*?[^\s`])?)(?:`)$/g,
action: ({ inlineEditor, prefixText, inlineRange, pattern, undoManager }) => {
const match = pattern.exec(prefixText);
if (!match) {
return KEYBOARD_ALLOW_DEFAULT;
}
const annotatedText = match[0];
const startIndex = inlineRange.index - annotatedText.length;
if (prefixText.match(/^([* \n]+)$/g)) {
return KEYBOARD_ALLOW_DEFAULT;
}
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,
});
return KEYBOARD_PREVENT_DEFAULT;
},
});
export const LinkExtension = InlineMarkdownExtension({
name: 'link',
pattern: /(?:\[(.+?)\])(?:\((.+?)\))$/g,
action: ({ inlineEditor, prefixText, inlineRange, pattern, undoManager }) => {
const startIndex = prefixText.search(pattern);
const matchedText = prefixText.match(pattern)?.[0];
const hrefText = prefixText.match(/(?:\[(.*?)\])/g)?.[0];
const hrefLink = prefixText.match(/(?:\((.*?)\))/g)?.[0];
if (startIndex === -1 || !matchedText || !hrefText || !hrefLink) {
return KEYBOARD_ALLOW_DEFAULT;
}
const start = inlineRange.index - matchedText.length;
inlineEditor.insertText(
{
index: inlineRange.index,
length: 0,
},
' '
);
inlineEditor.setInlineRange({
index: inlineRange.index + 1,
length: 0,
});
undoManager.stopCapturing();
inlineEditor.formatText(
{
index: start,
length: hrefText.length,
},
{
link: hrefLink.slice(1, hrefLink.length - 1),
}
);
inlineEditor.deleteText({
index: inlineRange.index + matchedText.length,
length: 1,
});
inlineEditor.deleteText({
index: inlineRange.index - hrefLink.length - 1,
length: hrefLink.length + 1,
});
inlineEditor.deleteText({
index: start,
length: 1,
});
inlineEditor.setInlineRange({
index: start + hrefText.length - 1,
length: 0,
});
return KEYBOARD_PREVENT_DEFAULT;
},
});
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 KEYBOARD_ALLOW_DEFAULT;
}
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();
const blockComponent =
inlineEditor.rootElement.closest<BlockComponent>('[data-block-id]');
if (!blockComponent) return KEYBOARD_ALLOW_DEFAULT;
const doc = blockComponent.doc;
const parentComponent = blockComponent.parentComponent;
if (!parentComponent) return KEYBOARD_ALLOW_DEFAULT;
const index = parentComponent.model.children.indexOf(
blockComponent.model
);
if (index === -1) return KEYBOARD_ALLOW_DEFAULT;
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 FIXME: ts error
latexBlock.toggleEditor();
})
.catch(console.error);
return KEYBOARD_PREVENT_DEFAULT;
}
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 KEYBOARD_PREVENT_DEFAULT;
}
if (!content || content.length === 0) {
return KEYBOARD_ALLOW_DEFAULT;
}
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,
});
return KEYBOARD_PREVENT_DEFAULT;
},
});
export const MarkdownExtensions: ExtensionType[] = [
BoldItalicMarkdown,
BoldMarkdown,
ItalicExtension,
StrikethroughExtension,
UnderthroughExtension,
CodeExtension,
LinkExtension,
LatexExtension,
];
@@ -0,0 +1,69 @@
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { ShadowlessElement } from '@blocksuite/block-std';
import { type DeltaInsert, ZERO_WIDTH_SPACE } from '@blocksuite/inline';
import { html } from 'lit';
import { property } from 'lit/decorators.js';
import { type StyleInfo, styleMap } from 'lit/directives/style-map.js';
export function affineTextStyles(
props: AffineTextAttributes,
override?: Readonly<StyleInfo>
): StyleInfo {
let textDecorations = '';
if (props.underline) {
textDecorations += 'underline';
}
if (props.strike) {
textDecorations += ' line-through';
}
let inlineCodeStyle = {};
if (props.code) {
inlineCodeStyle = {
'font-family': 'var(--affine-font-code-family)',
background: 'var(--affine-background-code-block)',
border: '1px solid var(--affine-border-color)',
'border-radius': '4px',
color: 'var(--affine-text-primary-color)',
'font-variant-ligatures': 'none',
'line-height': 'auto',
};
}
return {
'font-weight': props.bold ? 'bolder' : 'inherit',
'font-style': props.italic ? 'italic' : 'normal',
'background-color': props.background ? props.background : undefined,
color: props.color ? props.color : undefined,
'text-decoration': textDecorations.length > 0 ? textDecorations : 'none',
...inlineCodeStyle,
...override,
};
}
export class AffineText extends ShadowlessElement {
override render() {
const style = this.delta.attributes
? affineTextStyles(this.delta.attributes)
: {};
// we need to avoid \n appearing before and after the span element, which will
// cause the unexpected space
if (this.delta.attributes?.code) {
return html`<code style=${styleMap(style)}
><v-text .str=${this.delta.insert}></v-text
></code>`;
}
// we need to avoid \n appearing before and after the span element, which will
// cause the unexpected space
return html`<span style=${styleMap(style)}
><v-text .str=${this.delta.insert}></v-text
></span>`;
}
@property({ type: Object })
accessor delta: DeltaInsert<AffineTextAttributes> = {
insert: ZERO_WIDTH_SPACE,
};
}
@@ -0,0 +1,2 @@
export const REFERENCE_NODE = ' ';
export const DEFAULT_DOC_NAME = 'Untitled';
@@ -0,0 +1,5 @@
export { DEFAULT_DOC_NAME, REFERENCE_NODE } from './consts.js';
export { AffineLink, toggleLinkPopup } from './link-node/index.js';
export * from './reference-node/reference-config.js';
export { AffineReference } from './reference-node/reference-node.js';
export type { RefNodeSlots } from './reference-node/types.js';
@@ -0,0 +1,197 @@
import { ColorScheme } from '@blocksuite/affine-model';
import { ThemeProvider } from '@blocksuite/affine-shared/services';
import { unsafeCSSVar } from '@blocksuite/affine-shared/theme';
import { type BlockStdScope, ShadowlessElement } from '@blocksuite/block-std';
import { noop, SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import { DoneIcon } from '@blocksuite/icons/lit';
import type { Y } from '@blocksuite/store';
import { DocCollection } from '@blocksuite/store';
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 { 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 DocCollection.Y.Doc();
this.yText = doc.getText('latex');
this.yText.insert(0, this.latexSignal.value);
const yTextObserver = () => {
const text = this.yText.toString();
this.latexSignal.value = text;
this._updateHighlightTokens(text);
};
this.yText.observe(yTextObserver);
this.disposables.add(() => {
this.yText.unobserve(yTextObserver);
});
this.disposables.add(
effect(() => {
noop(this.highlightTokens$.value);
this.richText?.inlineEditor?.render();
})
);
this.disposables.add(
this.std.get(ThemeProvider).theme$.subscribe(() => {
this._updateHighlightTokens(this.yText.toString());
})
);
this.disposables.addFromEvent(this, 'keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
e.stopPropagation();
this.abortController.abort();
}
});
this.disposables.addFromEvent(this, 'pointerdown', e => {
e.stopPropagation();
});
this.disposables.addFromEvent(this, 'pointerup', e => {
e.stopPropagation();
});
this.updateComplete
.then(async () => {
await this.richText?.updateComplete;
setTimeout(() => {
this.richText?.inlineEditor?.focusEnd();
});
})
.catch(console.error);
}
override render() {
return html`<div class="latex-editor-container">
<div class="latex-editor">
<rich-text
.yText=${this.yText}
.attributesSchema=${this.inlineManager.getSchema()}
.attributeRenderer=${this.inlineManager.getRenderer()}
></rich-text>
</div>
<div class="latex-editor-confirm">
<span @click=${() => this.abortController.abort()}
>${DoneIcon({
width: '24',
height: '24',
})}</span
>
</div>
<div class="latex-editor-hint">Shift Enter to line break</div>
</div>`;
}
@property({ attribute: false })
accessor abortController!: AbortController;
@property({ attribute: false })
accessor latexSignal!: Signal<string>;
@property({ attribute: false })
accessor std!: BlockStdScope;
}
@@ -0,0 +1,54 @@
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { ShadowlessElement } from '@blocksuite/block-std';
import { type DeltaInsert, ZERO_WIDTH_SPACE } from '@blocksuite/inline';
import { html } from 'lit';
import { property } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
export class LatexEditorUnit extends ShadowlessElement {
get latexMenu() {
return this.closest('latex-editor-menu');
}
get vElement() {
return this.closest('v-element');
}
override render() {
const plainContent = html`<span
><v-text .str=${this.delta.insert}></v-text
></span>`;
const latexMenu = this.latexMenu;
const vElement = this.vElement;
if (!latexMenu || !vElement) {
return plainContent;
}
const lineIndex = this.vElement.lineIndex;
const tokens = latexMenu.highlightTokens$.value[lineIndex] ?? [];
if (
tokens.length === 0 ||
tokens.reduce((acc, token) => acc + token.content, '') !==
this.delta.insert
) {
return plainContent;
}
return html`<span
>${tokens.map(token => {
return html`<v-text
.str=${token.content}
style=${styleMap({
color: token.color,
})}
></v-text>`;
})}</span
>`;
}
@property({ attribute: false })
accessor delta: DeltaInsert<AffineTextAttributes> = {
insert: ZERO_WIDTH_SPACE,
};
}
@@ -0,0 +1,237 @@
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/utils';
import {
type DeltaInsert,
type InlineEditor,
ZERO_WIDTH_NON_JOINER,
ZERO_WIDTH_SPACE,
} from '@blocksuite/inline';
import { effect, 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: ${
// @ts-expect-error FIXME: ts error
unsafeCSSVarV2('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('');
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.disposables.add(
effect(() => {
const latex = this.latex$.value;
if (latex !== this.deltaLatex) {
this.editor.formatText(
{
index: this.startOffset,
length: this.endOffset - this.startOffset,
},
{
latex,
}
);
}
this.updateComplete
.then(() => {
const latexContainer = this.latexContainer;
if (!latexContainer) return;
latexContainer.replaceChildren();
// @ts-expect-error FIXME: ts error
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 FIXME: ts error
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();
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.latex$}
.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();
},
{ once: true }
);
}
@property({ attribute: false })
accessor delta: DeltaInsert<AffineTextAttributes> = {
insert: ZERO_WIDTH_SPACE,
};
@property({ attribute: false })
accessor editor!: InlineEditor<AffineTextAttributes>;
@property({ attribute: false })
accessor endOffset!: number;
@property({ attribute: false })
accessor selected = false;
@property({ attribute: false })
accessor startOffset!: number;
@property({ attribute: false })
accessor std!: BlockStdScope;
}
@@ -0,0 +1,187 @@
import type { ReferenceInfo } from '@blocksuite/affine-model';
import { ParseDocUrlProvider } from '@blocksuite/affine-shared/services';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import type { BlockComponent } from '@blocksuite/block-std';
import { BLOCK_ID_ATTR, ShadowlessElement } from '@blocksuite/block-std';
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 { ref } from 'lit/directives/ref.js';
import { type StyleInfo, styleMap } from 'lit/directives/style-map.js';
import { HoverController } from '../../../../../hover/index.js';
import { RefNodeSlotsProvider } from '../../../../extension/index.js';
import { affineTextStyles } from '../affine-text.js';
import { toggleLinkPopup } from './link-popup/toggle-link-popup.js';
export class AffineLink extends 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 _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);
};
private _whenHover = new HoverController(
this,
({ abortController }) => {
if (this.block?.doc.readonly) {
return null;
}
if (!this.inlineEditor || !this.selfInlineRange) {
return null;
}
const selection = this.std?.selection;
const textSelection = selection?.find('text');
if (!!textSelection && !textSelection.isCollapsed()) {
return null;
}
const blockSelections = selection?.filter('block');
if (blockSelections?.length) {
return null;
}
return {
template: toggleLinkPopup(
this.inlineEditor,
'view',
this.selfInlineRange,
abortController,
(e?: MouseEvent) => {
this.openLink(e);
abortController.abort();
}
),
};
},
{ enterDelay: 500 }
);
// 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() {
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;
}
get std() {
const std = this.block?.std;
return std;
}
// 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
${ref(this._whenHover.setReference)}
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,
};
}
@@ -0,0 +1,2 @@
export { AffineLink } from './affine-link.js';
export { toggleLinkPopup } from './link-popup/toggle-link-popup.js';
@@ -0,0 +1,689 @@
import {
EmbedOptionProvider,
type LinkEventType,
type TelemetryEvent,
TelemetryProvider,
} from '@blocksuite/affine-shared/services';
import type { EmbedOptions } from '@blocksuite/affine-shared/types';
import {
getHostName,
isValidUrl,
normalizeUrl,
stopPropagation,
} from '@blocksuite/affine-shared/utils';
import {
BLOCK_ID_ATTR,
type BlockComponent,
type BlockStdScope,
} from '@blocksuite/block-std';
import { WithDisposable } from '@blocksuite/global/utils';
import type { InlineRange } from '@blocksuite/inline/types';
import { computePosition, inline, offset, shift } from '@floating-ui/dom';
import { html, LitElement, nothing } from 'lit';
import { property, query } from 'lit/decorators.js';
import { choose } from 'lit/directives/choose.js';
import { join } from 'lit/directives/join.js';
import { repeat } from 'lit/directives/repeat.js';
import {
ConfirmIcon,
CopyIcon,
DeleteIcon,
EditIcon,
MoreVerticalIcon,
OpenIcon,
SmallArrowDownIcon,
UnlinkIcon,
} from '../../../../../../icons/index.js';
import { toast } from '../../../../../../toast/index.js';
import type { EditorIconButton } from '../../../../../../toolbar/index.js';
import {
renderActions,
renderToolbarSeparator,
} from '../../../../../../toolbar/index.js';
import type { AffineInlineEditor } from '../../../affine-inline-specs.js';
import { linkPopupStyle } from './styles.js';
export class LinkPopup extends WithDisposable(LitElement) {
static override styles = linkPopupStyle;
private _bodyOverflowStyle = '';
private _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 _delete = () => {
if (this.inlineEditor.isValidInlineRange(this.targetInlineRange)) {
this.inlineEditor.deleteText(this.targetInlineRange);
}
this.abortController.abort();
};
private _edit = () => {
if (!this.host) return;
this.type = 'edit';
track(this.host.std, 'OpenedAliasPopup', { control: 'edit' });
};
private _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>
`;
};
private _embedOptions: EmbedOptions | null = null;
private _openLink = () => {
if (this.openLink) {
this.openLink();
return;
}
let link = this.currentLink;
if (!link) return;
if (!link.match(/^[a-zA-Z]+:\/\//)) {
link = 'https://' + link;
}
window.open(link, '_blank');
this.abortController.abort();
};
private _removeLink = () => {
if (this.inlineEditor.isValidInlineRange(this.targetInlineRange)) {
this.inlineEditor.formatText(this.targetInlineRange, {
link: null,
});
}
this.abortController.abort();
};
private _toggleViewSelector = (e: Event) => {
if (!this.host) return;
const opened = (e as CustomEvent<boolean>).detail;
if (!opened) return;
track(this.host.std, 'OpenedViewSelector', { control: 'switch view' });
};
private _trackViewSelected = (type: string) => {
if (!this.host) return;
track(this.host.std, 'SelectedView', {
control: 'select view',
type: `${type} view`,
});
};
private _viewTemplate = () => {
if (!this.currentLink) return;
this._embedOptions =
this.std
?.get(EmbedOptionProvider)
.getEmbedBlockOptions(this.currentLink) ?? null;
const buttons = [
html`
<a
class="affine-link-preview"
href=${this.currentLink}
rel="noopener noreferrer"
target="_blank"
@click=${(e: MouseEvent) => this.openLink?.(e)}
>
<span>${getHostName(this.currentLink)}</span>
</a>
<editor-icon-button
aria-label="Copy"
data-testid="copy-link"
.tooltip=${'Copy link'}
@click=${this._copyUrl}
>
${CopyIcon}
</editor-icon-button>
<editor-icon-button
aria-label="Edit"
data-testid="edit"
.tooltip=${'Edit'}
@click=${this._edit}
>
${EditIcon}
</editor-icon-button>
`,
this._viewSelector(),
html`
<editor-menu-button
.contentPadding=${'8px'}
.button=${html`
<editor-icon-button aria-label="More" .tooltip=${'More'}>
${MoreVerticalIcon}
</editor-icon-button>
`}
>
<div data-size="large" data-orientation="vertical">
${this._moreActions()}
</div>
</editor-menu-button>
`,
];
return html`
<editor-toolbar class="affine-link-popover view">
${join(
buttons.filter(button => button !== nothing),
renderToolbarSeparator
)}
</editor-toolbar>
`;
};
private get _canConvertToEmbedView() {
return this._embedOptions?.viewType === 'embed';
}
private get _isBookmarkAllowed() {
const block = this.block;
if (!block) return false;
const schema = block.doc.schema;
const parent = block.doc.getParent(block.model);
if (!parent) return false;
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;
}
get block() {
const { rootElement } = this.inlineEditor;
if (!rootElement) return null;
const block = rootElement.closest<BlockComponent>(`[${BLOCK_ID_ATTR}]`);
if (!block) return null;
return block;
}
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
);
}
get host() {
return this.block?.host;
}
get std() {
return this.block?.std;
}
private _confirmBtnTemplate() {
return html`
<editor-icon-button
class="affine-confirm-button"
.iconSize=${'24px'}
.disabled=${true}
@click=${this._onConfirm}
>
${ConfirmIcon}
</editor-icon-button>
`;
}
private _convertToCardView() {
if (!this.inlineEditor.isValidInlineRange(this.targetInlineRange)) {
return;
}
let targetFlavour = 'affine:bookmark';
if (this._embedOptions && this._embedOptions.viewType === 'card') {
targetFlavour = this._embedOptions.flavour;
}
const block = this.block;
if (!block) return;
const url = this.currentLink;
const title = this.currentText;
const props = {
url,
title: title === url ? '' : title,
};
const doc = block.doc;
const parent = doc.getParent(block.model);
if (!parent) return;
const index = parent.children.indexOf(block.model);
doc.addBlock(targetFlavour as never, props, parent, index + 1);
const totalTextLength = this.inlineEditor.yTextLength;
const inlineTextLength = this.targetInlineRange.length;
if (totalTextLength === inlineTextLength) {
doc.deleteBlock(block.model);
} else {
this.inlineEditor.formatText(this.targetInlineRange, { link: null });
}
this.abortController.abort();
}
private _convertToEmbedView() {
if (!this._embedOptions || this._embedOptions.viewType !== 'embed') {
return;
}
const { flavour } = this._embedOptions;
const url = this.currentLink;
const block = this.block;
if (!block) return;
const doc = block.doc;
const parent = doc.getParent(block.model);
if (!parent) return;
const index = parent.children.indexOf(block.model);
doc.addBlock(flavour as never, { url }, parent, index + 1);
const totalTextLength = this.inlineEditor.yTextLength;
const inlineTextLength = this.targetInlineRange.length;
if (totalTextLength === inlineTextLength) {
doc.deleteBlock(block.model);
} else {
this.inlineEditor.formatText(this.targetInlineRange, { link: null });
}
this.abortController.abort();
}
private _copyUrl() {
if (!this.currentLink) return;
navigator.clipboard.writeText(this.currentLink).catch(console.error);
if (!this.host) return;
toast(this.host, 'Copied link to clipboard');
this.abortController.abort();
track(this.host.std, 'CopiedLink', { control: 'copy link' });
}
private _moreActions() {
return renderActions([
[
{
label: 'Open',
type: 'open',
icon: OpenIcon,
action: this._openLink,
},
{
label: 'Copy',
type: 'copy',
icon: CopyIcon,
action: this._copyUrl,
},
{
label: 'Remove link',
type: 'remove-link',
icon: UnlinkIcon,
action: this._removeLink,
},
],
[
{
type: 'delete',
label: 'Delete',
icon: DeleteIcon,
action: this._delete,
},
],
]);
}
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);
const textSelection = this.host?.selection.find('text');
if (!textSelection) return;
this.std?.range.syncTextSelectionToRange(textSelection);
} 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.host?.selection.find('text');
if (!textSelection) return;
this.std?.range.syncTextSelectionToRange(textSelection);
}
this.abortController.abort();
}
private _onKeydown(e: KeyboardEvent) {
e.stopPropagation();
if (e.key === 'Enter' && !e.isComposing) {
e.preventDefault();
this._onConfirm();
}
}
private _updateConfirmBtn() {
if (!this.confirmButton) {
return;
}
const link = this.linkInput?.value.trim();
const disabled = !(link && isValidUrl(link));
this.confirmButton.disabled = disabled;
this.confirmButton.active = !disabled;
this.confirmButton.requestUpdate();
}
private _viewSelector() {
if (!this._isBookmarkAllowed) return nothing;
const buttons = [];
buttons.push({
type: 'inline',
label: 'Inline view',
});
buttons.push({
type: 'card',
label: 'Card view',
action: () => this._convertToCardView(),
});
if (this._canConvertToEmbedView) {
buttons.push({
type: 'embed',
label: 'Embed view',
action: () => this._convertToEmbedView(),
});
}
return html`
<editor-menu-button
.contentPadding=${'8px'}
.button=${html`
<editor-icon-button
aria-label="Switch view"
.justify=${'space-between'}
.labelHeight=${'20px'}
.iconContainerWidth=${'110px'}
>
<div class="label">Inline view</div>
${SmallArrowDownIcon}
</editor-icon-button>
`}
@toggle=${this._toggleViewSelector}
>
<div data-size="small" data-orientation="vertical">
${repeat(
buttons,
button => button.type,
({ type, label, action }) => html`
<editor-menu-action
data-testid=${`link-to-${type}`}
?data-selected=${type === 'inline'}
?disabled=${type === 'inline'}
@click=${() => {
action?.();
this._trackViewSelected(type);
}}
>
${label}
</editor-menu-action>
`
)}
</div>
</editor-menu-button>
`;
}
override connectedCallback() {
super.connectedCallback();
if (this.targetInlineRange.length === 0) {
return;
}
if (this.type === 'edit' || this.type === 'create') {
// disable body scroll
this._bodyOverflowStyle = document.body.style.overflow;
document.body.style.overflow = 'hidden';
this.disposables.add({
dispose: () => {
document.body.style.overflow = this._bodyOverflowStyle;
},
});
}
}
protected override firstUpdated() {
if (!this.linkInput) return;
this._disposables.addFromEvent(this.linkInput, 'copy', stopPropagation);
this._disposables.addFromEvent(this.linkInput, 'cut', stopPropagation);
this._disposables.addFromEvent(this.linkInput, 'paste', stopPropagation);
}
override render() {
return html`
<div class="overlay-root">
${this.type === 'view'
? nothing
: html`
<div
class="affine-link-popover-overlay-mask"
@click=${() => {
this.abortController.abort();
this.host?.selection.clear();
}}
></div>
`}
<div class="affine-link-popover-container" @keydown=${this._onKeydown}>
${choose(this.type, [
['create', this._createTemplate],
['edit', this._editTemplate],
['view', this._viewTemplate],
])}
</div>
<div class="mock-selection-container"></div>
</div>
`;
}
override updated() {
const range = this.inlineEditor.toDomRange(this.targetInlineRange);
if (!range) {
return;
}
if (this.type !== 'view') {
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(),
};
computePosition(visualElement, this.popupContainer, {
middleware: [
offset(10),
inline(),
shift({
padding: 6,
}),
],
})
.then(({ x, y }) => {
const popupContainer = this.popupContainer;
if (!popupContainer) return;
popupContainer.style.left = `${x}px`;
popupContainer.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;
@property({ attribute: false })
accessor openLink: ((e?: MouseEvent) => void) | null = null;
@query('.affine-link-popover-container')
accessor popupContainer!: HTMLDivElement;
@property({ attribute: false })
accessor targetInlineRange!: InlineRange;
@query('#text-input')
accessor textInput: HTMLInputElement | null = null;
@property()
accessor type: 'create' | 'edit' | 'view' = 'create';
}
function track(
std: BlockStdScope,
event: LinkEventType,
props: Partial<TelemetryEvent>
) {
std.getOptional(TelemetryProvider)?.track(event, {
segment: 'toolbar',
page: 'doc editor',
module: 'link toolbar',
type: 'inline view',
category: 'link',
...props,
});
}
@@ -0,0 +1,191 @@
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);
}
.affine-link-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);
}
}
.affine-link-popover-overlay-mask {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: var(--affine-z-index-popover);
}
.affine-link-preview {
display: flex;
justify-content: flex-start;
min-width: 60px;
max-width: 140px;
padding: var(--1, 0px);
border-radius: var(--1, 0px);
opacity: var(--add, 1);
user-select: none;
cursor: pointer;
color: var(--affine-link-color);
font-feature-settings:
'clig' off,
'liga' off;
font-family: var(--affine-font-family);
font-size: var(--affine-font-sm);
font-style: normal;
font-weight: 400;
text-decoration: none;
text-wrap: nowrap;
}
.affine-link-preview > span {
display: inline-block;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
text-overflow: ellipsis;
overflow: hidden;
opacity: var(--add, 1);
}
.affine-link-popover.create {
${PANEL_BASE};
gap: 12px;
padding: 12px;
color: var(--affine-text-primary-color);
}
.affine-link-popover-input {
min-width: 280px;
height: 30px;
box-sizing: border-box;
padding: 4px 10px;
background: var(--affine-white-10);
border-radius: 4px;
border-width: 1px;
border-style: solid;
border-color: var(--affine-border-color);
color: var(--affine-text-primary-color);
${FONT_XS};
}
.affine-link-popover-input::placeholder {
color: var(--affine-placeholder-color);
}
.affine-link-popover-input:focus {
border-color: var(--affine-blue-700);
box-shadow: var(--affine-active-shadow);
}
${editLinkStyle}
`;
@@ -0,0 +1,23 @@
import type { InlineRange } from '@blocksuite/inline';
import type { AffineInlineEditor } from '../../../affine-inline-specs.js';
import { LinkPopup } from './link-popup.js';
export function toggleLinkPopup(
inlineEditor: AffineInlineEditor,
type: LinkPopup['type'],
targetInlineRange: InlineRange,
abortController: AbortController,
openLink: ((e?: MouseEvent) => void) | null = null
): LinkPopup {
const popup = new LinkPopup();
popup.inlineEditor = inlineEditor;
popup.type = type;
popup.targetInlineRange = targetInlineRange;
popup.openLink = openLink;
popup.abortController = abortController;
document.body.append(popup);
return popup;
}
@@ -0,0 +1,284 @@
import type { ReferenceInfo } from '@blocksuite/affine-model';
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 { type BlockStdScope, ShadowlessElement } from '@blocksuite/block-std';
import {
assertExists,
SignalWatcher,
WithDisposable,
} from '@blocksuite/global/utils';
import { DoneIcon, ResetIcon } from '@blocksuite/icons/lit';
import type { DeltaInsert, 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.js';
import type { AffineInlineEditor } from '../../affine-inline-specs.js';
import { REFERENCE_NODE } from '../consts.js';
export class ReferenceAliasPopup 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;
}
.alias-form-popup {
${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 _onSave = () => {
const title = this.title$.value.trim();
if (!title) {
this.remove();
return;
}
this._setTitle(title);
track(this.std, 'SavedAlias', { control: 'save' });
this.remove();
};
private _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.overlayMask, 'click', e => {
e.stopPropagation();
this.remove();
});
this.disposables.addFromEvent(this, 'keydown', this._onKeydown);
this.inputElement.focus();
this.inputElement.select();
}
override render() {
return html`
<div class="overlay-root">
<div class="overlay-mask"></div>
<div class="alias-form-popup">
<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);
assertExists(range);
const visualElement = {
getBoundingClientRect: () => range.getBoundingClientRect(),
getClientRects: () => range.getClientRects(),
};
computePosition(visualElement, this.popupContainer, {
middleware: [
offset(10),
inline(),
shift({
padding: 6,
}),
],
})
.then(({ x, y }) => {
const popupContainer = this.popupContainer;
if (!popupContainer) return;
popupContainer.style.left = `${x}px`;
popupContainer.style.top = `${y}px`;
})
.catch(console.error);
}
@property({ type: Object })
accessor delta!: DeltaInsert<AffineTextAttributes>;
@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('.alias-form-popup')
accessor popupContainer!: 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: 'toolbar',
page: 'doc editor',
module: 'reference edit popup',
type: 'inline view',
category: 'linked doc',
...props,
});
}
@@ -0,0 +1,64 @@
import type { BlockStdScope, ExtensionType } from '@blocksuite/block-std';
import { createIdentifier } from '@blocksuite/global/di';
import type { TemplateResult } from 'lit';
import type { AffineReference } from './reference-node.js';
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.doc;
}
get hidePopup() {
return this._hidePopup;
}
get interactable() {
return this._interactable;
}
constructor(readonly std: BlockStdScope) {}
setCustomContent(content: ReferenceNodeConfigProvider['_customContent']) {
this._customContent = content;
}
setHidePopup(hidePopup: boolean) {
this._hidePopup = hidePopup;
}
setInteractable(interactable: boolean) {
this._interactable = interactable;
}
}
@@ -0,0 +1,318 @@
import type { ReferenceInfo } from '@blocksuite/affine-model';
import { DocDisplayMetaProvider } from '@blocksuite/affine-shared/services';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import {
cloneReferenceInfo,
referenceToNode,
} from '@blocksuite/affine-shared/utils';
import {
BLOCK_ID_ATTR,
type BlockComponent,
ShadowlessElement,
} from '@blocksuite/block-std';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { WithDisposable } from '@blocksuite/global/utils';
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 { Doc, DocMeta } 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 { ref } from 'lit/directives/ref.js';
import { styleMap } from 'lit/directives/style-map.js';
import { HoverController } from '../../../../../hover/index.js';
import { Peekable } from '../../../../../peek/index.js';
import { RefNodeSlotsProvider } from '../../../../extension/index.js';
import { affineTextStyles } from '../affine-text.js';
import { DEFAULT_DOC_NAME, REFERENCE_NODE } from '../consts.js';
import type { ReferenceNodeConfigProvider } from './reference-config.js';
import { toggleReferencePopup } from './reference-popup.js';
@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);
}
`;
private _updateRefMeta = (doc: Doc) => {
const refAttribute = this.delta.attributes?.reference;
if (!refAttribute) {
return;
}
const refMeta = doc.collection.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;
private _whenHover: HoverController = new HoverController(
this,
({ abortController }) => {
if (
this.config.hidePopup ||
this.doc?.readonly ||
this.closest('.prevent-reference-popup') ||
!this.selfInlineRange ||
!this.inlineEditor
) {
return null;
}
const selection = this.std?.selection;
if (!selection) {
return null;
}
const textSelection = selection.find('text');
if (!!textSelection && !textSelection.isCollapsed()) {
return null;
}
const blockSelections = selection.filter('block');
if (blockSelections.length) {
return null;
}
return {
template: toggleReferencePopup(
this,
this.referenceToNode(),
this.referenceInfo,
this.inlineEditor,
this.selfInlineRange,
this.refMeta?.title ?? DEFAULT_DOC_NAME,
abortController
),
};
},
{ enterDelay: 500 }
);
get _icon() {
const { pageId, params, title } = this.referenceInfo;
return this.block?.std
?.get(DocDisplayMetaProvider)
.icon(pageId, { params, title, referenced: true }).value;
}
get _title() {
const { pageId, params, title } = this.referenceInfo;
return (
title ||
this.block?.std
?.get(DocDisplayMetaProvider)
.title(pageId, { params, title, referenced: true }).value
);
}
get block() {
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;
}
get std() {
const std = this.block?.std;
if (!std) {
throw new BlockSuiteError(
ErrorCode.ValueNotExists,
'std not found in reference node'
);
}
return std;
}
private _onClick() {
if (!this.config.interactable) return;
this.std
.getOptional(RefNodeSlotsProvider)
?.docLinkClicked.emit(this.referenceInfo);
}
override connectedCallback() {
super.connectedCallback();
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.collection.slots.docUpdated.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
${this.config.interactable ? ref(this._whenHover.setReference) : ''}
data-selected=${this.selected}
class="affine-reference"
style=${styleMap(style)}
@click=${this._onClick}
>${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;
}
@@ -0,0 +1,559 @@
import type { ReferenceInfo } from '@blocksuite/affine-model';
import {
GenerateDocUrlProvider,
type LinkEventType,
type TelemetryEvent,
TelemetryProvider,
} from '@blocksuite/affine-shared/services';
import {
cloneReferenceInfoWithoutAliases,
isInsideBlockByFlavour,
} from '@blocksuite/affine-shared/utils';
import {
BLOCK_ID_ATTR,
type BlockComponent,
type BlockStdScope,
} from '@blocksuite/block-std';
import { assertExists, WithDisposable } from '@blocksuite/global/utils';
import type { InlineRange } from '@blocksuite/inline';
import { computePosition, inline, offset, shift } from '@floating-ui/dom';
import { effect } from '@preact/signals-core';
import { html, LitElement, nothing } from 'lit';
import { property, query } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { join } from 'lit/directives/join.js';
import { repeat } from 'lit/directives/repeat.js';
import {
CenterPeekIcon,
CopyIcon,
DeleteIcon,
EditIcon,
ExpandFullSmallIcon,
MoreVerticalIcon,
OpenIcon,
SmallArrowDownIcon,
} from '../../../../../icons/index.js';
import { notifyLinkedDocSwitchedToEmbed } from '../../../../../notification/index.js';
import { isPeekable, peek } from '../../../../../peek/index.js';
import { toast } from '../../../../../toast/toast.js';
import {
type MenuItem,
renderActions,
renderToolbarSeparator,
} from '../../../../../toolbar/index.js';
import { RefNodeSlotsProvider } from '../../../../extension/index.js';
import type { AffineInlineEditor } from '../../affine-inline-specs.js';
import { ReferenceAliasPopup } from './reference-alias-popup.js';
import { styles } from './styles.js';
export class ReferencePopup extends WithDisposable(LitElement) {
static override styles = styles;
private _copyLink = () => {
const url = this.std
.getOptional(GenerateDocUrlProvider)
?.generateDocUrl(this.referenceInfo.pageId, this.referenceInfo.params);
if (url) {
navigator.clipboard.writeText(url).catch(console.error);
toast(this.std.host, 'Copied link to clipboard');
}
this.abortController.abort();
track(this.std, 'CopiedLink', { control: 'copy link' });
};
private _openDoc = () => {
this.std
.getOptional(RefNodeSlotsProvider)
?.docLinkClicked.emit(this.referenceInfo);
};
private _openEditPopup = (e: MouseEvent) => {
e.stopPropagation();
if (document.body.querySelector('reference-alias-popup')) {
return;
}
const {
std,
docTitle,
referenceInfo,
inlineEditor,
targetInlineRange,
abortController,
} = this;
const aliasPopup = new ReferenceAliasPopup();
aliasPopup.std = std;
aliasPopup.docTitle = docTitle;
aliasPopup.referenceInfo = referenceInfo;
aliasPopup.inlineEditor = inlineEditor;
aliasPopup.inlineRange = targetInlineRange;
document.body.append(aliasPopup);
abortController.abort();
track(std, 'OpenedAliasPopup', { control: 'edit' });
};
private _toggleViewSelector = (e: Event) => {
const opened = (e as CustomEvent<boolean>).detail;
if (!opened) return;
track(this.std, 'OpenedViewSelector', { control: 'switch view' });
};
private _trackViewSelected = (type: string) => {
track(this.std, 'SelectedView', {
control: 'select view',
type: `${type} view`,
});
};
get _embedViewButtonDisabled() {
if (
this.block.doc.readonly ||
isInsideBlockByFlavour(
this.block.doc,
this.block.model,
'affine:edgeless-text'
)
) {
return true;
}
return (
!!this.block.closest('affine-embed-synced-doc-block') ||
this.referenceDocId === this.doc.id
);
}
get _openButtonDisabled() {
return this.referenceDocId === this.doc.id;
}
get block() {
const block = this.inlineEditor.rootElement.closest<BlockComponent>(
`[${BLOCK_ID_ATTR}]`
);
assertExists(block);
return block;
}
get doc() {
const doc = this.block.doc;
assertExists(doc);
return doc;
}
get referenceDocId() {
const docId = this.inlineEditor.getFormat(this.targetInlineRange).reference
?.pageId;
assertExists(docId);
return docId;
}
get std() {
const std = this.block.std;
assertExists(std);
return std;
}
private _convertToCardView() {
const block = this.block;
const doc = block.host.doc;
const parent = doc.getParent(block.model);
assertExists(parent);
const index = parent.children.indexOf(block.model);
doc.addBlock(
'affine:embed-linked-doc',
this.referenceInfo,
parent,
index + 1
);
const totalTextLength = this.inlineEditor.yTextLength;
const inlineTextLength = this.targetInlineRange.length;
if (totalTextLength === inlineTextLength) {
doc.deleteBlock(block.model);
} else {
this.inlineEditor.insertText(this.targetInlineRange, this.docTitle);
}
this.abortController.abort();
}
private _convertToEmbedView() {
const block = this.block;
const std = block.std;
const doc = block.host.doc;
const parent = doc.getParent(block.model);
assertExists(parent);
const index = parent.children.indexOf(block.model);
const referenceInfo = this.referenceInfo;
const hasTitleAlias = Boolean(referenceInfo.title);
doc.addBlock(
'affine:embed-synced-doc',
cloneReferenceInfoWithoutAliases(referenceInfo),
parent,
index + 1
);
const totalTextLength = this.inlineEditor.yTextLength;
const inlineTextLength = this.targetInlineRange.length;
if (totalTextLength === inlineTextLength) {
doc.deleteBlock(block.model);
} else {
this.inlineEditor.insertText(this.targetInlineRange, this.docTitle);
}
if (hasTitleAlias) {
notifyLinkedDocSwitchedToEmbed(std);
}
this.abortController.abort();
}
private _delete() {
if (this.inlineEditor.isValidInlineRange(this.targetInlineRange)) {
this.inlineEditor.deleteText(this.targetInlineRange);
}
this.abortController.abort();
}
private _moreActions() {
return renderActions([
[
{
type: 'delete',
label: 'Delete',
icon: DeleteIcon,
disabled: this.doc.readonly,
action: () => this._delete(),
},
],
]);
}
private _openMenuButton() {
const buttons: MenuItem[] = [
{
label: 'Open this doc',
type: 'open-this-doc',
icon: ExpandFullSmallIcon,
action: this._openDoc,
disabled: this._openButtonDisabled,
},
];
// open in new tab
if (isPeekable(this.target)) {
buttons.push({
label: 'Open in center peek',
type: 'open-in-center-peek',
icon: CenterPeekIcon,
action: () => peek(this.target),
});
}
// open in split view
if (buttons.length === 0) {
return nothing;
}
return html`
<editor-menu-button
.contentPadding=${'8px'}
.button=${html`
<editor-icon-button
aria-label="Open doc"
.justify=${'space-between'}
.labelHeight=${'20px'}
>
${OpenIcon}${SmallArrowDownIcon}
</editor-icon-button>
`}
>
<div data-size="large" data-orientation="vertical">
${repeat(
buttons,
button => button.label,
({ label, icon, action, disabled }) => html`
<editor-menu-action
aria-label=${ifDefined(label)}
?disabled=${disabled}
@click=${action}
>
${icon}<span class="label">${label}</span>
</editor-menu-action>
`
)}
</div>
</editor-menu-button>
`;
}
private _viewSelector() {
// synced doc entry controlled by awareness flag
const isSyncedDocEnabled = this.doc.awarenessStore.getFlag(
'enable_synced_doc_block'
);
const buttons = [];
buttons.push({
type: 'inline',
label: 'Inline view',
});
buttons.push({
type: 'card',
label: 'Card view',
action: () => this._convertToCardView(),
disabled: this.doc.readonly,
});
if (isSyncedDocEnabled) {
buttons.push({
type: 'embed',
label: 'Embed view',
action: () => this._convertToEmbedView(),
disabled:
this.doc.readonly ||
this.isLinkedNode ||
this._embedViewButtonDisabled,
});
}
return html`
<editor-menu-button
.contentPadding=${'8px'}
.button=${html`
<editor-icon-button
aria-label="Switch view"
.justify=${'space-between'}
.labelHeight=${'20px'}
.iconContainerWidth=${'110px'}
>
<span class="label">Inline view</span>
${SmallArrowDownIcon}
</editor-icon-button>
`}
@toggle=${this._toggleViewSelector}
>
<div data-size="small" data-orientation="vertical">
${repeat(
buttons,
button => button.type,
({ type, label, action, disabled }) => html`
<editor-menu-action
aria-label=${label}
data-testid=${`link-to-${type}`}
?data-selected=${type === 'inline'}
?disabled=${disabled || type === 'inline'}
@click=${() => {
action?.();
this._trackViewSelected(type);
}}
>
${label}
</editor-menu-action>
`
)}
</div>
</editor-menu-button>
`;
}
override connectedCallback() {
super.connectedCallback();
if (this.targetInlineRange.length === 0) {
return;
}
const parent = this.block.host.doc.getParent(this.block.model);
assertExists(parent);
this.disposables.add(
effect(() => {
const children = parent.children;
if (children.includes(this.block.model)) return;
this.abortController.abort();
})
);
}
override render() {
const titleButton = this.referenceInfo.title
? html`
<editor-icon-button
class="doc-title"
aria-label="Doc title"
.hover=${false}
.labelHeight=${'20px'}
.tooltip=${this.docTitle}
@click=${this._openDoc}
>
<span class="label">${this.docTitle}</span>
</editor-icon-button>
`
: nothing;
const buttons = [
this._openMenuButton(),
html`
${titleButton}
<editor-icon-button
aria-label="Copy link"
data-testid="copy-link"
.tooltip=${'Copy link'}
@click=${this._copyLink}
>
${CopyIcon}
</editor-icon-button>
<editor-icon-button
aria-label="Edit"
data-testid="edit"
.tooltip=${'Edit'}
?disabled=${this.doc.readonly}
@click=${this._openEditPopup}
>
${EditIcon}
</editor-icon-button>
`,
this._viewSelector(),
html`
<editor-menu-button
.contentPadding=${'8px'}
.button=${html`
<editor-icon-button aria-label="More" .tooltip=${'More'}>
${MoreVerticalIcon}
</editor-icon-button>
`}
>
<div data-size="large" data-orientation="vertical">
${this._moreActions()}
</div>
</editor-menu-button>
`,
];
return html`
<div class="overlay-root">
<div class="affine-reference-popover-container">
<editor-toolbar class="affine-reference-popover view">
${join(
buttons.filter(button => button !== nothing),
renderToolbarSeparator
)}
</editor-toolbar>
</div>
</div>
`;
}
override updated() {
assertExists(this.popupContainer);
const range = this.inlineEditor.toDomRange(this.targetInlineRange);
assertExists(range);
const visualElement = {
getBoundingClientRect: () => range.getBoundingClientRect(),
getClientRects: () => range.getClientRects(),
};
computePosition(visualElement, this.popupContainer, {
middleware: [
offset(10),
inline(),
shift({
padding: 6,
}),
],
})
.then(({ x, y }) => {
const popupContainer = this.popupContainer;
if (!popupContainer) return;
popupContainer.style.left = `${x}px`;
popupContainer.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 isLinkedNode!: boolean;
@query('.affine-reference-popover-container')
accessor popupContainer!: HTMLDivElement;
@property({ type: Object })
accessor referenceInfo!: ReferenceInfo;
@property({ attribute: false })
accessor target!: LitElement;
@property({ attribute: false })
accessor targetInlineRange!: InlineRange;
}
export function toggleReferencePopup(
target: LitElement,
isLinkedNode: boolean,
referenceInfo: ReferenceInfo,
inlineEditor: AffineInlineEditor,
targetInlineRange: InlineRange,
docTitle: string,
abortController: AbortController
): ReferencePopup {
const popup = new ReferencePopup();
popup.target = target;
popup.isLinkedNode = isLinkedNode;
popup.referenceInfo = referenceInfo;
popup.inlineEditor = inlineEditor;
popup.targetInlineRange = targetInlineRange;
popup.docTitle = docTitle;
popup.abortController = abortController;
document.body.append(popup);
return popup;
}
function track(
std: BlockStdScope,
event: LinkEventType,
props: Partial<TelemetryEvent>
) {
std.getOptional(TelemetryProvider)?.track(event, {
segment: 'toolbar',
page: 'doc editor',
module: 'reference toolbar',
type: 'inline view',
category: 'linked doc',
...props,
});
}
@@ -0,0 +1,44 @@
import { css } from 'lit';
export const styles = css`
:host {
box-sizing: border-box;
}
.affine-reference-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);
}
}
editor-icon-button.doc-title .label {
max-width: 110px;
display: inline-block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
user-select: none;
cursor: pointer;
color: var(--affine-link-color);
font-feature-settings:
'clig' off,
'liga' off;
font-family: var(--affine-font-family);
font-size: var(--affine-font-sm);
font-style: normal;
font-weight: 400;
text-decoration: none;
text-wrap: nowrap;
}
`;
@@ -0,0 +1,6 @@
import type { ReferenceInfo } from '@blocksuite/affine-model';
import type { Slot } from '@blocksuite/global/utils';
export type RefNodeSlots = {
docLinkClicked: Slot<ReferenceInfo>;
};
@@ -0,0 +1,72 @@
import type { BlockStdScope, 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('text');
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('text');
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('text');
if (!text) return;
selectBlock(std, text.from.blockId);
ctx.get('keyboardState').raw.stopPropagation();
return true;
},
'Mod-a': ctx => {
const text = std.selection.find('text');
if (!text) return;
const model = std.doc.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('block');
const blockId = blocks.at(-1)?.blockId;
if (!blockId) return;
const model = std.doc.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('block', { blockId })]);
}
@@ -0,0 +1,161 @@
import { BRACKET_PAIRS } from '@blocksuite/affine-shared/consts';
import {
createDefaultDoc,
matchFlavours,
} from '@blocksuite/affine-shared/utils';
import type { BlockStdScope, 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 { doc, selection } = std;
if (doc.readonly) return;
const textSelection = selection.find('text');
if (!textSelection) return;
const model = doc.getBlock(textSelection.from.blockId)?.model;
if (!model) return;
if (!matchFlavours(model, ['affine:code'])) 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 { doc, selection } = std;
if (doc.readonly) return;
const textSelection = selection.find('text');
if (!textSelection) return;
const model = doc.getBlock(textSelection.from.blockId)?.model;
if (!model) return;
const isCodeBlock = matchFlavours(model, ['affine:code']);
// 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 { doc, selection } = std;
if (doc.readonly) return;
const textSelection = selection.find('text');
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.doc.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.doc.collection, {
title: docName,
});
insertLinkedNode({
inlineEditor,
docId: doc.id,
});
return true;
}
@@ -0,0 +1,26 @@
import type { BlockStdScope, 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 { doc, selection } = std;
if (doc.readonly) return;
const textSelection = selection.find('text');
if (!textSelection) return;
config.action(std.host);
ctx.get('keyboardState').raw.preventDefault();
return true;
},
};
},
{} as Record<string, UIEventHandler>
);
@@ -0,0 +1,15 @@
import type { BlockStdScope, UIEventHandler } from '@blocksuite/block-std';
import { textCommonKeymap } from './basic.js';
import { bracketKeymap } from './bracket.js';
import { textFormatKeymap } from './format.js';
export const textKeymap = (
std: BlockStdScope
): Record<string, UIEventHandler> => {
return {
...textCommonKeymap(std),
...textFormatKeymap(std),
...bracketKeymap(std),
};
};
@@ -0,0 +1,20 @@
import { type AffineInlineEditor, REFERENCE_NODE } 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,
});
}
@@ -0,0 +1,38 @@
import { matchFlavours } 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 { doc } = std;
if (
matchFlavours(model, ['affine:divider']) ||
(matchFlavours(model, ['affine:paragraph']) && 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;
}
@@ -0,0 +1 @@
export { markdownInput } from './markdown-input.js';
@@ -0,0 +1,50 @@
import type { ListProps, ListType } from '@blocksuite/affine-model';
import { matchFlavours, 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 (!matchFlavours(model, ['affine:paragraph'])) {
return;
}
const { 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;
}
@@ -0,0 +1,85 @@
import {
isMarkdownPrefix,
matchFlavours,
} from '@blocksuite/affine-shared/utils';
import type { BlockStdScope } 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('text');
id = text?.from.blockId;
}
if (!id) return;
const model = std.doc.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 = matchFlavours(model, ['affine:paragraph']);
const isHeading = isParagraph && model.type.startsWith('h');
const isParagraphQuoteBlock = isParagraph && model.type === 'quote';
const isCodeBlock = matchFlavours(model, ['affine:code']);
if (isHeading || isParagraphQuoteBlock || isCodeBlock) 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));
}
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 '***':
case '---':
return toDivider(std, model, 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);
}
}
@@ -0,0 +1,46 @@
import type { ParagraphType } from '@blocksuite/affine-model';
import { matchFlavours } 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 { doc } = std;
if (!matchFlavours(model, ['affine:paragraph'])) {
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 (matchFlavours(model, ['affine:paragraph']) && 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;
}
@@ -0,0 +1,38 @@
import { matchFlavours } 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 (matchFlavours(model, ['affine:paragraph']) && 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;
}
@@ -0,0 +1,39 @@
import type { BlockStdScope } from '@blocksuite/block-std';
import type { InlineEditor } from '@blocksuite/inline';
import type { BlockModel } from '@blocksuite/store';
import { focusTextModel } from '../dom.js';
export function getPrefixText(inlineEditor: InlineEditor) {
const inlineRange = inlineEditor.getInlineRange();
if (!inlineRange) return '';
const firstLineEnd = inlineEditor.yTextString.search(/\n/);
if (firstLineEnd !== -1 && inlineRange.index > firstLineEnd) {
return '';
}
const textPoint = inlineEditor.getTextPoint(inlineRange.index);
if (!textPoint) return '';
const [leafStart, offsetStart] = textPoint;
return leafStart.textContent
? leafStart.textContent.slice(0, offsetStart)
: '';
}
export function beforeConvert(
std: BlockStdScope,
model: BlockModel,
index: number
) {
const { text } = model;
if (!text) return;
// Add a space after the text, then stop capturing
// So when the user undo, the prefix will be restored with a `space`
// Ex. (| is the cursor position)
// *| <- user input
// <space> -> bullet list
// *<space>| -> undo
text.insert(' ', index);
focusTextModel(std, model.id, index + 1);
std.doc.captureSync();
text.delete(0, index + 1);
}
@@ -0,0 +1,429 @@
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { ShadowlessElement } from '@blocksuite/block-std';
import { assertExists, WithDisposable } from '@blocksuite/global/utils';
import {
type AttributeRenderer,
createInlineKeyDownHandler,
type DeltaInsert,
InlineEditor,
type InlineRange,
type InlineRangeProvider,
type KeyboardBindingContext,
type VLine,
} from '@blocksuite/inline';
import type { Y } from '@blocksuite/store';
import { DocCollection, 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 { z } from 'zod';
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 _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 _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 _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 _onStackItemAdded = (event: { stackItem: RichTextStackItem }) => {
const inlineRange = this.inlineEditor?.getInlineRange();
if (inlineRange) {
event.stackItem.meta.set('richtext-v-range', inlineRange);
}
};
private _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() {
assertExists(this._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 markdownShortcutHandler = this.markdownShortcutHandler;
if (markdownShortcutHandler) {
const keyDownHandler = createInlineKeyDownHandler(inlineEditor, {
inputRule: {
key: [' ', 'Enter'],
handler: context =>
markdownShortcutHandler(context, this.undoManager),
},
});
inlineEditor.disposables.addFromEvent(
this.inlineEventSource ?? this.inlineEditorContainer,
'keydown',
keyDownHandler
);
}
// 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 DocCollection.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>) {
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 markdownShortcutHandler:
| (<TextAttributes extends AffineTextAttributes = AffineTextAttributes>(
context: KeyboardBindingContext<TextAttributes>,
undoManager: Y.UndoManager
) => boolean)
| undefined = undefined;
@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;
}