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,180 @@
import type { DocMode } from '@blocksuite/affine-model';
import { stopPropagation } from '@blocksuite/affine-shared/utils';
import type { BlockStdScope } from '@blocksuite/block-std';
import {
docContext,
modelContext,
ShadowlessElement,
stdContext,
} from '@blocksuite/block-std';
import { WithDisposable } from '@blocksuite/global/utils';
import type { BlockModel, Doc } from '@blocksuite/store';
import { Text } from '@blocksuite/store';
import { consume } from '@lit/context';
import { css, html, nothing } from 'lit';
import { query, state } from 'lit/decorators.js';
import { focusTextModel } from '../rich-text/index.js';
export interface BlockCaptionProps {
caption: string | null | undefined;
}
export class BlockCaptionEditor<
Model extends BlockModel<BlockCaptionProps> = BlockModel<BlockCaptionProps>,
> extends WithDisposable(ShadowlessElement) {
static override styles = css`
.block-caption-editor {
display: inline-table;
resize: none;
width: 100%;
outline: none;
border: 0;
background: transparent;
color: var(--affine-icon-color);
font-size: var(--affine-font-sm);
font-family: inherit;
text-align: center;
field-sizing: content;
padding: 0;
margin-top: 4px;
}
.block-caption-editor::placeholder {
color: var(--affine-placeholder-color);
}
`;
private _focus = false;
show = () => {
this.display = true;
this.updateComplete.then(() => this.input.focus()).catch(console.error);
};
get mode(): DocMode {
return this.doc.getParent(this.model)?.flavour === 'affine:surface'
? 'edgeless'
: 'page';
}
private _onCaptionKeydown(event: KeyboardEvent) {
event.stopPropagation();
if (this.mode === 'edgeless' || event.isComposing) {
return;
}
if (event.key === 'Enter') {
event.preventDefault();
const doc = this.doc;
const target = event.target as HTMLInputElement;
const start = target.selectionStart;
if (start === null) {
return;
}
const model = this.model;
const parent = doc.getParent(model);
if (!parent) {
return;
}
const value = target.value;
const caption = value.slice(0, start);
doc.updateBlock(model, { caption });
const nextBlockText = value.slice(start);
const index = parent.children.indexOf(model);
const id = doc.addBlock(
'affine:paragraph',
{ text: new Text(nextBlockText) },
parent,
index + 1
);
focusTextModel(this.std, id);
}
}
private _onInputBlur() {
this._focus = false;
this.display = !!this.caption?.length;
}
private _onInputChange(e: InputEvent) {
const target = e.target as HTMLInputElement;
this.caption = target.value;
this.doc.updateBlock(this.model, {
caption: this.caption,
});
}
private _onInputFocus() {
this._focus = true;
}
override connectedCallback(): void {
super.connectedCallback();
this.caption = this.model.caption;
this.disposables.add(
this.model.propsUpdated.on(({ key }) => {
if (key === 'caption') {
this.caption = this.model.caption;
if (!this._focus) {
this.display = !!this.caption?.length;
}
}
})
);
}
override render() {
if (!this.display && !this.caption) {
return nothing;
}
return html`<textarea
.disabled=${this.doc.readonly}
placeholder="Write a caption"
class="block-caption-editor"
.value=${this.caption ?? ''}
@input=${this._onInputChange}
@focus=${this._onInputFocus}
@blur=${this._onInputBlur}
@pointerdown=${stopPropagation}
@click=${stopPropagation}
@dblclick=${stopPropagation}
@cut=${stopPropagation}
@copy=${stopPropagation}
@paste=${stopPropagation}
@keydown=${this._onCaptionKeydown}
@keyup=${stopPropagation}
></textarea>`;
}
@state()
accessor caption: string | null | undefined = undefined;
@state()
accessor display = false;
@consume({ context: docContext })
accessor doc!: Doc;
@query('.block-caption-editor')
accessor input!: HTMLInputElement;
@consume({ context: modelContext })
accessor model!: Model;
@consume({ context: stdContext })
accessor std!: BlockStdScope;
}
declare global {
interface HTMLElementTagNameMap {
'block-caption-editor': BlockCaptionEditor;
}
}
@@ -0,0 +1,80 @@
import { ThemeProvider } from '@blocksuite/affine-shared/services';
import { BlockComponent, type BlockService } from '@blocksuite/block-std';
import type { BlockModel } from '@blocksuite/store';
import { html, nothing } from 'lit';
import { classMap } from 'lit/directives/class-map.js';
import { createRef, type Ref, ref } from 'lit/directives/ref.js';
import { type StyleInfo, styleMap } from 'lit/directives/style-map.js';
import type { BlockCaptionEditor } from './block-caption.js';
import { styles } from './styles.js';
export enum SelectedStyle {
Background = 'Background',
Border = 'Border',
}
export class CaptionedBlockComponent<
Model extends BlockModel = BlockModel,
Service extends BlockService = BlockService,
WidgetName extends string = string,
> extends BlockComponent<Model, Service, WidgetName> {
static override styles = styles;
get captionEditor() {
if (!this.useCaptionEditor || !this._captionEditorRef.value) {
console.error(
'Oops! Please enable useCaptionEditor before accessing captionEditor'
);
}
return this._captionEditorRef.value;
}
constructor() {
super();
this.addRenderer(this._renderWithWidget);
}
private _renderWithWidget(content: unknown) {
const style = styleMap({
position: 'relative',
...this.blockContainerStyles,
});
const theme = this.std.get(ThemeProvider).theme;
const isBorder = this.selectedStyle === SelectedStyle.Border;
return html`<div
style=${style}
class=${classMap({
'affine-block-component': true,
[theme]: true,
border: isBorder,
})}
>
${content}
${this.useCaptionEditor
? html`<block-caption-editor
${ref(this._captionEditorRef)}
></block-caption-editor>`
: nothing}
${this.selectedStyle === SelectedStyle.Background
? html`<affine-block-selection .block=${this}></affine-block-selection>`
: null}
${this.useZeroWidth && !this.doc.readonly
? html`<block-zero-width .block=${this}></block-zero-width>`
: nothing}
</div>`;
}
// There may be multiple block-caption-editors in a nested structure.
private accessor _captionEditorRef: Ref<BlockCaptionEditor> =
createRef<BlockCaptionEditor>();
protected accessor blockContainerStyles: StyleInfo | undefined = undefined;
protected accessor selectedStyle = SelectedStyle.Background;
protected accessor useCaptionEditor = false;
protected accessor useZeroWidth = false;
}
@@ -0,0 +1,10 @@
import { BlockCaptionEditor } from './block-caption.js';
export { BlockCaptionEditor, type BlockCaptionProps } from './block-caption.js';
export {
CaptionedBlockComponent,
SelectedStyle,
} from './captioned-block-component.js';
export function effects() {
customElements.define('block-caption-editor', BlockCaptionEditor);
}
@@ -0,0 +1,18 @@
import { css } from 'lit';
export const styles = css`
.affine-block-component.border.light .selected-style {
border-radius: 8px;
box-shadow: 0px 0px 0px 1px var(--affine-brand-color);
}
.affine-block-component.border.dark .selected-style {
border-radius: 8px;
box-shadow: 0px 0px 0px 1px var(--affine-brand-color);
}
@media print {
.affine-block-component.border.light .selected-style,
.affine-block-component.border.dark .selected-style {
box-shadow: none;
}
}
`;
@@ -0,0 +1,282 @@
import { IS_MOBILE } from '@blocksuite/global/env';
import {
CheckBoxCkeckSolidIcon,
CheckBoxUnIcon,
DoneIcon,
} from '@blocksuite/icons/lit';
import type { ReadonlySignal } from '@preact/signals-core';
import { cssVarV2 } from '@toeverything/theme/v2';
import { css, html, type TemplateResult, unsafeCSS } from 'lit';
import { property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { keyed } from 'lit/directives/keyed.js';
import type { ClassInfo } from 'lit-html/directives/class-map.js';
import { MenuFocusable } from './focusable.js';
import type { Menu } from './menu.js';
import type { MenuClass, MenuItemRender } from './types.js';
export type MenuButtonData = {
content: () => TemplateResult;
class: ClassInfo;
select: (ele: HTMLElement) => void | false;
onHover?: (hover: boolean) => void;
};
export class MenuButton extends MenuFocusable {
static override styles = css`
.affine-menu-button {
display: flex;
width: 100%;
font-size: 20px;
cursor: pointer;
align-items: center;
padding: 4px;
gap: 8px;
border-radius: 4px;
color: var(--affine-icon-color);
}
.affine-menu-button:hover,
affine-menu-button.active .affine-menu-button {
background-color: var(--affine-hover-color);
}
.affine-menu-button .affine-menu-action-text {
flex: 1;
font-size: 14px;
line-height: 22px;
color: var(--affine-text-primary-color);
}
.affine-menu-button.focused {
outline: 1px solid ${unsafeCSS(cssVarV2.layer.insideBorder.primaryBorder)};
}
.affine-menu-button.delete-item:hover {
background-color: var(--affine-background-error-color);
color: var(--affine-error-color);
}
.affine-menu-button.delete-item:hover .affine-menu-action-text {
color: var(--affine-error-color);
}
`;
override connectedCallback() {
super.connectedCallback();
this.disposables.addFromEvent(this, 'mouseenter', () => {
this.data.onHover?.(true);
this.menu.closeSubMenu();
});
this.disposables.addFromEvent(this, 'mouseleave', () => {
this.data.onHover?.(false);
});
this.disposables.addFromEvent(this, 'click', this.onClick);
}
override disconnectedCallback() {
super.disconnectedCallback();
this.data.onHover?.(false);
}
onClick() {
if (this.data.select(this) !== false) {
this.menu.options.onComplete?.();
this.menu.close();
}
}
override onPressEnter() {
this.onClick();
}
protected override render(): unknown {
const classString = classMap({
'affine-menu-button': true,
focused: this.isFocused$.value,
...this.data.class,
});
return html` <div class="${classString}">${this.data.content()}</div>`;
}
@property({ attribute: false })
accessor data!: MenuButtonData;
}
export class MobileMenuButton extends MenuFocusable {
static override styles = css`
.mobile-menu-button {
display: flex;
width: 100%;
cursor: pointer;
align-items: center;
font-size: 20px;
padding: 11px 8px;
gap: 8px;
border-radius: 4px;
color: var(--affine-icon-color);
}
.mobile-menu-button .affine-menu-action-text {
flex: 1;
color: var(--affine-text-primary-color);
font-size: 17px;
line-height: 22px;
}
.mobile-menu-button.delete-item {
color: var(--affine-error-color);
}
.mobile-menu-button.delete-item .mobile-menu-action-text {
color: var(--affine-error-color);
}
`;
override connectedCallback() {
super.connectedCallback();
this.disposables.addFromEvent(this, 'click', this.onClick);
}
// eslint-disable-next-line sonarjs/no-identical-functions
onClick() {
if (this.data.select(this) !== false) {
this.menu.options.onComplete?.();
this.menu.close();
}
}
override onPressEnter() {
this.onClick();
}
protected override render(): unknown {
const classString = classMap({
'mobile-menu-button': true,
focused: this.isFocused$.value,
...this.data.class,
});
return html` <div class="${classString}">${this.data.content()}</div>`;
}
@property({ attribute: false })
accessor data!: MenuButtonData;
}
const renderButton = (data: MenuButtonData, menu: Menu) => {
if (IS_MOBILE) {
return html`<mobile-menu-button
.data="${data}"
.menu="${menu}"
></mobile-menu-button>`;
}
return html`<affine-menu-button
.data="${data}"
.menu="${menu}"
></affine-menu-button>`;
};
export const menuButtonItems = {
action:
(config: {
name: string;
label?: () => TemplateResult;
prefix?: TemplateResult;
postfix?: TemplateResult;
isSelected?: boolean;
select: (ele: HTMLElement) => void | false;
onHover?: (hover: boolean) => void;
class?: MenuClass;
hide?: () => boolean;
}) =>
menu => {
if (config.hide?.() || !menu.search(config.name)) {
return;
}
const data: MenuButtonData = {
content: () => {
return html`
${config.prefix}
<div class="affine-menu-action-text">
${config.label?.() ?? config.name}
</div>
${config.postfix ?? (config.isSelected ? DoneIcon() : undefined)}
`;
},
onHover: config.onHover,
select: config.select,
class: {
'selected-item': config.isSelected ?? false,
...config.class,
},
};
return renderButton(data, menu);
},
checkbox:
(config: {
name: string;
checked: ReadonlySignal<boolean>;
postfix?: TemplateResult;
label?: () => TemplateResult;
select: (checked: boolean) => boolean;
class?: ClassInfo;
}) =>
menu => {
if (!menu.search(config.name)) {
return;
}
const data: MenuButtonData = {
content: () => html`
${config.checked.value
? CheckBoxCkeckSolidIcon({ style: `color:#1E96EB` })
: CheckBoxUnIcon()}
<div class="affine-menu-action-text">
${config.label?.() ?? config.name}
</div>
${config.postfix}
`,
select: () => {
config.select(config.checked.value);
return false;
},
class: config.class ?? {},
};
return html`${keyed(config.name, renderButton(data, menu))}`;
},
toggleSwitch:
(config: {
name: string;
on: boolean;
postfix?: TemplateResult;
label?: () => TemplateResult;
onChange: (on: boolean) => void;
class?: ClassInfo;
}) =>
menu => {
if (!menu.search(config.name)) {
return;
}
const onChange = (on: boolean) => {
config.onChange(on);
};
const data: MenuButtonData = {
content: () => html`
<div class="affine-menu-action-text">
${config.label?.() ?? config.name}
</div>
<toggle-switch
.on="${config.on}"
.onChange="${onChange}"
></toggle-switch>
${config.postfix}
`,
select: () => {
config.onChange(config.on);
return false;
},
class: config.class ?? {},
};
return html`${keyed(config.name, renderButton(data, menu))}`;
},
} satisfies Record<string, MenuItemRender<never>>;
@@ -0,0 +1,15 @@
import { html, type TemplateResult } from 'lit';
import type { MenuConfig } from './menu.js';
import type { MenuItemRender } from './types.js';
export const menuDynamicItems = {
dynamic: (config: () => MenuConfig[]) => menu => {
const items = menu.renderItems(config());
if (!items.length) {
return;
}
const result: TemplateResult = html`${items}`;
return result;
},
} satisfies Record<string, MenuItemRender<never>>;
@@ -0,0 +1,18 @@
import { computed } from '@preact/signals-core';
import { MenuItem } from './item.js';
export abstract class MenuFocusable extends MenuItem {
isFocused$ = computed(() => this.menu.currentFocused$.value === this);
override connectedCallback() {
super.connectedCallback();
this.dataset.focusable = 'true';
}
override focus() {
this.menu.focusTo(this);
}
abstract onPressEnter(): void;
}
@@ -0,0 +1,35 @@
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { IS_MOBILE } from '@blocksuite/global/env';
import { html, type TemplateResult } from 'lit';
import type { MenuConfig } from './menu.js';
import type { MenuItemRender } from './types.js';
export const menuGroupItems = {
group: (config: { name?: string; items: MenuConfig[] }) => (menu, index) => {
const items = menu.renderItems(config.items);
if (!items.length) {
return;
}
if (IS_MOBILE) {
return html` <div
style="
display: flex;
flex-direction: column;
background-color: ${unsafeCSSVarV2('layer/background/primary')};
padding: 4px;
border-radius: 12px;
"
>
${items}
</div>`;
}
const result: TemplateResult = html` ${index === 0
? ''
: html` <div
style="height: 0.5px;background-color: var(--affine-divider-color);margin: 4px 0"
></div>`}
<div style="display: flex;flex-direction: column;gap:4px">${items}</div>`;
return result;
},
} satisfies Record<string, MenuItemRender<never>>;
@@ -0,0 +1,26 @@
import { MenuButton, MobileMenuButton } from './button.js';
import { MenuInput, MobileMenuInput } from './input.js';
import { MenuComponent, MobileMenuComponent } from './menu-renderer.js';
import { MenuSubMenu, MobileSubMenu } from './sub-menu.js';
export * from './button.js';
export * from './focusable.js';
export * from './group.js';
export * from './input.js';
export * from './item.js';
export * from './menu.js';
export * from './menu-renderer.js';
export * from './sub-menu.js';
export function effects() {
customElements.define('affine-menu', MenuComponent);
customElements.define('mobile-menu', MobileMenuComponent);
customElements.define('affine-menu-button', MenuButton);
customElements.define('mobile-menu-button', MobileMenuButton);
customElements.define('affine-menu-input', MenuInput);
customElements.define('mobile-menu-input', MobileMenuInput);
customElements.define('affine-menu-sub-menu', MenuSubMenu);
customElements.define('mobile-sub-menu', MobileSubMenu);
}
export * from './types.js';
@@ -0,0 +1,252 @@
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { IS_MOBILE } from '@blocksuite/global/env';
import { cssVarV2 } from '@toeverything/theme/v2';
import { css, html, type TemplateResult } from 'lit';
import { property, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { styleMap } from 'lit/directives/style-map.js';
import type { StyleInfo } from 'lit-html/directives/style-map.js';
import { MenuFocusable } from './focusable.js';
import type { Menu } from './menu.js';
import type { MenuItemRender } from './types.js';
export type MenuInputData = {
placeholder?: string;
initialValue?: string;
class?: string;
onComplete?: (value: string) => void;
onChange?: (value: string) => void;
disableAutoFocus?: boolean;
};
export class MenuInput extends MenuFocusable {
static override styles = css`
.affine-menu-input {
flex: 1;
outline: none;
border-radius: 4px;
font-size: 14px;
line-height: 22px;
padding: 4px 6px;
border: 1px solid var(--affine-border-color);
width: 100%;
color: ${unsafeCSSVarV2('text/primary')};
background-color: transparent;
}
.affine-menu-input.focused {
border: 1px solid ${unsafeCSSVarV2('layer/insideBorder/primaryBorder')};
}
.affine-menu-input:focus {
border: 1px solid ${unsafeCSSVarV2('layer/insideBorder/primaryBorder')};
box-shadow: 0px 0px 0px 2px rgba(28, 158, 228, 0.3);
}
`;
private onCompositionEnd = () => {
this.data.onChange?.(this.inputRef.value);
};
private onInput = (e: InputEvent) => {
e.stopPropagation();
if (e.isComposing) return;
this.data.onChange?.(this.inputRef.value);
};
private onKeydown = (e: KeyboardEvent) => {
e.stopPropagation();
if (e.isComposing) return;
if (e.key === 'Escape') {
this.complete();
this.inputRef.blur();
this.menu.focusTo(this);
return;
}
if (e.key === 'Enter') {
this.complete();
this.menu.close();
return;
}
};
private stopPropagation = (e: Event) => {
e.stopPropagation();
};
complete() {
this.data.onComplete?.(this.inputRef.value);
}
override connectedCallback() {
super.connectedCallback();
this.disposables.addFromEvent(this, 'click', e => {
e.stopPropagation();
});
this.disposables.addFromEvent(this, 'mouseenter', () => {
this.menu.closeSubMenu();
});
requestAnimationFrame(() => {
requestAnimationFrame(() => {
this.inputRef.select();
});
});
}
override onPressEnter() {
this.inputRef.focus();
}
protected override render(): unknown {
const classString = classMap({
[this.data.class ?? '']: true,
'affine-menu-input': true,
focused: this.isFocused$.value,
});
return html`<input
@focus="${() => {
this.menu.setFocusOnly(this);
}}"
@input="${this.onInput}"
@keydown="${this.onKeydown}"
@copy="${this.stopPropagation}"
@paste="${this.stopPropagation}"
@compositionend="${this.onCompositionEnd}"
class="${classString}"
value="${this.data.initialValue ?? ''}"
type="text"
/>`;
}
@property({ attribute: false })
accessor data!: MenuInputData;
@query('input')
accessor inputRef!: HTMLInputElement;
}
export class MobileMenuInput extends MenuFocusable {
static override styles = css`
.mobile-menu-input {
flex: 1;
outline: none;
font-size: 17px;
line-height: 22px;
border: none;
width: 100%;
color: ${unsafeCSSVarV2('text/primary')};
}
`;
private onCompositionEnd = () => {
this.data.onChange?.(this.inputRef.value);
};
private onInput = (e: InputEvent) => {
e.stopPropagation();
if (e.isComposing) return;
this.data.onChange?.(this.inputRef.value);
};
private stopPropagation = (e: Event) => {
e.stopPropagation();
};
complete() {
this.data.onComplete?.(this.inputRef.value);
}
override onPressEnter() {
this.inputRef.focus();
}
protected override render(): unknown {
const classString = classMap({
[this.data.class ?? '']: true,
'mobile-menu-input': true,
focused: this.isFocused$.value,
});
return html`<input
@focus="${() => {
this.menu.setFocusOnly(this);
}}"
@input="${this.onInput}"
@copy="${this.stopPropagation}"
@paste="${this.stopPropagation}"
@compositionend="${this.onCompositionEnd}"
class="${classString}"
value="${this.data.initialValue ?? ''}"
type="text"
/>`;
}
@property({ attribute: false })
accessor data!: MenuInputData;
@query('input')
accessor inputRef!: HTMLInputElement;
}
const renderInput = (data: MenuInputData, menu: Menu) => {
if (IS_MOBILE) {
return html` <mobile-menu-input
style="flex:1"
.data="${data}"
.menu="${menu}"
></mobile-menu-input>`;
}
return html` <affine-menu-input
style="flex:1"
.data="${data}"
.menu="${menu}"
></affine-menu-input>`;
};
export const menuInputItems = {
input:
(config: {
placeholder?: string;
initialValue?: string;
postfix?: TemplateResult;
prefix?: TemplateResult;
onComplete?: (value: string) => void;
onChange?: (value: string) => void;
class?: string;
style?: Readonly<StyleInfo>;
}) =>
menu => {
if (menu.showSearch$.value) {
return;
}
const data: MenuInputData = {
placeholder: config.placeholder,
initialValue: config.initialValue,
class: config.class,
onComplete: config.onComplete,
onChange: config.onChange,
};
const style = styleMap({
display: 'flex',
alignItems: 'center',
...(IS_MOBILE
? {
borderRadius: '12px',
backgroundColor: cssVarV2('layer/background/primary'),
padding: '12px',
gap: '8px',
}
: {
marginBottom: '8px',
gap: '4px',
}),
...config.style,
});
return html`
<div style="${style}">
${config.prefix} ${renderInput(data, menu)} ${config.postfix}
</div>
`;
},
} satisfies Record<string, MenuItemRender<never>>;
@@ -0,0 +1,12 @@
import { ShadowlessElement } from '@blocksuite/block-std';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import { property } from 'lit/decorators.js';
import type { Menu } from './menu.js';
export abstract class MenuItem extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
@property({ attribute: false })
accessor menu!: Menu;
}
@@ -0,0 +1,571 @@
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { ShadowlessElement } from '@blocksuite/block-std';
import { IS_MOBILE } from '@blocksuite/global/env';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import {
ArrowLeftBigIcon,
ArrowLeftSmallIcon,
CloseIcon,
SearchIcon,
} from '@blocksuite/icons/lit';
import {
autoPlacement,
autoUpdate,
computePosition,
type Middleware,
offset,
type ReferenceElement,
shift,
} from '@floating-ui/dom';
import { css, html, nothing } from 'lit';
import { property } from 'lit/decorators.js';
import { createRef, ref } from 'lit/directives/ref.js';
import { styleMap } from 'lit/directives/style-map.js';
import type { MenuFocusable } from './focusable.js';
import { Menu, type MenuConfig, type MenuOptions } from './menu.js';
import type { MenuComponentInterface } from './types.js';
export class MenuComponent
extends SignalWatcher(WithDisposable(ShadowlessElement))
implements MenuComponentInterface
{
static override styles = css`
affine-menu {
font-family: var(--affine-font-family);
display: flex;
flex-direction: column;
user-select: none;
min-width: 276px;
box-shadow: ${unsafeCSSVar('overlayPanelShadow')};
border-radius: 4px;
background-color: ${unsafeCSSVarV2('layer/background/overlayPanel')};
padding: 8px;
position: absolute;
z-index: 999;
gap: 8px;
border: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
color: ${unsafeCSSVarV2('text/primary')};
}
.affine-menu-search-container {
border-radius: 4px;
display: flex;
align-items: center;
padding: 4px 10px;
gap: 8px;
border: 1px solid ${unsafeCSSVarV2('input/border/default')};
}
.affine-menu-search {
flex: 1;
outline: none;
font-size: 14px;
line-height: 22px;
border: none;
background-color: transparent;
}
.affine-menu-body {
display: flex;
flex-direction: column;
gap: 4px;
}
.no-results {
font-size: 12px;
line-height: 20px;
color: var(--affine-text-secondary-color);
display: flex;
align-items: center;
justify-content: center;
margin-top: 8px;
}
`;
private _clickContainer = (e: MouseEvent) => {
e.stopPropagation();
this.focusInput();
this.menu.closeSubMenu();
};
private searchRef = createRef<HTMLInputElement>();
override firstUpdated() {
const input = this.searchRef.value;
if (input) {
requestAnimationFrame(() => {
this.focusInput();
});
const length = input.value.length;
input.setSelectionRange(length, length);
this.disposables.addFromEvent(input, 'keydown', e => {
e.stopPropagation();
if (e.key === 'Escape') {
this.menu.close();
return;
}
const onBack = this.menu.options.title?.onBack;
if (e.key === 'Backspace' && onBack && !this.menu.showSearch$.value) {
this.menu.close();
onBack(this.menu);
return;
}
if (e.key === 'Enter' && !e.isComposing) {
this.menu.pressEnter();
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
this.menu.focusPrev();
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
this.menu.focusNext();
return;
}
});
this.disposables.addFromEvent(input, 'copy', e => {
e.stopPropagation();
});
this.disposables.addFromEvent(input, 'cut', e => {
e.stopPropagation();
});
this.disposables.addFromEvent(this, 'click', this._clickContainer);
}
}
focusInput() {
this.searchRef.value?.focus();
}
focusTo(ele?: MenuFocusable) {
this.menu.setFocusOnly(ele);
this.focusInput();
}
getFirstFocusableElement(): HTMLElement | null {
return this.querySelector('[data-focusable="true"]');
}
getFocusableElements(): HTMLElement[] {
return Array.from(this.querySelectorAll('[data-focusable="true"]'));
}
override render() {
const result = this.menu.renderItems(this.menu.options.items);
return html`
${this.renderTitle()} ${this.renderSearch()}
<div class="affine-menu-body">
${result.length === 0 && this.menu.enableSearch
? html` <div class="no-results">No Results</div>`
: ''}
${result}
</div>
`;
}
renderSearch() {
const config = this.menu.options.search;
const showSearch = this.menu.showSearch$.value || config?.placeholder;
const searchStyle = styleMap({
opacity: showSearch ? '1' : '0',
height: showSearch ? undefined : '0',
overflow: showSearch ? undefined : 'hidden',
position: showSearch ? undefined : 'absolute',
pointerEvents: showSearch ? undefined : 'none',
});
return html` <div style=${searchStyle} class="affine-menu-search-container">
<div
style="font-size:20px;display:flex;align-items:center;color: var(--affine-text-secondary-color)"
>
${SearchIcon()}
</div>
<input
autocomplete="off"
class="affine-menu-search"
placeholder="${config?.placeholder ?? ''}"
data-1p-ignore
${ref(this.searchRef)}
type="text"
value="${this.menu.searchName$.value}"
@input="${(e: Event) =>
(this.menu.searchName$.value = (e.target as HTMLInputElement).value)}"
/>
</div>`;
}
renderTitle() {
const title = this.menu.options.title;
if (!title) {
return;
}
return html`
<div
style="display:flex;align-items:center;gap: 4px;min-width: 300px;padding:3px 4px 3px 2px"
@mouseenter="${() => this.menu.closeSubMenu()}"
>
${title.onBack
? html` <div
@click="${() => {
title.onBack?.(this.menu);
this.menu.close();
}}"
class="dv-icon-20 dv-hover dv-pd-2 dv-round-4"
style="display:flex;"
>
${ArrowLeftBigIcon()}
</div>`
: nothing}
<div
style="flex:1;font-weight:500;font-size: 14px;line-height: 22px;color: var(--affine-text-primary-color)"
>
${title.text}
</div>
${title.postfix?.()}
${title.onClose
? html` <div
@click="${title.onClose}"
class="dv-icon-20 dv-hover dv-pd-2 dv-round-4"
style="display:flex;"
>
${CloseIcon()}
</div>`
: nothing}
</div>
`;
}
@property({ attribute: false })
accessor menu!: Menu;
}
declare global {
interface HTMLElementTagNameMap {
'affine-menu': MenuComponent;
}
}
export class MobileMenuComponent
extends SignalWatcher(WithDisposable(ShadowlessElement))
implements MenuComponentInterface
{
static override styles = css`
mobile-menu {
height: 100%;
font-family: var(--affine-font-family);
display: flex;
flex-direction: column;
user-select: none;
width: 100%;
background-color: ${unsafeCSSVarV2('layer/background/secondary')};
padding: calc(8px + env(safe-area-inset-top, 0px)) 8px
calc(8px + env(safe-area-inset-bottom, 0px)) 8px;
position: absolute;
z-index: 999;
color: ${unsafeCSSVarV2('text/primary')};
}
.mobile-menu-body {
display: flex;
flex-direction: column;
padding: 24px 16px;
gap: 16px;
flex: 1;
overflow-y: auto;
}
`;
onClose = () => {
const close = this.menu.options.title?.onClose;
if (close) {
close();
} else {
this.menu.close();
}
};
focusTo(ele?: MenuFocusable) {
this.menu.setFocusOnly(ele);
}
getFirstFocusableElement(): HTMLElement | null {
return this.querySelector('[data-focusable="true"]');
}
getFocusableElements(): HTMLElement[] {
return Array.from(this.querySelectorAll('[data-focusable="true"]'));
}
override render() {
const result = this.menu.renderItems(this.menu.options.items);
return html`
${this.renderTitle()}
<div class="mobile-menu-body">${result}</div>
`;
}
renderTitle() {
const title = this.menu.options.title;
return html`
<div
style="display:flex;align-items:center;height: 44px;"
@mouseenter="${() => this.menu.closeSubMenu()}"
>
<div style="width: 50px;flex-shrink: 0;margin-left: 10px;">
${title?.onBack
? html` <div
@click="${() => {
title.onBack?.(this.menu);
this.menu.close();
}}"
style="
display:flex;
font-size: 24px;
align-items:center;
"
>
${ArrowLeftSmallIcon()}
</div>`
: nothing}
</div>
<div
style="
flex:1;
font-size: 17px;
font-style: normal;
font-weight: 500;
line-height: 22px;
color: var(--affine-text-primary-color);
display: flex;
justify-content: center;
"
>
${title?.text}
</div>
<div
@click="${this.onClose}"
style="
display:flex;
font-weight: 500;
font-size: 17px;
color: ${unsafeCSSVarV2('button/primary')};
width: 50px;
flex-shrink: 0;
margin-right: 10px;
"
>
Done
</div>
</div>
`;
}
@property({ attribute: false })
accessor menu!: Menu;
}
declare global {
interface HTMLElementTagNameMap {
'affine-menu-mobile': MobileMenuComponent;
}
}
export const getDefaultModalRoot = (ele: HTMLElement) => {
const host: HTMLElement | null =
ele.closest('editor-host') ?? ele.closest('.data-view-popup-container');
if (host) {
return host;
}
return document.body;
};
export const createModal = (container: HTMLElement = document.body) => {
const div = document.createElement('div');
div.style.pointerEvents = 'auto';
div.style.position = 'absolute';
div.style.left = '0';
div.style.top = '0';
div.style.width = '100%';
div.style.height = '100%';
div.style.zIndex = '1001';
div.style.fontFamily = 'var(--affine-font-family)';
container.append(div);
return div;
};
export type PopupTarget = {
targetRect: ReferenceElement;
root: HTMLElement;
popupStart: () => () => void;
};
export const popupTargetFromElement = (element: HTMLElement): PopupTarget => {
let rect = element.getBoundingClientRect();
let count = 0;
let isActive = false;
return {
targetRect: {
getBoundingClientRect: () => {
if (element.isConnected) {
return (rect = element.getBoundingClientRect());
}
return rect;
},
},
root: getDefaultModalRoot(element),
popupStart: () => {
if (!count) {
isActive = element.classList.contains('active');
if (!isActive) {
element.classList.add('active');
}
}
count++;
return () => {
count--;
if (!count && !isActive) {
element.classList.remove('active');
}
};
},
};
};
export const createPopup = (
target: PopupTarget,
content: HTMLElement,
options?: {
onClose?: () => void;
middleware?: Array<Middleware | null | undefined | false>;
container?: HTMLElement;
}
) => {
const close = () => {
modal.remove();
options?.onClose?.();
};
const modal = createModal(target.root);
autoUpdate(target.targetRect, content, () => {
computePosition(target.targetRect, content, {
middleware: options?.middleware ?? [shift({ crossAxis: true })],
})
.then(({ x, y }) => {
Object.assign(content.style, {
left: `${x}px`,
top: `${y}px`,
});
})
.catch(console.error);
});
modal.append(content);
modal.onpointerdown = ev => {
if (ev.target === modal) {
close();
}
};
modal.onmousedown = ev => {
if (ev.target === modal) {
close();
}
};
modal.oncontextmenu = ev => {
ev.preventDefault();
if (ev.target === modal) {
close();
}
};
return close;
};
export type MenuHandler = {
close: () => void;
menu: Menu;
reopen: () => void;
};
const popMobileMenu = (options: MenuOptions): MenuHandler => {
const model = createModal(document.body);
const menu = new Menu({
...options,
onClose: () => {
closePopup();
},
});
model.append(menu.menuElement);
const closePopup = () => {
model.remove();
options.onClose?.();
};
return {
close: () => {
menu.close();
},
menu,
reopen: () => {
menu.close();
popMobileMenu(options);
},
};
};
export const popMenu = (
target: PopupTarget,
props: {
options: MenuOptions;
middleware?: Array<Middleware | null | undefined | false>;
container?: HTMLElement;
}
): MenuHandler => {
if (IS_MOBILE) {
return popMobileMenu(props.options);
}
const popupEnd = target.popupStart();
const onClose = () => {
props.options.onClose?.();
popupEnd();
closePopup();
};
const menu = new Menu({
...props.options,
onClose: onClose,
});
const closePopup = createPopup(target, menu.menuElement, {
onClose: () => {
menu.close();
},
middleware: props.middleware ?? [
autoPlacement({
allowedPlacements: [
'bottom-start',
'bottom-end',
'top-start',
'top-end',
],
}),
offset(4),
],
container: props.container,
});
return {
close: closePopup,
menu,
reopen: () => {
popMenu(target, props);
},
};
};
export const popFilterableSimpleMenu = (
target: PopupTarget,
options: MenuConfig[],
onClose?: () => void
) => {
popMenu(target, {
options: {
items: options,
onClose,
},
});
};
@@ -0,0 +1,180 @@
import { IS_MOBILE } from '@blocksuite/global/env';
import { computed, signal } from '@preact/signals-core';
import type { TemplateResult } from 'lit';
import { menuButtonItems } from './button.js';
import { menuDynamicItems } from './dynamic.js';
import { MenuFocusable } from './focusable.js';
import { menuGroupItems } from './group.js';
import { menuInputItems } from './input.js';
// eslint-disable-next-line
import { MenuComponent, MobileMenuComponent } from './menu-renderer.js';
import { subMenuItems } from './sub-menu.js';
import type { MenuComponentInterface, MenuItemRender } from './types.js';
export const menu = {
...menuButtonItems,
...subMenuItems,
...menuInputItems,
...menuGroupItems,
...menuDynamicItems,
} satisfies Record<string, MenuItemRender<never>>;
export type MenuConfig = (
menu: Menu,
index: number
) => TemplateResult | undefined;
export type MenuOptions = {
onComplete?: () => void;
onClose?: () => void;
title?: {
text: string;
onBack?: (menu: Menu) => void;
onClose?: () => void;
postfix?: () => TemplateResult;
};
search?: {
placeholder?: string;
};
items: MenuConfig[];
};
// Global menu open listener type
type MenuOpenListener = (menu: Menu) => (() => void) | void;
// Global menu open listeners
const menuOpenListeners = new Set<MenuOpenListener>();
// Add global menu open listener
export function onMenuOpen(listener: MenuOpenListener) {
menuOpenListeners.add(listener);
// Return cleanup function
return () => {
menuOpenListeners.delete(listener);
};
}
export class Menu {
private _cleanupFns: Array<() => void> = [];
private _currentFocused$ = signal<MenuFocusable>();
private _subMenu$ = signal<Menu>();
closed = false;
readonly currentFocused$ = computed(() => this._currentFocused$.value);
menuElement: MenuComponentInterface;
searchName$ = signal('');
searchResult$ = computed(() => {
return this.renderItems(this.options.items);
});
showSearch$ = computed(() => {
return this.enableSearch && this.searchName$.value.length > 0;
});
get enableSearch() {
return true;
}
constructor(public options: MenuOptions) {
this.menuElement = IS_MOBILE
? new MobileMenuComponent()
: new MenuComponent();
this.menuElement.menu = this;
// Call global menu open listeners
menuOpenListeners.forEach(listener => {
const cleanup = listener(this);
if (cleanup) {
this._cleanupFns.push(cleanup);
}
});
}
close() {
if (this.closed) {
return;
}
this.closed = true;
// Execute cleanup functions
this._cleanupFns.forEach(cleanup => cleanup());
this._cleanupFns = [];
this.menuElement.remove();
this.options.onClose?.();
}
closeSubMenu() {
this._subMenu$.value?.close();
this._subMenu$.value = undefined;
}
focusNext() {
if (!this._currentFocused$.value) {
const ele = this.menuElement.getFirstFocusableElement();
if (ele instanceof MenuFocusable) {
ele.focus();
}
return;
}
const list = this.menuElement
.getFocusableElements()
.filter(ele => ele instanceof MenuFocusable);
const index = list.indexOf(this._currentFocused$.value);
list[index + 1]?.focus();
}
focusPrev() {
if (!this._currentFocused$.value) {
return;
}
const list = this.menuElement
.getFocusableElements()
.filter(ele => ele instanceof MenuFocusable);
const index = list.indexOf(this._currentFocused$.value);
if (index === 0) {
this._currentFocused$.value = undefined;
return;
}
list[index - 1]?.focus();
}
focusTo(ele?: MenuFocusable) {
this.menuElement.focusTo(ele);
}
openSubMenu(menu: Menu) {
this.closeSubMenu();
this._subMenu$.value = menu;
}
pressEnter() {
this._currentFocused$.value?.onPressEnter();
}
renderItems(items: MenuConfig[]) {
const result = [];
// eslint-disable-next-line @typescript-eslint/prefer-for-of
for (let i = 0; i < items.length; i++) {
const item = items[i];
const template = item(this, result.length);
if (template != null) {
result.push(template);
}
}
return result;
}
search(name: string) {
return name.toLowerCase().includes(this.searchName$.value.toLowerCase());
}
setFocusOnly(ele?: MenuFocusable) {
this._currentFocused$.value = ele;
}
}
@@ -0,0 +1,194 @@
import { IS_MOBILE } from '@blocksuite/global/env';
import { ArrowRightSmallIcon } from '@blocksuite/icons/lit';
import {
autoPlacement,
autoUpdate,
computePosition,
offset,
} from '@floating-ui/dom';
import { html, nothing, type TemplateResult } from 'lit';
import { property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { MenuFocusable } from './focusable.js';
import { Menu, type MenuOptions } from './menu.js';
import { popMenu, popupTargetFromElement } from './menu-renderer.js';
import type { MenuItemRender } from './types.js';
export type MenuSubMenuData = {
content: () => TemplateResult;
options: MenuOptions;
select?: () => void;
class?: string;
};
export const subMenuOffset = offset({
mainAxis: 16,
crossAxis: -8.5,
});
export const subMenuPlacements = autoPlacement({
allowedPlacements: ['right-start', 'left-start', 'right-end', 'left-end'],
});
export const subMenuMiddleware = [subMenuOffset, subMenuPlacements];
export class MenuSubMenu extends MenuFocusable {
createTime = 0;
override connectedCallback() {
super.connectedCallback();
this.createTime = Date.now();
this.disposables.addFromEvent(this, 'mouseenter', this.onMouseEnter);
this.disposables.addFromEvent(this, 'click', e => {
e.preventDefault();
e.stopPropagation();
if (this.data.select) {
this.data.select();
this.menu.close();
} else {
this.openSubMenu();
}
});
}
onMouseEnter() {
if (Date.now() - this.createTime > 100) {
this.openSubMenu();
}
}
override onPressEnter() {
this.onMouseEnter();
}
openSubMenu() {
const focus = this.menu.currentFocused$.value;
const menu = new Menu({
...this.data.options,
onComplete: () => {
this.menu.close();
},
onClose: () => {
menu.menuElement.remove();
this.menu.focusTo(focus);
this.data.options.onClose?.();
unsub();
},
});
this.menu.menuElement.parentElement?.append(menu.menuElement);
const unsub = autoUpdate(this, menu.menuElement, () => {
computePosition(this, menu.menuElement, {
middleware: subMenuMiddleware,
})
.then(({ x, y }) => {
menu.menuElement.style.left = `${x}px`;
menu.menuElement.style.top = `${y}px`;
})
.catch(err => console.error(err));
});
this.menu.openSubMenu(menu);
}
protected override render(): unknown {
const classString = classMap({
[this.data.class ?? '']: true,
'affine-menu-button': true,
focused: this.isFocused$.value,
});
return html` <div class="${classString}">${this.data.content()}</div>`;
}
@property({ attribute: false })
accessor data!: MenuSubMenuData;
}
export class MobileSubMenu extends MenuFocusable {
override connectedCallback() {
super.connectedCallback();
this.disposables.addFromEvent(this, 'click', e => {
e.preventDefault();
e.stopPropagation();
this.openSubMenu();
});
}
onMouseEnter() {
this.openSubMenu();
}
override onPressEnter() {
this.onMouseEnter();
}
openSubMenu() {
const { menu } = popMenu(popupTargetFromElement(this), {
options: {
...this.data.options,
onComplete: () => {
this.menu.close();
},
onClose: () => {
menu.menuElement.remove();
this.data.options.onClose?.();
},
},
});
this.menu.openSubMenu(menu);
}
protected override render(): unknown {
const classString = classMap({
[this.data.class ?? '']: true,
'mobile-menu-button': true,
focused: this.isFocused$.value,
});
return html` <div class="${classString}">${this.data.content()}</div>`;
}
@property({ attribute: false })
accessor data!: MenuSubMenuData;
}
export const renderSubMenu = (data: MenuSubMenuData, menu: Menu) => {
if (IS_MOBILE) {
return html` <mobile-sub-menu
.data="${data}"
.menu="${menu}"
></mobile-sub-menu>`;
}
return html` <affine-menu-sub-menu
.data="${data}"
.menu="${menu}"
></affine-menu-sub-menu>`;
};
export const subMenuItems = {
subMenu:
(config: {
name: string;
label?: () => TemplateResult;
select?: () => void;
isSelected?: boolean;
postfix?: TemplateResult;
prefix?: TemplateResult;
class?: string;
options: MenuOptions;
disableArrow?: boolean;
hide?: () => boolean;
}) =>
menu => {
if (config.hide?.() || !menu.search(config.name)) {
return;
}
const data: MenuSubMenuData = {
content: () =>
html`${config.prefix}
<div class="affine-menu-action-text">
${config.label?.() ?? config.name}
</div>
${config.postfix}
${config.disableArrow ? nothing : ArrowRightSmallIcon()} `,
class: config.class,
options: config.options,
};
return renderSubMenu(data, menu);
},
} satisfies Record<string, MenuItemRender<never>>;
@@ -0,0 +1,21 @@
import type { ClassInfo } from 'lit-html/directives/class-map.js';
import type { MenuFocusable } from './focusable.js';
import type { Menu, MenuConfig } from './menu.js';
export type MenuClass = ClassInfo & {
'delete-item'?: boolean;
};
export type MenuItemRender<Props> = (props: Props) => MenuConfig;
export interface MenuComponentInterface extends HTMLElement {
menu: Menu;
remove(): void;
getFocusableElements(): HTMLElement[];
getFirstFocusableElement(): HTMLElement | null;
focusTo(ele?: MenuFocusable): void;
}
@@ -0,0 +1,625 @@
import { WithDisposable } from '@blocksuite/global/utils';
import { isSameDay, isSameMonth, isToday } from 'date-fns';
import {
html,
LitElement,
nothing,
type PropertyValues,
type TemplateResult,
} from 'lit';
import { property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { styleMap } from 'lit/directives/style-map.js';
import { arrowLeftIcon } from './icons.js';
import { datePickerStyle } from './style.js';
import { clamp, getMonthMatrix, toDate } from './utils.js';
const days = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
const months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
export interface DateCell {
date: Date;
label: string;
isToday: boolean;
notCurrentMonth: boolean;
selected?: boolean;
tabIndex?: number;
}
type NavActionArg = {
action: () => void;
disable?: boolean;
};
/**
* Date picker
*/
export class DatePicker extends WithDisposable(LitElement) {
static override styles = datePickerStyle;
/** current active month */
private _cursor = new Date();
private _maxYear = 2099;
private _minYear = 1970;
get _cardStyle() {
return {
'--cell-size': `${this.size}px`,
'--gap-h': `${this.gapH}px`,
'--gap-v': `${this.gapV}px`,
'min-width': `${this.cardWidth}px`,
'min-height': `${this.cardHeight}px`,
padding: `${this.padding}px`,
};
}
get cardHeight() {
const rowNum = 7;
return this.size * rowNum + this.padding * 2 + this.gapV * (rowNum - 1) - 2;
}
get cardWidth() {
const colNum = 7;
return this.size * colNum + this.padding * 2 + this.gapH * (colNum - 1);
}
get date() {
return this._cursor.getDate();
}
get day() {
return this._cursor.getDay();
}
get dayLabel() {
return days[this.day];
}
get minHeight() {
const rowNum = this._matrix.length;
return this.size * rowNum + this.padding * 2 + this.gapV * (rowNum - 1) - 2;
}
get month() {
return this._cursor.getMonth();
}
get monthLabel() {
return months[this.month];
}
get year() {
return this._cursor.getFullYear();
}
get yearLabel() {
return this.year;
}
/** Cell */
private _cellRenderer(cell: DateCell) {
const classes = classMap({
interactive: true,
'date-cell': true,
'date-cell--today': cell.isToday,
'date-cell--not-curr-month': cell.notCurrentMonth,
'date-cell--selected': !!cell.selected,
});
const dateRaw = `${cell.date.getFullYear()}-${cell.date.getMonth()}-${cell.date.getDate()}(${cell.date.getDay()})`;
return html`<button
tabindex=${cell.tabIndex ?? -1}
aria-label=${dateRaw}
data-date=${dateRaw}
class=${classes}
@click=${() => {
this._onChange(cell.date);
}}
>
${cell.label}
</button>`;
}
private _dateContent() {
return html` <div class="date-picker-header">
<div class="date-picker-header__buttons">
<button
class="date-picker-header__date interactive"
@click=${() => this.toggleMonthSelector()}
>
<div>${this.monthLabel}</div>
</button>
<button
class="date-picker-header__date interactive"
@click=${() => this.toggleYearSelector()}
>
<div>${this.yearLabel}</div>
</button>
</div>
${this._navAction(
() => this._moveMonth(-1),
() => this._moveMonth(1),
html`<button
tabindex="0"
aria-label="today"
class="action-label interactive today"
@click=${() => {
this._onChange(new Date());
}}
>
<span>TODAY</span>
</button>`
)}
</div>
${this._dayHeaderRenderer()}
<div class="date-picker-weeks">
${this._matrix.map(
week =>
html`<div class="date-picker-week">
${week.map(cell => this._cellRenderer(cell))}
</div>`
)}
</div>
${this.onClear
? html`<div class="date-picker-footer">
<button
tabindex="0"
aria-label="clear"
class="footer-button interactive"
@click=${() => this.onClear?.()}
>
Clear
</button>
</div>`
: nothing}`;
}
/** Week header */
private _dayHeaderRenderer() {
return html`<div class="days-header">
${days.map(day => html`<div class="date-cell">${day}</div>`)}
</div>`;
}
private _getMatrix() {
this._matrix = getMonthMatrix(this._cursor).map(row => {
return row.map(date => {
const tabIndex = isSameDay(date, this._cursor) ? 0 : -1;
return {
date,
label: date.getDate().toString(),
isToday: isToday(date),
notCurrentMonth: !isSameMonth(date, this._cursor),
selected: this.value ? isSameDay(date, toDate(this.value)) : false,
tabIndex,
} satisfies DateCell;
});
});
}
private _getYearMatrix() {
// every decade has 12 years
const no = Math.floor((this._yearCursor - this._minYear) / 12);
const decade = no * 12;
const start = this._minYear + decade;
const end = start + 12;
this._yearMatrix = Array.from(
{ length: end - start },
(_, i) => start + i
).filter(v => v >= this._minYear && v <= this._maxYear);
}
private _modeDecade(offset: number) {
this._yearCursor = clamp(
this._minYear,
this._maxYear,
this._yearCursor + offset
);
this._getYearMatrix();
}
private _monthContent() {
return html` <div class="date-picker-header">
<button
class="date-picker-header__date interactive"
@click=${() => this.toggleMonthSelector()}
>
<div>${this._monthPickYearCursor}</div>
</button>
${this._navAction(
{
action: () => this._monthPickYearCursor--,
disable: this._monthPickYearCursor <= this._minYear,
},
{
action: () => this._monthPickYearCursor++,
disable: this._monthPickYearCursor >= this._maxYear,
}
)}
</div>
<div class="date-picker-month">
${months.map((month, index) => {
const isActive = this.value
? isSameMonth(
this.value,
new Date(this._monthPickYearCursor, index, 1)
)
: false;
const classes = classMap({
'month-cell': true,
interactive: true,
active: isActive,
});
return html`<button
tabindex=${this._monthCursor === index ? 0 : -1}
aria-label=${month}
class=${classes}
@click=${() => {
this._cursor.setMonth(index);
this._cursor.setFullYear(this._monthPickYearCursor);
this._mode = 'date';
this._getMatrix();
}}
>
${month}
</button>`;
})}
</div>`;
}
private _moveMonth(offset: number) {
this._cursor.setMonth(this._cursor.getMonth() + offset);
this._getMatrix();
}
/** Actions */
private _navAction(
prev: NavActionArg | NavActionArg['action'],
curr: NavActionArg | NavActionArg['action'],
slot?: TemplateResult
) {
const onPrev = typeof prev === 'function' ? prev : prev.action;
const onNext = typeof curr === 'function' ? curr : curr.action;
const prevDisable = typeof prev === 'function' ? false : prev.disable;
const nextDisable = typeof curr === 'function' ? false : curr.disable;
const classes = classMap({
'date-picker-header__action': true,
'with-slot': !!slot,
});
return html`<div class=${classes}>
<button
aria-label="previous month"
class="date-picker-small-action interactive left"
@click=${onPrev}
?disabled=${prevDisable}
>
${arrowLeftIcon}
</button>
${slot ?? nothing}
<button
aria-label="next month"
class="date-picker-small-action interactive right"
@click=${onNext}
?disabled=${nextDisable}
>
${arrowLeftIcon}
</button>
</div>`;
}
private _onChange(date: Date, emit = true) {
this._cursor = date;
this.value = date.getTime();
this._getMatrix();
emit && this.onChange?.(date);
}
private _switchMode<T>(map: Record<typeof this._mode, T>) {
return (map[this._mode] as T) ?? nothing;
}
private _yearContent() {
const startYear = this._yearMatrix[0];
const endYear = this._yearMatrix[this._yearMatrix.length - 1];
return html`<div class="date-picker-header">
<button
class="date-picker-header__date interactive"
@click=${() => this.toggleYearSelector()}
>
<div>${startYear}-${endYear}</div>
</button>
${this._navAction(
{
action: () => this._modeDecade(-12),
disable: startYear <= this._minYear,
},
{
action: () => this._modeDecade(12),
disable: endYear >= this._maxYear,
}
)}
</div>
<div class="date-picker-year">
${this._yearMatrix.map(year => {
const isActive = year === this._cursor.getFullYear();
const classes = classMap({
'year-cell': true,
interactive: true,
active: isActive,
});
return html`<button
tabindex=${this._yearCursor === year ? 0 : -1}
aria-label=${year}
class=${classes}
@click=${() => {
this._cursor.setFullYear(year);
this._mode = 'date';
this._getMatrix();
}}
>
${year}
</button>`;
})}
</div>`;
}
closeMonthSelector() {
this._mode = 'date';
}
closeYearSelector() {
this._mode = 'date';
}
override connectedCallback(): void {
super.connectedCallback();
if (this.value) this._cursor = toDate(this.value);
this._getMatrix();
}
override firstUpdated(): void {
this._disposables.addFromEvent(
this,
'keydown',
e => {
e.stopPropagation();
const directions = new Set([
'ArrowLeft',
'ArrowRight',
'ArrowUp',
'ArrowDown',
]);
if (directions.has(e.key) && this.isDateCellFocused()) {
e.preventDefault();
if (e.key === 'ArrowLeft') {
this._cursor.setDate(this._cursor.getDate() - 1);
} else if (e.key === 'ArrowRight') {
this._cursor.setDate(this._cursor.getDate() + 1);
} else if (e.key === 'ArrowUp') {
this._cursor.setDate(this._cursor.getDate() - 7);
} else if (e.key === 'ArrowDown') {
this._cursor.setDate(this._cursor.getDate() + 7);
}
this._getMatrix();
setTimeout(this.focusDateCell.bind(this));
}
if (directions.has(e.key) && this.isMonthCellFocused()) {
e.preventDefault();
if (e.key === 'ArrowLeft') {
this._monthCursor = (this._monthCursor - 1 + 12) % 12;
} else if (e.key === 'ArrowRight') {
this._monthCursor = (this._monthCursor + 1) % 12;
} else if (e.key === 'ArrowUp') {
this._monthCursor = (this._monthCursor - 3 + 12) % 12;
} else if (e.key === 'ArrowDown') {
this._monthCursor = (this._monthCursor + 3) % 12;
}
setTimeout(this.focusMonthCell.bind(this));
}
if (directions.has(e.key) && this.isYearCellFocused()) {
e.preventDefault();
if (e.key === 'ArrowLeft') {
this._modeDecade(-1);
} else if (e.key === 'ArrowRight') {
this._modeDecade(1);
} else if (e.key === 'ArrowUp') {
this._modeDecade(-3);
} else if (e.key === 'ArrowDown') {
this._modeDecade(3);
}
setTimeout(this.focusYearCell.bind(this));
}
if (e.key === 'Tab') {
setTimeout(() => {
const focused = this.shadowRoot?.activeElement as HTMLElement;
const firstEl = this.shadowRoot?.querySelector('button');
// check if focus the last element, then focus the first element
if (!e.shiftKey && !focused) firstEl?.focus();
// check if focused element is inside current date-picker
if (e.shiftKey && !this.shadowRoot?.contains(focused))
this.focusDateCell();
});
}
if (e.key === 'Escape') {
this.onEscape?.(toDate(this.value));
}
},
true
);
}
/**
* Focus on date-cell
*/
focusDateCell() {
const lastEl = this.shadowRoot?.querySelector(
'button.date-cell[tabindex="0"]'
) as HTMLElement;
lastEl?.focus();
}
focusMonthCell() {
const lastEl = this.shadowRoot?.querySelector(
'button.month-cell[tabindex="0"]'
) as HTMLElement;
lastEl?.focus();
}
focusYearCell() {
const lastEl = this.shadowRoot?.querySelector(
'button.year-cell[tabindex="0"]'
) as HTMLElement;
lastEl?.focus();
}
/**
* check if date-cell is focused
* @returns
*/
isDateCellFocused() {
const focused = this.shadowRoot?.activeElement as HTMLElement;
return focused?.classList.contains('date-cell');
}
isMonthCellFocused() {
const focused = this.shadowRoot?.activeElement as HTMLElement;
return focused?.classList.contains('month-cell');
}
isYearCellFocused() {
const focused = this.shadowRoot?.activeElement as HTMLElement;
return focused?.classList.contains('year-cell');
}
openMonthSelector() {
this._monthCursor = this.month;
this._monthPickYearCursor = this.year;
this._mode = 'month';
}
openYearSelector() {
this._yearCursor = clamp(this._minYear, this._maxYear, this.year);
this._mode = 'year';
this._getYearMatrix();
}
override render() {
const classes = classMap({
'date-picker': true,
[`date-picker--mode-${this._mode}`]: true,
popup: this.popup,
});
const wrapperStyle = styleMap({
'min-height': `${this.minHeight}px`,
});
return html`<div style=${wrapperStyle} class="date-picker-height-wrapper">
<div class=${classes} style=${styleMap(this._cardStyle)}>
${this._switchMode({
date: this._dateContent(),
month: this._monthContent(),
year: this._yearContent(),
})}
</div>
</div>`;
}
toggleMonthSelector() {
if (this._mode === 'month') this.closeMonthSelector();
else this.openMonthSelector();
}
toggleYearSelector() {
if (this._mode === 'year') this.closeYearSelector();
else this.openYearSelector();
}
override updated(_changedProperties: PropertyValues): void {
if (_changedProperties.has('value')) {
// this._getMatrix();
if (this.value) this._onChange(toDate(this.value), false);
else this._getMatrix();
}
}
/** date matrix */
@property({ attribute: false })
private accessor _matrix: DateCell[][] = [];
@property({ attribute: false })
private accessor _mode: 'date' | 'month' | 'year' = 'date';
/** web-accessibility for month select */
@property({ attribute: false })
private accessor _monthCursor = 0;
@property({ attribute: false })
private accessor _monthPickYearCursor = 0;
@property({ attribute: false })
private accessor _yearCursor = 0;
@property({ attribute: false })
private accessor _yearMatrix: number[] = [];
/** horizontal gap between cells in px */
@property({ type: Number })
accessor gapH = 10;
/** vertical gap between cells in px */
@property({ type: Number })
accessor gapV = 8;
@property({ attribute: false })
accessor onChange: ((value: Date) => void) | undefined = undefined;
@property({ attribute: false })
accessor onClear: (() => void) | undefined = undefined;
@property({ attribute: false })
accessor onEscape: ((value: Date) => void) | undefined = undefined;
/** card padding in px */
@property({ type: Number })
accessor padding = 20;
@property({ type: Boolean })
accessor popup: boolean = false;
/** cell size in px */
@property({ type: Number })
accessor size = 28;
/** Checked date timestamp */
@property({ type: Number })
accessor value: number | undefined = undefined;
}
declare global {
interface HTMLElementTagNameMap {
'date-picker': DatePicker;
}
}
@@ -0,0 +1,11 @@
import { svg } from 'lit';
export const arrowLeftIcon = svg`<svg width="7" height="10" viewBox="0 0 7 10" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fill="currentColor"
fill-rule="evenodd"
clip-rule="evenodd"
d="M6.10861 0.391387C6.35269 0.635464 6.35269 1.03119 6.10861 1.27527L2.38388 5L6.10861 8.72472C6.35269 8.9688 6.35269 9.36453 6.10861 9.6086C5.86453 9.85268 5.4688 9.85268 5.22473 9.6086L1.05806 5.44194C0.813981 5.19786 0.813981 4.80213 1.05806 4.55805L5.22473 0.391387C5.4688 0.147309 5.86453 0.147309 6.10861 0.391387Z"
/>
</svg>
`;
@@ -0,0 +1,7 @@
import { DatePicker } from './date-picker.js';
export * from './date-picker.js';
export function effects() {
customElements.define('date-picker', DatePicker);
}
@@ -0,0 +1,309 @@
import { css } from 'lit';
export const datePickerStyle = css`
:host {
display: block;
}
.date-picker {
display: flex;
flex-direction: column;
box-sizing: border-box;
gap: var(--gap-v);
font-family: var(--affine-font-family);
}
.popup.date-picker {
background: var(--affine-background-overlay-panel-color);
border-radius: 12px;
box-shadow: var(--affine-menu-shadow);
}
/* small action */
.date-picker-small-action {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 4px;
}
.interactive.date-picker-small-action,
.interactive.action-label.today {
color: var(--affine-icon-color);
}
.date-picker-small-action:hover {
color: var(--affine-icon-hover-color);
background: var(--affine-icon-hover-background);
}
.date-picker-small-action.left > svg {
transform: rotate(0deg);
}
.date-picker-small-action.right > svg {
transform: rotate(180deg);
}
.date-picker-small-action.down > svg {
transform: rotate(-90deg);
}
/* action-header */
.date-picker-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.date-picker-header__buttons {
display: flex;
}
.date-picker-header__date {
display: flex;
align-items: center;
gap: 4px;
color: var(--affine-text-primary-color);
font-weight: 600;
padding: 2px;
border-radius: 4px;
font-size: 14px;
line-height: 22px;
}
.date-picker-header__date > div {
padding: 0px 4px;
}
.date-picker-header__action {
display: flex;
align-items: center;
gap: 16px;
color: var(--affine-icon-color);
}
.date-picker-header__action.with-slot {
gap: 4px;
}
.date-picker-header__action .action-label {
font-size: 12px;
padding: 0px 4px;
height: 20px;
border-radius: 4px;
transition: all 0.23s ease;
max-width: 100px;
}
.date-picker-header__action .action-label > span {
display: inline-flex;
justify-content: center;
align-items: center;
width: 100%;
text-align: center;
white-space: nowrap;
overflow: hidden;
}
/** days header */
.days-header {
display: flex;
gap: var(--gap-h);
}
.days-header > div {
color: var(--affine-text-secondary-color);
font-weight: 500;
font-size: 12px;
cursor: default;
}
/** week */
.date-picker-weeks {
display: flex;
flex-direction: column;
gap: var(--gap-v);
}
.date-picker-week {
display: flex;
gap: var(--gap-h);
}
/** cell */
.date-cell {
width: var(--cell-size);
height: var(--cell-size);
display: flex;
align-items: center;
justify-content: center;
user-select: none;
border-radius: 8px;
}
.date-cell[data-date] {
font-weight: 400;
font-size: 14px;
}
.date-cell.date-cell--not-curr-month {
opacity: 0.1;
}
.date-cell.date-cell--today {
color: var(--affine-primary-color);
font-weight: 600;
}
.date-cell.date-cell--selected {
background: var(--affine-primary-color);
color: var(--affine-pure-white);
font-weight: 500;
}
/** interactive */
.interactive {
cursor: pointer;
/* transition:
background 0.23s ease,
color 0.23s ease; */
user-select: none;
position: relative;
border: none;
background-color: unset;
font-family: var(--affine-font-family);
color: var(--affine-text-primary-color);
}
/* --hover */
.interactive::after,
.interactive::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
transition: background 0.23s ease;
}
.interactive::after {
opacity: 1;
background: transparent;
}
.interactive:hover::after {
background: var(--affine-hover-color);
}
/* --focus */
.interactive::before {
opacity: 0;
transition: none;
box-shadow: 0 0 0 3px var(--affine-primary-color);
}
/* .interactive:active, */
.interactive:focus-visible {
outline: none;
outline: 1px solid var(--affine-primary-color);
}
/* .interactive:active::before, */
.interactive:focus-visible::before {
opacity: 0.5;
}
/** disabled */
.interactive[disabled] {
cursor: not-allowed;
opacity: 0.5;
}
/** Month Select */
.date-picker-month {
--btn-width: 36px;
}
.date-picker-year {
--btn-width: 46px;
}
.date-picker-month,
.date-picker-year {
display: grid;
grid-template-columns: repeat(3, var(--btn-width));
gap: 18px 32px;
justify-content: space-between;
}
.date-picker-month button,
.date-picker-year button {
height: 34px;
width: fit-content;
padding: 4px;
border-radius: 8px;
font-size: 15px;
display: flex;
align-items: center;
justify-content: center;
width: var(--btn-width);
}
.date-picker-month button.active,
.date-picker-year button.active {
color: var(--affine-primary-color);
/* background: var(--affine-primary-color); */
font-weight: 600;
}
.date-picker .date-picker-header {
padding: 0px;
transition: padding 0.23s ease;
}
.date-picker--mode-month,
.date-picker--mode-year {
gap: 26px;
}
.date-picker--mode-month .date-picker-header,
.date-picker--mode-year .date-picker-header {
/* padding: 0 10px; */
}
.date-picker-footer {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--affine-border-color);
}
.footer-button {
height: 28px;
border: none;
border-radius: 4px;
background: none;
color: var(--affine-text-secondary-color);
cursor: pointer;
font-size: var(--affine-font-sm);
padding: 0 12px;
}
.footer-button:hover {
background: var(--affine-hover-color);
}
`;
@@ -0,0 +1,73 @@
export type MaybeDate = Date | string | number | undefined;
/**
* @deprecated should not use raw string to represent timestamp
* @param str
* @returns
*/
function _isTimestampString(str: string) {
return /^\d+$/.test(str);
}
/**
* Parse the given date to Date object
* @param date
* @returns
*/
export function toDate(date?: MaybeDate) {
// TODO: handle invalid date
if (date instanceof Date) return date;
if (typeof date === 'string' && _isTimestampString(date)) date = +date;
return date ? new Date(date) : new Date();
}
/**
* get the first day of the month of the given date
* @param maybeDate
*/
export function getFirstDayOfMonth(maybeDate: MaybeDate) {
const date = toDate(maybeDate);
return new Date(date.getFullYear(), date.getMonth(), 1);
}
/**
* get the last day of the month of the given date
* @param maybeDate
* @example
* getLastDayOfMonth('2021-01-01') // 2021-01-31
*/
export function getLastDayOfMonth(maybeDate: MaybeDate) {
const date = toDate(maybeDate);
return new Date(date.getFullYear(), date.getMonth() + 1, 0);
}
export function getMonthMatrix(maybeDate: MaybeDate) {
const date = toDate(maybeDate);
const firstDayOfMonth = getFirstDayOfMonth(date);
const lastDayOfMonth = getLastDayOfMonth(date);
const firstDayOfFirstWeek = new Date(firstDayOfMonth);
firstDayOfFirstWeek.setDate(
firstDayOfMonth.getDate() - firstDayOfMonth.getDay()
);
const lastDayOfLastWeek = new Date(lastDayOfMonth);
lastDayOfLastWeek.setDate(
lastDayOfMonth.getDate() + (6 - lastDayOfMonth.getDay())
);
const matrix = [];
let week = [];
const day = new Date(firstDayOfFirstWeek);
while (day <= lastDayOfLastWeek) {
week.push(new Date(day));
if (week.length === 7) {
matrix.push(week);
week = [];
}
day.setDate(day.getDate() + 1);
}
return matrix;
}
export function clamp(num1: number, num2: number, value: number) {
const [min, max] = [num1, num2].sort();
return Math.min(Math.max(value, min), max);
}
@@ -0,0 +1,48 @@
import type { Rect } from '@blocksuite/global/utils';
import { css, html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
export class DragIndicator extends LitElement {
static override styles = css`
.affine-drag-indicator {
position: absolute;
top: 0;
left: 0;
background: var(--affine-primary-color);
transition-property: width, height, transform;
transition-duration: 100ms;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-delay: 0s;
transform-origin: 0 0;
pointer-events: none;
z-index: 2;
}
`;
override render() {
if (!this.rect) {
return null;
}
const { left, top, width, height } = this.rect;
const style = styleMap({
width: `${width}px`,
height: `${height}px`,
transform: `translate(${left}px, ${top}px)`,
});
return html`<div class="affine-drag-indicator" style=${style}></div>`;
}
@property({ attribute: false })
accessor rect: Rect | null = null;
}
declare global {
interface HTMLElementTagNameMap {
'affine-drag-indicator': DragIndicator;
}
}
export function effects() {
customElements.define('affine-drag-indicator', DragIndicator);
}
@@ -0,0 +1,201 @@
import { DisposableGroup } from '@blocksuite/global/utils';
import type { ReactiveController, ReactiveElement } from 'lit';
import {
type AdvancedPortalOptions,
createLitPortal,
} from '../portal/index.js';
import type { HoverOptions } from './types.js';
import { whenHover } from './when-hover.js';
type OptionsParams = Omit<
ReturnType<typeof whenHover>,
'setFloating' | 'dispose'
> & {
abortController: AbortController;
};
type HoverPortalOptions = Omit<AdvancedPortalOptions, 'abortController'>;
const DEFAULT_HOVER_OPTIONS: HoverOptions = {
transition: {
duration: 100,
in: {
opacity: '1',
transition: 'opacity 0.1s ease-in-out',
},
out: {
opacity: '0',
transition: 'opacity 0.1s ease-in-out',
},
},
setPortalAsFloating: true,
allowMultiple: false,
};
const abortHoverPortal = ({
portal,
hoverOptions,
abortController,
}: {
portal: HTMLDivElement | undefined;
hoverOptions: HoverOptions;
abortController: AbortController;
}) => {
if (!portal || !hoverOptions.transition) {
abortController.abort();
return;
}
// Transition out
Object.assign(portal.style, hoverOptions.transition.out);
portal.addEventListener(
'transitionend',
() => {
abortController.abort();
},
{ signal: abortController.signal }
);
portal.addEventListener(
'transitioncancel',
() => {
abortController.abort();
},
{ signal: abortController.signal }
);
// Make sure the portal is aborted after the transition ends
setTimeout(() => abortController.abort(), hoverOptions.transition.duration);
};
export class HoverController implements ReactiveController {
static globalAbortController?: AbortController;
private _abortController?: AbortController;
private readonly _hoverOptions: HoverOptions;
private _isHovering = false;
private readonly _onHover: (
options: OptionsParams
) => HoverPortalOptions | null;
private _portal?: HTMLDivElement;
private _setReference: (element?: Element | undefined) => void = () => {
console.error('setReference is not ready');
};
protected _disposables = new DisposableGroup();
host: ReactiveElement;
/**
* Callback when the portal needs to be aborted.
*/
onAbort = () => {
this.abort();
};
/**
* Whether the host is currently hovering.
*
* This property is unreliable when the floating element disconnect from the DOM suddenly.
*/
get isHovering() {
return this._isHovering;
}
get portal() {
return this._portal;
}
get setReference() {
return this._setReference;
}
constructor(
host: ReactiveElement,
onHover: (options: OptionsParams) => HoverPortalOptions | null,
hoverOptions?: Partial<HoverOptions>
) {
this._hoverOptions = { ...DEFAULT_HOVER_OPTIONS, ...hoverOptions };
(this.host = host).addController(this);
this._onHover = onHover;
}
abort(force = false) {
if (!this._abortController) return;
if (force) {
this._abortController.abort();
return;
}
abortHoverPortal({
portal: this._portal,
hoverOptions: this._hoverOptions,
abortController: this._abortController,
});
}
hostConnected() {
if (this._disposables.disposed) {
this._disposables = new DisposableGroup();
}
// Start a timer when the host is connected
const { setReference, setFloating, dispose } = whenHover(isHover => {
if (!this.host.isConnected) {
return;
}
this._isHovering = isHover;
if (!isHover) {
this.onAbort();
return;
}
if (this._abortController) {
return;
}
this._abortController = new AbortController();
this._abortController.signal.addEventListener('abort', () => {
this._abortController = undefined;
});
if (!this._hoverOptions.allowMultiple) {
HoverController.globalAbortController?.abort();
HoverController.globalAbortController = this._abortController;
}
const portalOptions = this._onHover({
setReference,
abortController: this._abortController,
});
if (!portalOptions) {
// Sometimes the portal is not ready to show
this._abortController.abort();
return;
}
this._portal = createLitPortal({
...portalOptions,
abortController: this._abortController,
});
const transition = this._hoverOptions.transition;
if (transition) {
Object.assign(this._portal.style, transition.in);
}
if (this._hoverOptions.setPortalAsFloating) {
setFloating(this._portal);
}
}, this._hoverOptions);
this._setReference = setReference;
this._disposables.add(dispose);
}
hostDisconnected() {
this._abortController?.abort();
this._disposables.dispose();
}
}
@@ -0,0 +1,3 @@
export { HoverController } from './controller.js';
export type * from './types.js';
export { whenHover } from './when-hover.js';
@@ -0,0 +1,79 @@
import { sleep } from '@blocksuite/global/utils';
import type { HoverMiddleware } from '../types.js';
/**
* When the mouse is hovering in, the `mouseover` event will be fired multiple times.
* This middleware will filter out the duplicated events.
*/
export const dedupe = (keepWhenFloatingNotReady = true): HoverMiddleware => {
const SKIP = false;
const KEEP = true;
let hoverState = false;
return ({ event, floatingElement }) => {
const curState = hoverState;
if (event.type === 'mouseover') {
// hover in
hoverState = true;
if (curState !== hoverState)
// state changed, so we should keep the event
return KEEP;
if (
keepWhenFloatingNotReady &&
(!floatingElement || !floatingElement.isConnected)
) {
// Already hovered
// But the floating element is not ready
// so we should not skip the event
return KEEP;
}
return SKIP;
}
if (event.type === 'mouseleave') {
// hover out
hoverState = false;
if (curState !== hoverState) return KEEP;
if (keepWhenFloatingNotReady && floatingElement?.isConnected) {
// Already hover out
// But the floating element is still showing
// so we should not skip the event
return KEEP;
}
return SKIP;
}
console.warn('Unknown event type in hover middleware', event);
return KEEP;
};
};
/**
* Wait some time before emitting the `mouseover` event.
*/
export const delayShow = (delay: number): HoverMiddleware => {
let abortController = new AbortController();
return async ({ event }) => {
abortController.abort();
const newAbortController = new AbortController();
abortController = newAbortController;
if (event.type !== 'mouseover') return true;
if (delay <= 0) return true;
await sleep(delay, newAbortController.signal);
return !newAbortController.signal.aborted;
};
};
/**
* Wait some time before emitting the `mouseleave` event.
*/
export const delayHide = (delay: number): HoverMiddleware => {
let abortController = new AbortController();
return async ({ event }) => {
abortController.abort();
const newAbortController = new AbortController();
abortController = newAbortController;
if (event.type !== 'mouseleave') return true;
if (delay <= 0) return true;
await sleep(delay, newAbortController.signal);
return !newAbortController.signal.aborted;
};
};
@@ -0,0 +1,361 @@
import type { HoverMiddleware } from '../types.js';
export type SafeTriangleOptions = {
zIndex: number;
buffer: number;
/**
* abort triangle guard if the mouse not move for some time
*/
idleTimeout: number;
debug?: boolean;
};
/**
* Returns true if the line from (a,b)->(c,d) intersects with (p,q)->(r,s)
*
* See https://stackoverflow.com/questions/9043805/test-if-two-lines-intersect-javascript-function
*/
function hasIntersection(
{ x: a, y: b }: { x: number; y: number },
{ x: c, y: d }: { x: number; y: number },
{ x: p, y: q }: { x: number; y: number },
{ x: r, y: s }: { x: number; y: number }
) {
const det = (c - a) * (s - q) - (r - p) * (d - b);
if (det === 0) {
return false;
} else {
const lambda = ((s - q) * (r - a) + (p - r) * (s - b)) / det;
const gamma = ((b - d) * (r - a) + (c - a) * (s - b)) / det;
return 0 < lambda && lambda < 1 && 0 < gamma && gamma < 1;
}
}
function isInside(
{ x, y }: { x: number; y: number },
rect: DOMRect,
buffer = 0
) {
return (
x >= rect.left - buffer &&
x <= rect.right + buffer &&
y >= rect.top - buffer &&
y <= rect.bottom - buffer
);
}
const getNearestSide = (
point: { x: number; y: number },
rect: DOMRect
):
| [
'top' | 'bottom' | 'left' | 'right',
{ x: number; y: number },
{ x: number; y: number },
]
| null => {
const centerPoint = {
x: rect.x + rect.width / 2,
y: rect.y + rect.height / 2,
};
const topLeft = { x: rect.x, y: rect.y };
const topRight = { x: rect.right, y: rect.y };
const bottomLeft = { x: rect.x, y: rect.bottom };
const bottomRight = { x: rect.right, y: rect.bottom };
if (hasIntersection(point, centerPoint, bottomLeft, bottomRight)) {
return ['top', bottomLeft, bottomRight];
}
if (hasIntersection(point, centerPoint, topLeft, topRight)) {
return ['bottom', topLeft, topRight];
}
if (hasIntersection(point, centerPoint, topLeft, bottomLeft)) {
return ['right', topLeft, bottomLeft];
}
if (hasIntersection(point, centerPoint, topRight, bottomRight)) {
return ['left', topRight, bottomRight];
}
return null;
};
/**
* Part of the code is ported from https://github.com/floating-ui/floating-ui/blob/master/packages/react/src/safePolygon.ts
* Licensed under MIT.
*/
export const safeTriangle = ({
zIndex = 10000,
buffer = 2,
idleTimeout = 40,
debug = false,
}: Partial<SafeTriangleOptions> = {}): HoverMiddleware => {
let abortController = new AbortController();
return async ({ event, referenceElement, floatingElement }) => {
abortController.abort();
const newAbortController = new AbortController();
abortController = newAbortController;
const isLeave = event.type === 'mouseleave';
if (!isLeave || event.target !== referenceElement) return true;
if (!(event instanceof MouseEvent)) {
console.warn('Unknown event type in hover middleware', event);
return true;
}
if (!floatingElement) return true;
const mouseX = event.x;
const mouseY = event.y;
const refRect = referenceElement.getBoundingClientRect();
const rect = floatingElement.getBoundingClientRect();
// If the mouse leaves from inside the referenceElement element,
// we should ignore the event.
const leaveFromInside = isInside({ x: mouseX, y: mouseY }, refRect);
if (leaveFromInside) return true;
// what side is the floating element on
const floatingData = getNearestSide({ x: mouseX, y: mouseY }, rect);
if (!floatingData) return true;
const floatingSide = floatingData[0];
// If the pointer is leaving from the opposite side, no need to show the triangle.
// A constant of 1 handles floating point rounding errors.
if (
(floatingSide === 'top' && mouseY >= refRect.bottom - 1) ||
(floatingSide === 'bottom' && mouseY <= refRect.top + 1) ||
(floatingSide === 'left' && mouseX >= refRect.right - 1) ||
(floatingSide === 'right' && mouseX <= refRect.left + 1)
) {
return true;
}
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
const updateSafeTriangle = (mouseX: number, mouseY: number) => {
if (newAbortController.signal.aborted) return;
// If the mouse is inside the floating element, we should ignore the event.
if (
mouseX >= rect.left &&
mouseX <= rect.right &&
mouseY >= rect.top &&
mouseY <= rect.bottom
)
newAbortController.abort();
const p1 = { x: mouseX, y: mouseY };
// Assume the floating element is still in the same position.
const p2 = floatingData[1];
const p3 = floatingData[2];
// The base point is the top left corner of the three points.
const basePoint = {
x: Math.min(p1.x, p2.x, p3.x) - buffer,
y: Math.min(p1.y, p2.y, p3.y) - buffer,
};
const areaHeight = Math.max(
Math.abs(p1.y - p2.y),
Math.abs(p1.y - p3.y),
Math.abs(p2.y - p3.y)
);
const areaWidth = Math.max(
Math.abs(p1.x - p2.x),
Math.abs(p1.x - p3.x),
Math.abs(p2.x - p3.x)
);
Object.assign(svg.style, {
position: 'fixed',
pointerEvents: 'none',
width: areaWidth + buffer * 2,
height: areaHeight + buffer * 2,
zIndex,
top: 0,
left: 0,
transform: `translate(${basePoint.x}px, ${basePoint.y}px)`,
});
path.setAttributeNS(
null,
'd',
`M${p1.x - basePoint.x} ${p1.y - basePoint.y} ${p2.x - basePoint.x} ${
p2.y - basePoint.y
} ${p3.x - basePoint.x} ${p3.y - basePoint.y} z`
);
};
path.setAttributeNS(null, 'pointer-events', 'auto');
path.setAttributeNS(null, 'fill', 'transparent');
path.setAttributeNS(null, 'stroke-width', buffer.toString());
path.setAttributeNS(null, 'stroke', 'transparent');
if (debug) {
path.setAttributeNS(null, 'stroke', 'red');
}
updateSafeTriangle(mouseX, mouseY);
svg.append(path);
document.body.append(svg);
abortController.signal.addEventListener('abort', () => svg.remove());
let frameId = 0;
let idleId = window.setTimeout(
() => newAbortController.abort(),
idleTimeout
);
svg.addEventListener(
'mousemove',
e => {
clearTimeout(idleId);
idleId = window.setTimeout(
() => newAbortController.abort(),
idleTimeout
);
cancelAnimationFrame(frameId);
// prevent unexpected mouseleave
frameId = requestAnimationFrame(() =>
updateSafeTriangle(e.clientX, e.clientY)
);
},
{ signal: newAbortController.signal }
);
await new Promise<void>(res => {
if (newAbortController.signal.aborted) res();
newAbortController.signal.addEventListener('abort', () => res());
svg.addEventListener('mouseleave', () => newAbortController.abort(), {
signal: newAbortController.signal,
});
});
return true;
};
};
export type SafeBridgeOptions = { debug: boolean; idleTimeout: number };
/**
* Create a virtual rectangular bridge between the reference element and the floating element.
*
* Part of the code is ported from https://github.com/floating-ui/floating-ui/blob/master/packages/react/src/safePolygon.ts
* Licensed under MIT.
*/
export const safeBridge = ({
debug = false,
idleTimeout = 500,
}: Partial<SafeBridgeOptions> = {}): HoverMiddleware => {
let abortController = new AbortController();
return async ({ event, referenceElement, floatingElement }) => {
abortController.abort();
const newAbortController = new AbortController();
abortController = newAbortController;
const isLeave = event.type === 'mouseleave';
if (!isLeave || event.target !== referenceElement) return true;
if (!(event instanceof MouseEvent)) {
console.warn('Unknown event type in hover middleware', event);
return true;
}
if (!floatingElement) return true;
const checkInside = (mouseX: number, mouseY: number) => {
if (newAbortController.signal.aborted) return false;
const point = { x: mouseX, y: mouseY };
const refRect = referenceElement.getBoundingClientRect();
const rect = floatingElement.getBoundingClientRect();
// what side is the floating element on
const floatingData = getNearestSide(point, rect);
if (!floatingData) return false;
const floatingSide = floatingData[0];
// If the pointer is leaving from the other side, no need to show the bridge.
// A constant of 1 handles floating point rounding errors.
if (
(floatingSide === 'top' && mouseY > refRect.top + 1) ||
(floatingSide === 'bottom' && mouseY < refRect.bottom - 1) ||
(floatingSide === 'left' && mouseX > refRect.left + 1) ||
(floatingSide === 'right' && mouseX < refRect.right - 1)
)
return false;
let rectRect: DOMRect;
switch (floatingSide) {
case 'top': {
rectRect = new DOMRect(
Math.max(rect.left, refRect.left),
rect.bottom,
Math.min(rect.right, refRect.right) -
Math.max(rect.left, refRect.left),
refRect.top - rect.bottom
);
break;
}
case 'bottom': {
rectRect = new DOMRect(
Math.max(rect.left, refRect.left),
refRect.bottom,
Math.min(rect.right, refRect.right) -
Math.max(rect.left, refRect.left),
rect.top - refRect.bottom
);
break;
}
case 'left': {
rectRect = new DOMRect(
rect.right,
Math.max(rect.top, refRect.top),
refRect.left - rect.right,
Math.min(rect.bottom, refRect.bottom) -
Math.max(rect.top, refRect.top)
);
break;
}
case 'right': {
rectRect = new DOMRect(
refRect.right,
Math.max(rect.top, refRect.top),
rect.left - refRect.right,
Math.min(rect.bottom, refRect.bottom) -
Math.max(rect.top, refRect.top)
);
break;
}
default:
return false;
}
const inside = isInside(point, rectRect, 1);
if (inside && debug) {
const debugId = 'debug-rectangle-bridge-rect';
const rectDom =
document.querySelector<HTMLDivElement>(`#${debugId}`) ??
document.createElement('div');
rectDom.id = debugId;
Object.assign(rectDom.style, {
position: 'fixed',
pointerEvents: 'none',
background: 'aqua',
opacity: '0.3',
top: rectRect.top + 'px',
left: rectRect.left + 'px',
width: rectRect.width + 'px',
height: rectRect.height + 'px',
});
document.body.append(rectDom);
newAbortController.signal.addEventListener('abort', () =>
rectDom.remove()
);
}
return inside;
};
if (!checkInside(event.x, event.y)) return true;
await new Promise<void>(res => {
if (newAbortController.signal.aborted) res();
newAbortController.signal.addEventListener('abort', () => res());
let idleId = window.setTimeout(
() => newAbortController.abort(),
idleTimeout
);
document.addEventListener(
'mousemove',
e => {
clearTimeout(idleId);
idleId = window.setTimeout(
() => newAbortController.abort(),
idleTimeout
);
if (!checkInside(e.clientX, e.clientY)) newAbortController.abort();
},
{
signal: newAbortController.signal,
}
);
});
return true;
};
};
@@ -0,0 +1,65 @@
import type { StyleInfo } from 'lit/directives/style-map.js';
import type {
SafeBridgeOptions,
SafeTriangleOptions,
} from './middlewares/safe-area.js';
export type WhenHoverOptions = {
enterDelay?: number;
leaveDelay?: number;
/**
* When already hovered to the reference element,
* but the floating element is not ready,
* the callback will still be executed if the `alwayRunWhenNoFloating` is true.
*
* It is useful when the floating element is removed just before by a user's action,
* and the user's mouse is still hovering over the reference element.
*
* @default true
*/
alwayRunWhenNoFloating?: boolean;
safeTriangle?: boolean | SafeTriangleOptions;
/**
* Create a virtual rectangular bridge between the reference element and the floating element.
*/
safeBridge?: boolean | SafeBridgeOptions;
};
export type HoverMiddleware = (ctx: {
event: Event;
referenceElement?: Element;
floatingElement?: Element;
}) => boolean | Promise<boolean>;
export type HoverOptions = {
/**
* Transition style when the portal is shown or hidden.
*/
transition: {
/**
* Specifies the length of the transition in ms.
*
* You only need to specify the transition end duration actually.
*
* ---
*
* Why is the duration required?
*
* The transition event is not reliable, and it may not be triggered in some cases.
*
* See also https://github.com/w3c/csswg-drafts/issues/3043 https://github.com/toeverything/blocksuite/pull/7248/files#r1631375330
*
* Take a look at solutions from other projects: https://floating-ui.com/docs/useTransition#duration
*/
duration: number;
in: StyleInfo;
out: StyleInfo;
} | null;
/**
* Set the portal as hover element automatically.
* @default true
*/
setPortalAsFloating: boolean;
allowMultiple?: boolean;
} & WhenHoverOptions;
@@ -0,0 +1,142 @@
import { dedupe, delayHide, delayShow } from './middlewares/basic.js';
import { safeBridge, safeTriangle } from './middlewares/safe-area.js';
import type { HoverMiddleware, WhenHoverOptions } from './types.js';
/**
* Call the `whenHoverChange` callback when the element is hovered.
*
* After the mouse leaves the element, there is a 300ms delay by default.
*
* Note: The callback may be called multiple times when the mouse is hovering or hovering out.
*
* See also https://floating-ui.com/docs/useHover
*
* @example
* ```ts
* private _setReference: RefOrCallback;
*
* connectedCallback() {
* let hoverTip: HTMLElement | null = null;
* const { setReference, setFloating } = whenHover(isHover => {
* if (!isHover) {
* hoverTips?.remove();
* return;
* }
* hoverTip = document.createElement('div');
* document.body.append(hoverTip);
* setFloating(hoverTip);
* }, { hoverDelay: 500 });
* this._setReference = setReference;
* }
*
* render() {
* return html`
* <div ref=${this._setReference}></div>
* `;
* }
* ```
*/
export const whenHover = (
whenHoverChange: (isHover: boolean, event?: Event) => void,
{
enterDelay = 0,
leaveDelay = 250,
alwayRunWhenNoFloating = true,
safeTriangle: triangleOptions = false,
safeBridge: bridgeOptions = true,
}: WhenHoverOptions = {}
) => {
/**
* The event listener will be removed when the signal is aborted.
*/
const abortController = new AbortController();
let referenceElement: Element | undefined;
let floatingElement: Element | undefined;
const middlewares: HoverMiddleware[] = [
dedupe(alwayRunWhenNoFloating),
triangleOptions &&
safeTriangle(
typeof triangleOptions === 'boolean' ? undefined : triangleOptions
),
bridgeOptions &&
safeBridge(
typeof bridgeOptions === 'boolean' ? undefined : bridgeOptions
),
delayShow(enterDelay),
delayHide(leaveDelay),
].filter(v => typeof v !== 'boolean') as HoverMiddleware[];
let currentEvent: Event | null = null;
const onHoverChange = (async (e: Event) => {
currentEvent = e;
for (const middleware of middlewares) {
const go = await middleware({
event: e,
floatingElement,
referenceElement,
});
if (!go) return;
}
// ignore expired event
if (e !== currentEvent) return;
const isHover = e.type === 'mouseover' ? true : false;
whenHoverChange(isHover, e);
}) as (e: Event) => void;
const addHoverListener = (element?: Element) => {
if (!element) return;
// see https://stackoverflow.com/questions/14795099/pure-javascript-to-check-if-something-has-hover-without-setting-on-mouseover-ou
const alreadyHover = element.matches(':hover');
if (alreadyHover && !abortController.signal.aborted) {
// When the element is already hovered, we need to trigger the callback manually
onHoverChange(new MouseEvent('mouseover'));
}
element.addEventListener('mouseover', onHoverChange, {
capture: true,
signal: abortController.signal,
});
element.addEventListener('mouseleave', onHoverChange, {
// Please refrain use `capture: true` here.
// It will cause the `mouseleave` trigger incorrectly when the pointer is still within the element.
// The issue is detailed in https://github.com/toeverything/blocksuite/issues/6241
//
// The `mouseleave` does not **bubble**.
// This means that `mouseleave` is fired when the pointer has exited the element and all of its descendants,
// If `capture` is used, all `mouseleave` events will be received when the pointer leaves the element or leaves one of the element's descendants (even if the pointer is still within the element).
//
// capture: true,
signal: abortController.signal,
});
};
const removeHoverListener = (element?: Element) => {
if (!element) return;
element.removeEventListener('mouseover', onHoverChange);
element.removeEventListener('mouseleave', onHoverChange);
};
const setReference = (element?: Element) => {
// Clean previous listeners
removeHoverListener(referenceElement);
addHoverListener(element);
referenceElement = element;
};
const setFloating = (element?: Element) => {
// Clean previous listeners
removeHoverListener(floatingElement);
addHoverListener(element);
floatingElement = element;
};
return {
setReference,
setFloating,
dispose: () => {
abortController.abort();
},
};
};
export type { WhenHoverOptions };
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,110 @@
import { html } from 'lit';
export const CloseIcon = html`
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M5.46967 5.46967C5.76256 5.17678 6.23744 5.17678 6.53033 5.46967L12 10.9393L17.4697 5.46967C17.7626 5.17678 18.2374 5.17678 18.5303 5.46967C18.8232 5.76256 18.8232 6.23744 18.5303 6.53033L13.0607 12L18.5303 17.4697C18.8232 17.7626 18.8232 18.2374 18.5303 18.5303C18.2374 18.8232 17.7626 18.8232 17.4697 18.5303L12 13.0607L6.53033 18.5303C6.23744 18.8232 5.76256 18.8232 5.46967 18.5303C5.17678 18.2374 5.17678 17.7626 5.46967 17.4697L10.9393 12L5.46967 6.53033C5.17678 6.23744 5.17678 5.76256 5.46967 5.46967Z"
fill="currentColor"
/>
</svg>
`;
export const ExportToMarkdownIcon = html`<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M8 2.25C6.48122 2.25 5.25 3.48122 5.25 5V8C5.25 8.41421 5.58579 8.75 6 8.75C6.41421 8.75 6.75 8.41421 6.75 8V5C6.75 4.30964 7.30964 3.75 8 3.75H13H15.5218C15.9076 3.75 16.2717 3.92809 16.5085 4.23257L18.9867 7.41879C19.1573 7.63821 19.25 7.90825 19.25 8.18622V12V18C19.25 18.6904 18.6904 19.25 18 19.25H8C7.30964 19.25 6.75 18.6904 6.75 18V16.5C6.75 16.0858 6.41421 15.75 6 15.75C5.58579 15.75 5.25 16.0858 5.25 16.5V18C5.25 19.5188 6.48122 20.75 8 20.75H18C19.5188 20.75 20.75 19.5188 20.75 18V12V8.18622C20.75 7.57468 20.5462 6.98059 20.1707 6.49788L17.6926 3.31166C17.1715 2.6418 16.3705 2.25 15.5218 2.25H13H8ZM2.76855 13.8408C2.76855 14.1765 2.94849 14.3672 3.26538 14.3672C3.58228 14.3672 3.76221 14.1765 3.76221 13.8408V12.0549H3.78369L4.55444 13.9966C4.62964 14.1899 4.76123 14.2812 4.96533 14.2812C5.16675 14.2812 5.29028 14.1926 5.36816 13.9966L6.13892 12.0549H6.1604V13.8408C6.1604 14.1765 6.34033 14.3672 6.65723 14.3672C6.97412 14.3672 7.15405 14.1765 7.15405 13.8408V11.1042C7.15405 10.6262 6.89087 10.3577 6.41553 10.3577C6.01538 10.3577 5.80322 10.5161 5.64209 10.9377L4.97339 12.686H4.9519L4.28052 10.9377C4.11938 10.5161 3.90723 10.3577 3.50708 10.3577C3.03174 10.3577 2.76855 10.6262 2.76855 11.1042V13.8408ZM7.69385 13.7039C7.69385 14.0852 7.90063 14.3 8.26318 14.3H9.35083C10.5244 14.3 11.2173 13.575 11.2173 12.3342C11.2173 11.0935 10.5271 10.4248 9.35083 10.4248H8.26318C7.90063 10.4248 7.69385 10.6396 7.69385 11.021V13.7039ZM9.1897 13.395H8.83252V11.3298H9.1897C9.73486 11.3298 10.0571 11.6816 10.0571 12.3342C10.0571 13.0486 9.75903 13.395 9.1897 13.395Z"
fill="currentColor"
/>
</svg> `;
export const ExportToHTMLIcon = html`<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M8 2.25C6.48122 2.25 5.25 3.48122 5.25 5V8C5.25 8.41421 5.58579 8.75 6 8.75C6.41421 8.75 6.75 8.41421 6.75 8V5C6.75 4.30964 7.30964 3.75 8 3.75H15.5218C15.9076 3.75 16.2717 3.92809 16.5085 4.23257L18.9867 7.41879C19.1573 7.63821 19.25 7.90825 19.25 8.18622V18C19.25 18.6904 18.6904 19.25 18 19.25H8C7.30964 19.25 6.75 18.6904 6.75 18V16.5C6.75 16.0858 6.41421 15.75 6 15.75C5.58579 15.75 5.25 16.0858 5.25 16.5V18C5.25 19.5188 6.48122 20.75 8 20.75H18C19.5188 20.75 20.75 19.5188 20.75 18V8.18622C20.75 7.57468 20.5462 6.98059 20.1707 6.49788L17.6926 3.31166C17.1715 2.6418 16.3705 2.25 15.5218 2.25H8ZM2.76855 13.771C2.76855 14.1523 2.97534 14.3672 3.33789 14.3672C3.70044 14.3672 3.90723 14.1523 3.90723 13.771V12.8015H5.27148V13.771C5.27148 14.1523 5.47827 14.3672 5.84082 14.3672C6.20337 14.3672 6.41016 14.1523 6.41016 13.771V10.9539C6.41016 10.5725 6.20337 10.3577 5.84082 10.3577C5.47827 10.3577 5.27148 10.5725 5.27148 10.9539V11.8965H3.90723V10.9539C3.90723 10.5725 3.70044 10.3577 3.33789 10.3577C2.97534 10.3577 2.76855 10.5725 2.76855 10.9539V13.771ZM7.82007 13.771C7.82007 14.1523 8.02686 14.3672 8.3894 14.3672C8.75195 14.3672 8.95874 14.1523 8.95874 13.771V11.3325H9.53076C9.83154 11.3325 10.033 11.1687 10.033 10.8787C10.033 10.5886 9.83691 10.4248 9.53076 10.4248H7.24805C6.94189 10.4248 6.74585 10.5886 6.74585 10.8787C6.74585 11.1687 6.94727 11.3325 7.24805 11.3325H7.82007V13.771ZM10.8655 14.3672C10.5486 14.3672 10.3687 14.1765 10.3687 13.8408V11.1042C10.3687 10.6262 10.6318 10.3577 11.1072 10.3577C11.5073 10.3577 11.7195 10.5161 11.8806 10.9377L12.552 12.686H12.5735L13.2422 10.9377C13.4033 10.5161 13.6155 10.3577 14.0156 10.3577C14.491 10.3577 14.7542 10.6262 14.7542 11.1042V13.8408C14.7542 14.1765 14.5742 14.3672 14.2573 14.3672C13.9404 14.3672 13.7605 14.1765 13.7605 13.8408V12.0549H13.739L12.9683 13.9966C12.8904 14.1926 12.7668 14.2812 12.5654 14.2812C12.3613 14.2812 12.2297 14.1899 12.1545 13.9966L11.3838 12.0549H11.3623V13.8408C11.3623 14.1765 11.1824 14.3672 10.8655 14.3672ZM15.2939 13.7039C15.2939 14.0852 15.5007 14.3 15.8633 14.3H17.5042C17.8103 14.3 18.0063 14.1362 18.0063 13.8462C18.0063 13.5562 17.8049 13.3923 17.5042 13.3923H16.4326V10.9539C16.4326 10.5725 16.2258 10.3577 15.8633 10.3577C15.5007 10.3577 15.2939 10.5725 15.2939 10.9539V13.7039Z"
fill="currentColor"
/>
</svg> `;
export const NotionIcon = html`
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.26795 6.19873C6.83575 6.65959 7.04771 6.62458 8.11333 6.55361L18.1589 5.9508C18.3728 5.9508 18.1949 5.7369 18.1239 5.70189L16.4555 4.49627C16.1356 4.24834 15.7097 3.96346 14.894 4.03444L5.16636 4.7442C4.81245 4.7792 4.74147 4.95713 4.88245 5.09908L6.26795 6.19873ZM6.87076 8.53997V19.1086C6.87076 19.6774 7.15466 19.8893 7.79442 19.8543L18.8336 19.2156C19.4724 19.1806 19.5434 18.7897 19.5434 18.3288V7.83021C19.5434 7.36935 19.3664 7.12044 18.9756 7.15642L7.43857 7.83021C7.01271 7.86521 6.87076 8.07814 6.87076 8.53997ZM17.77 9.1068C17.84 9.42571 17.77 9.74559 17.4491 9.78156L16.9173 9.88754V17.6901C16.4555 17.939 16.0296 18.0809 15.6747 18.0809C15.1069 18.0809 14.965 17.903 14.5391 17.3712L11.0593 11.9089V17.1942L12.1609 17.4421C12.1609 17.4421 12.1609 18.0809 11.2723 18.0809L8.82309 18.2229C8.75211 18.0809 8.82309 17.727 9.07199 17.6551L9.71078 17.4781V10.4904L8.82309 10.4194C8.75211 10.0995 8.93004 9.63864 9.42687 9.60267L12.054 9.42668L15.6747 14.9589V10.0655L14.7511 9.95949C14.6811 9.56864 14.965 9.2857 15.3198 9.24973L17.77 9.1068ZM4.34964 3.78651L14.4672 3.04175C15.7087 2.9348 16.0286 3.00674 16.8103 3.57358L20.0393 5.84385C20.5721 6.23373 20.75 6.33971 20.75 6.76556V19.2156C20.75 19.9963 20.4661 20.4572 19.4724 20.5281L7.72247 21.2379C6.97673 21.2729 6.62185 21.1669 6.231 20.6701L3.85281 17.5841C3.42695 17.0163 3.25 16.5914 3.25 16.0936V5.02811C3.25 4.38932 3.53293 3.85749 4.34964 3.78651Z"
fill="currentColor"
/>
</svg>
`;
export const NewIcon = html`<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M7.43531 5.11765C7.43531 3.53389 8.7192 2.25 10.303 2.25C11.1416 2.25 11.8962 2.61004 12.4206 3.18398C12.945 2.61004 13.6996 2.25 14.5382 2.25C16.122 2.25 17.4059 3.53389 17.4059 5.11765C17.4059 5.61288 17.2804 6.07879 17.0594 6.48529H19.8912C20.8577 6.48529 21.6412 7.2688 21.6412 8.23529V10.4706C21.6412 11.1916 21.2051 11.8108 20.5824 12.0788V19C20.5824 20.5188 19.3511 21.75 17.8324 21.75H7.00884C5.49006 21.75 4.25884 20.5188 4.25884 19V12.0788C3.63606 11.8108 3.20001 11.1916 3.20001 10.4706V8.2353C3.20001 7.2688 3.98351 6.48529 4.95001 6.48529H7.78183C7.56084 6.07879 7.43531 5.61288 7.43531 5.11765ZM10.303 6.48529H11.6706V5.11765C11.6706 4.36232 11.0583 3.75 10.303 3.75C9.54762 3.75 8.93531 4.36232 8.93531 5.11765C8.93531 5.87298 9.54762 6.48529 10.303 6.48529ZM13.1706 5.11765V6.48529H14.5382C15.2936 6.48529 15.9059 5.87298 15.9059 5.11765C15.9059 4.36232 15.2936 3.75 14.5382 3.75C13.7829 3.75 13.1706 4.36232 13.1706 5.11765ZM4.95001 7.98529C4.81194 7.98529 4.70001 8.09722 4.70001 8.2353V10.4706C4.70001 10.6087 4.81194 10.7206 4.95001 10.7206H19.8912C20.0293 10.7206 20.1412 10.6087 20.1412 10.4706V8.23529C20.1412 8.09722 20.0293 7.98529 19.8912 7.98529H4.95001ZM5.75884 12.2206V19C5.75884 19.6904 6.31848 20.25 7.00884 20.25H11.6706V12.2206H5.75884ZM13.1706 12.2206V20.25H17.8324C18.5227 20.25 19.0824 19.6904 19.0824 19V12.2206H13.1706Z"
fill="currentColor"
/>
</svg>`;
export const HelpIcon = html`<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M3.75 12C3.75 7.44365 7.44365 3.75 12 3.75C16.5563 3.75 20.25 7.44365 20.25 12C20.25 16.5563 16.5563 20.25 12 20.25C7.44365 20.25 3.75 16.5563 3.75 12ZM12 2.25C6.61522 2.25 2.25 6.61522 2.25 12C2.25 17.3848 6.61522 21.75 12 21.75C17.3848 21.75 21.75 17.3848 21.75 12C21.75 6.61522 17.3848 2.25 12 2.25ZM12.2385 10.9834C11.3157 11.708 10.9944 12.166 10.9944 12.9727C10.9944 13.4102 11.2678 13.7383 11.7668 13.7383C12.2454 13.7383 12.4231 13.4648 12.594 13.0615C12.7512 12.4873 12.9768 12.248 13.6399 11.7422C14.5149 11.0928 15.0959 10.5117 15.0959 9.45215C15.0959 7.92773 13.845 6.99121 12.0471 6.99121C11.0764 6.99121 10.3313 7.24414 9.83228 7.66797C9.42212 8.03027 9.15552 8.50879 9.15552 8.95996C9.15552 9.33594 9.39478 9.64355 9.82544 9.64355C10.1125 9.64355 10.2766 9.51367 10.4885 9.2334C10.8167 8.67285 11.2678 8.41992 11.9241 8.41992C12.676 8.41992 13.2708 8.86426 13.2708 9.53418C13.2708 10.1357 12.9016 10.4639 12.2385 10.9834ZM11.7668 14.709C11.1995 14.709 10.7346 15.1055 10.7346 15.6729C10.7346 16.2402 11.1995 16.6367 11.7668 16.6367C12.3342 16.6367 12.8059 16.2402 12.8059 15.6729C12.8059 15.1055 12.3342 14.709 11.7668 14.709Z"
fill="currentColor"
/>
</svg> `;
export const ImportIcon = html`
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M12 3.25C12.4142 3.25 12.75 3.58579 12.75 4L12.75 14.1893L15.4697 11.4697C15.7626 11.1768 16.2374 11.1768 16.5303 11.4697C16.8232 11.7626 16.8232 12.2374 16.5303 12.5303L12.5303 16.5303C12.2374 16.8232 11.7626 16.8232 11.4697 16.5303L7.46967 12.5303C7.17678 12.2374 7.17678 11.7626 7.46967 11.4697C7.76256 11.1768 8.23744 11.1768 8.53033 11.4697L11.25 14.1893L11.25 4C11.25 3.58579 11.5858 3.25 12 3.25ZM4 15.25C4.41421 15.25 4.75 15.5858 4.75 16V17C4.75 18.2426 5.75736 19.25 7 19.25H17C18.2426 19.25 19.25 18.2426 19.25 17V16C19.25 15.5858 19.5858 15.25 20 15.25C20.4142 15.25 20.75 15.5858 20.75 16V17C20.75 19.0711 19.0711 20.75 17 20.75H7C4.92893 20.75 3.25 19.0711 3.25 17V16C3.25 15.5858 3.58579 15.25 4 15.25Z"
fill="currentColor"
/>
</svg>
`;
@@ -0,0 +1,9 @@
export * from './ai.js';
export * from './edgeless.js';
export * from './file-icons.js';
export * from './import-export.js';
export * from './list.js';
export * from './misc.js';
export * from './tags.js';
export * from './text.js';
export * from './utils.js';
@@ -0,0 +1,172 @@
import { html, svg } from 'lit';
const Level1Icon = html`
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="7" cy="12" r="3" fill="currentColor" />
</svg>
`;
const Level2Icon = html`
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M7 14.25C8.24264 14.25 9.25 13.2426 9.25 12C9.25 10.7574 8.24264 9.75 7 9.75C5.75736 9.75 4.75 10.7574 4.75 12C4.75 13.2426 5.75736 14.25 7 14.25ZM7 15C8.65685 15 10 13.6569 10 12C10 10.3431 8.65685 9 7 9C5.34315 9 4 10.3431 4 12C4 13.6569 5.34315 15 7 15Z"
fill="currentColor"
/>
</svg>
`;
const Level3Icon = html`
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.40841 9.24504C6.73514 8.91832 7.26486 8.91832 7.59159 9.24505L9.75496 11.4084C10.0817 11.7351 10.0817 12.2649 9.75495 12.5916L7.59159 14.755C7.26486 15.0817 6.73514 15.0817 6.40841 14.755L4.24504 12.5916C3.91832 12.2649 3.91832 11.7351 4.24505 11.4084L6.40841 9.24504Z"
fill="currentColor"
/>
</svg>
`;
const Level4Icon = html`
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M9.16336 12L7 9.83664L4.83664 12L7 14.1634L9.16336 12ZM7.59159 9.24505C7.26486 8.91832 6.73514 8.91832 6.40841 9.24504L4.24505 11.4084C3.91832 11.7351 3.91832 12.2649 4.24504 12.5916L6.40841 14.755C6.73514 15.0817 7.26486 15.0817 7.59159 14.755L9.75495 12.5916C10.0817 12.2649 10.0817 11.7351 9.75496 11.4084L7.59159 9.24505Z"
fill="currentColor"
/>
</svg>
`;
const toggleSVG = svg`
<path
d="M16.5 11.134C17.1667 11.5189 17.1667 12.4811 16.5 12.866L9 17.1962C8.33333 17.5811 7.5 17.0999 7.5 16.3301L7.5 7.66989C7.5 6.90009 8.33333 6.41896 9 6.80386L16.5 11.134Z"
fill="#77757D"
/>
`;
export const toggleRight = html`
<svg
width="16"
height="16"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
${toggleSVG}
</svg>
`;
export const toggleDown = html`
<svg
width="16"
height="16"
viewBox="0 0 24 24"
style="transform: rotate(90deg)"
xmlns="http://www.w3.org/2000/svg"
>
${toggleSVG}
</svg>
`;
export const checkboxChecked = () => {
return html`
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M3.25 6C3.25 4.48122 4.48122 3.25 6 3.25H18C19.5188 3.25 20.75 4.48122 20.75 6V18C20.75 19.5188 19.5188 20.75 18 20.75H6C4.48122 20.75 3.25 19.5188 3.25 18V6ZM16.5303 9.53033C16.8232 9.23744 16.8232 8.76256 16.5303 8.46967C16.2374 8.17678 15.7626 8.17678 15.4697 8.46967L10.5 13.4393L9.03033 11.9697C8.73744 11.6768 8.26256 11.6768 7.96967 11.9697C7.67678 12.2626 7.67678 12.7374 7.96967 13.0303L9.96967 15.0303C10.2626 15.3232 10.7374 15.3232 11.0303 15.0303L16.5303 9.53033Z"
fill="#1E96EB"
/>
</svg>
`;
};
export const checkboxUnchecked = () => {
return html`
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M6 3.25C4.48122 3.25 3.25 4.48122 3.25 6V18C3.25 19.5188 4.48122 20.75 6 20.75H18C19.5188 20.75 20.75 19.5188 20.75 18V6C20.75 4.48122 19.5188 3.25 18 3.25H6ZM4.75 6C4.75 5.30964 5.30964 4.75 6 4.75H18C18.6904 4.75 19.25 5.30964 19.25 6V18C19.25 18.6904 18.6904 19.25 18 19.25H6C5.30964 19.25 4.75 18.6904 4.75 18V6Z"
fill="currentColor"
/>
</svg>
`;
};
export const playCheckAnimation = async (
refElement: Element,
{ left = 0, size = 20 }: { left?: number; size?: number } = {}
) => {
const sparkingEl = document.createElement('div');
sparkingEl.classList.add('affine-check-animation');
if (size < 20) {
console.warn('If the size is less than 20, the animation may be abnormal.');
}
sparkingEl.style.cssText = `
position: absolute;
width: ${size}px;
height: ${size}px;
border-radius: 50%;
`;
sparkingEl.style.left = `${left}px`;
refElement.append(sparkingEl);
await sparkingEl.animate(
[
{
boxShadow:
'0 -18px 0 -8px #1e96eb, 16px -8px 0 -8px #1e96eb, 16px 8px 0 -8px #1e96eb, 0 18px 0 -8px #1e96eb, -16px 8px 0 -8px #1e96eb, -16px -8px 0 -8px #1e96eb',
},
],
{ duration: 240, easing: 'ease', fill: 'forwards' }
).finished;
await sparkingEl.animate(
[
{
boxShadow:
'0 -36px 0 -10px transparent, 32px -16px 0 -10px transparent, 32px 16px 0 -10px transparent, 0 36px 0 -10px transparent, -32px 16px 0 -10px transparent, -32px -16px 0 -10px transparent',
},
],
{ duration: 360, easing: 'ease', fill: 'forwards' }
).finished;
sparkingEl.remove();
};
export const BulletIcons = [Level1Icon, Level2Icon, Level3Icon, Level4Icon];
@@ -0,0 +1,59 @@
import { html } from 'lit';
export const WarningIcon = html`<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M7.99935 3.16663C5.32997 3.16663 3.16602 5.33058 3.16602 7.99996C3.16602 10.6693 5.32997 12.8333 7.99935 12.8333C10.6687 12.8333 12.8327 10.6693 12.8327 7.99996C12.8327 5.33058 10.6687 3.16663 7.99935 3.16663ZM2.16602 7.99996C2.16602 4.7783 4.77769 2.16663 7.99935 2.16663C11.221 2.16663 13.8327 4.7783 13.8327 7.99996C13.8327 11.2216 11.221 13.8333 7.99935 13.8333C4.77769 13.8333 2.16602 11.2216 2.16602 7.99996ZM7.99935 5.12959C8.27549 5.12959 8.49935 5.35345 8.49935 5.62959V7.99996C8.49935 8.2761 8.27549 8.49996 7.99935 8.49996C7.72321 8.49996 7.49935 8.2761 7.49935 7.99996V5.62959C7.49935 5.35345 7.72321 5.12959 7.99935 5.12959ZM7.49935 10.3703C7.49935 10.0942 7.72321 9.87033 7.99935 9.87033H8.00528C8.28142 9.87033 8.50528 10.0942 8.50528 10.3703C8.50528 10.6465 8.28142 10.8703 8.00528 10.8703H7.99935C7.72321 10.8703 7.49935 10.6465 7.49935 10.3703Z"
fill="#77757D"
fill-opacity="0.6"
/>
</svg>`;
export const InsertBelowIcon = html`<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M3.54102 5.41675C3.54102 5.07157 3.82084 4.79175 4.16602 4.79175H15.8327C16.1779 4.79175 16.4577 5.07157 16.4577 5.41675C16.4577 5.76193 16.1779 6.04175 15.8327 6.04175H4.16602C3.82084 6.04175 3.54102 5.76193 3.54102 5.41675ZM3.54102 8.33341C3.54102 7.98824 3.82084 7.70841 4.16602 7.70841H15.8327C16.1779 7.70841 16.4577 7.98824 16.4577 8.33341C16.4577 8.67859 16.1779 8.95842 15.8327 8.95842H4.16602C3.82084 8.95842 3.54102 8.67859 3.54102 8.33341ZM10.8327 10.6251C10.4875 10.6251 10.2077 10.9049 10.2077 11.2501C10.2077 11.5953 10.4875 11.8751 10.8327 11.8751H15.8327C16.1779 11.8751 16.4577 11.5953 16.4577 11.2501C16.4577 10.9049 16.1779 10.6251 15.8327 10.6251H10.8327ZM10.2077 14.1667C10.2077 13.8216 10.4875 13.5417 10.8327 13.5417H15.8327C16.1779 13.5417 16.4577 13.8216 16.4577 14.1667C16.4577 14.5119 16.1779 14.7917 15.8327 14.7917H10.8327C10.4875 14.7917 10.2077 14.5119 10.2077 14.1667ZM5.20768 10.8334C5.20768 10.4882 4.92786 10.2084 4.58268 10.2084C4.2375 10.2084 3.95768 10.4882 3.95768 10.8334V13.3334C3.95768 13.6786 4.2375 13.9584 4.58268 13.9584H6.87435V14.5834C6.87435 14.8362 7.02663 15.0641 7.26017 15.1608C7.49372 15.2576 7.76254 15.2041 7.94129 15.0254L9.19129 13.7754C9.43537 13.5313 9.43537 13.1356 9.19129 12.8915L7.94129 11.6415C7.76254 11.4627 7.49372 11.4093 7.26017 11.506C7.02663 11.6027 6.87435 11.8306 6.87435 12.0834V12.7084H5.20768V10.8334Z"
/>
</svg>`;
export const ResetIcon = html`<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M2.70898 9.99992C2.70898 5.97284 5.97357 2.70825 10.0007 2.70825C11.8678 2.70825 13.5723 3.41084 14.8619 4.56508L14.8639 4.56687L16.0423 5.63071V3.33325C16.0423 2.98807 16.3221 2.70825 16.6673 2.70825C17.0125 2.70825 17.2923 2.98807 17.2923 3.33325V7.03696C17.2923 7.38213 17.0125 7.66196 16.6673 7.66196H12.9636C12.6184 7.66196 12.3386 7.38213 12.3386 7.03696C12.3386 6.69178 12.6184 6.41196 12.9636 6.41196H15.0423L14.0283 5.4965C14.0279 5.4962 14.0276 5.49591 14.0273 5.49561C12.9581 4.53909 11.5479 3.95825 10.0007 3.95825C6.66393 3.95825 3.95898 6.6632 3.95898 9.99992C3.95898 13.3366 6.66393 16.0416 10.0007 16.0416C12.8751 16.0416 15.2821 14.0335 15.8926 11.3431C15.969 11.0065 16.3038 10.7955 16.6404 10.8719C16.977 10.9483 17.188 11.2831 17.1116 11.6197C16.3748 14.867 13.4716 17.2916 10.0007 17.2916C5.97357 17.2916 2.70898 14.027 2.70898 9.99992Z"
/>
</svg>`;
export const ReplaceIcon = html`<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M14.8132 11.2542C14.6939 11.3852 14.5246 11.4594 14.3474 11.4583C14.1702 11.4572 14.0018 11.381 13.8841 11.2485L12.0322 9.16519C11.8029 8.9072 11.8261 8.51215 12.0841 8.28283C12.3421 8.0535 12.7372 8.07674 12.9665 8.33473L13.7262 9.18942V4.99996C13.7262 4.8849 13.6329 4.79163 13.5179 4.79163H8.33269C7.98751 4.79163 7.70769 4.5118 7.70769 4.16663C7.70769 3.82145 7.98751 3.54163 8.33269 3.54163H13.5179C14.3233 3.54163 14.9762 4.19454 14.9762 4.99996V9.21932L15.7874 8.32903C16.0198 8.07387 16.4151 8.05549 16.6703 8.28796C16.9254 8.52044 16.9438 8.91574 16.7114 9.17089L14.8132 11.2542ZM5.60217 8.7457C5.7215 8.61472 5.89082 8.54055 6.068 8.54164C6.24519 8.54273 6.41358 8.61897 6.5313 8.7514L8.38315 10.8347C8.61247 11.0927 8.58924 11.4878 8.33125 11.7171C8.07326 11.9464 7.67821 11.9232 7.44889 11.6652L6.68917 10.8105V15C6.68917 15.115 6.78244 15.2083 6.8975 15.2083H12.0827C12.4279 15.2083 12.7077 15.4881 12.7077 15.8333C12.7077 16.1785 12.4279 16.4583 12.0827 16.4583H6.8975C6.09208 16.4583 5.43917 15.8054 5.43917 15V10.7806L4.62802 11.6709C4.39554 11.926 4.00024 11.9444 3.74509 11.712C3.48993 11.4795 3.47155 11.0842 3.70402 10.829L5.60217 8.7457Z"
/>
</svg>`;
@@ -0,0 +1,6 @@
import { svg } from 'lit';
import { icon } from './utils.js';
const TagsSVG = svg`<path fill-rule="evenodd" clip-rule="evenodd" d="M8.26257 2.48082C7.85697 2.49584 7.54034 2.83683 7.55536 3.24243C7.57038 3.64803 7.91137 3.96466 8.31697 3.94964L12.5926 3.79128C12.9329 3.77867 13.2632 3.90835 13.504 4.14919L19.8106 10.4558C20.0976 10.7428 20.5629 10.7428 20.8499 10.4558C21.1369 10.1688 21.1369 9.70348 20.8499 9.41648L14.5433 3.10987C14.0135 2.58003 13.287 2.29473 12.5382 2.32247L8.26257 2.48082ZM7.72668 6.72224C6.0604 6.78396 4.72384 8.12053 4.66212 9.7868L4.53585 13.1963C4.52324 13.5366 4.65292 13.8669 4.89376 14.1077L10.5116 19.7256C10.99 20.2039 11.7655 20.2039 12.2438 19.7256L17.6655 14.304C18.1438 13.8256 18.1438 13.0501 17.6655 12.5718L12.0476 6.95388C11.8068 6.71304 11.4765 6.58336 11.1362 6.59597L7.72668 6.72224ZM3.19331 9.7324C3.28351 7.29707 5.23695 5.34363 7.67228 5.25343L11.0818 5.12716C11.8305 5.09942 12.5571 5.38472 13.0869 5.91456L18.7048 11.5324C19.7571 12.5848 19.7571 14.2909 18.7048 15.3433L13.2832 20.7649C12.2308 21.8173 10.5246 21.8173 9.47232 20.7649L3.85444 15.147C3.3246 14.6172 3.0393 13.8907 3.06703 13.1419L3.19331 9.7324ZM7.8664 8.75593C8.18961 9.07914 8.18961 9.60317 7.8664 9.92638C7.54319 10.2496 7.01916 10.2496 6.69595 9.92638C6.37274 9.60317 6.37274 9.07914 6.69595 8.75593C7.01916 8.43272 7.54319 8.43272 7.8664 8.75593Z" fill="currentColor"/>`;
export const TagsIcon = icon(TagsSVG, 16);
@@ -0,0 +1,922 @@
import * as icons from '@blocksuite/icons/lit';
import { html, svg } from 'lit';
import { icon } from './utils.js';
// Paragraph icons
export const TextIcon = icons.TextIcon({
width: '20',
height: '20',
});
export const HeadingIcon = icons.HeadingsIcon({
width: '20',
height: '20',
});
export const Heading1Icon = icons.Heading1Icon({
width: '20',
height: '20',
});
export const Heading2Icon = icons.Heading2Icon({
width: '20',
height: '20',
});
export const Heading3Icon = icons.Heading3Icon({
width: '20',
height: '20',
});
export const Heading4Icon = icons.Heading4Icon({
width: '20',
height: '20',
});
export const Heading5Icon = icons.Heading5Icon({
width: '20',
height: '20',
});
export const Heading6Icon = icons.Heading6Icon({
width: '20',
height: '20',
});
export const BulletedListIcon = icons.BulletedListIcon({
width: '20',
height: '20',
});
export const NumberedListIcon = icons.NumberedListIcon({
width: '20',
height: '20',
});
/**
* Size 24
*
* See also {@link DatabaseTableViewIcon20}
*/
export const DatabaseTableViewIcon = icons.DatabaseTableViewIcon({
width: '24',
height: '24',
});
export const DatabaseTableViewIcon20 = icons.DatabaseTableViewIcon({
width: '20',
height: '20',
});
/**
* Size 24
*
* See also {@link DatabaseKanbanViewIcon20}
*/
export const DatabaseKanbanViewIcon = icons.DatabaseKanbanViewIcon({
width: '24',
height: '24',
});
export const DatabaseKanbanViewIcon20 = icons.DatabaseKanbanViewIcon({
width: '20',
height: '20',
});
export const CheckBoxIcon = icons.CheckBoxCheckLinearIcon({
width: '20',
height: '20',
});
export const CodeBlockIcon = icons.CodeBlockIcon({
width: '20',
height: '20',
});
export const QuoteIcon = icons.QuoteIcon({
width: '20',
height: '20',
});
export const DividerIcon = icons.DividerIcon({
width: '20',
height: '20',
});
// Format icons
export const BoldIcon = icons.BoldIcon({
width: '20',
height: '20',
});
export const ItalicIcon = icons.ItalicIcon({
width: '20',
height: '20',
});
export const UnderlineIcon = icons.UnderLineIcon({
width: '20',
height: '20',
});
export const StrikethroughIcon = icons.StrikeThroughIcon({
width: '20',
height: '20',
});
export const CodeIcon = icons.CodeIcon({
width: '20',
height: '20',
});
export const LinkIcon = icons.LinkIcon({
width: '20',
height: '20',
});
// Slash menu action icons
export const CopyIcon = icons.CopyIcon({
width: '20',
height: '20',
});
export const PasteIcon = icons.PasteIcon({
width: '20',
height: '20',
});
export const DuplicateIcon = icons.DuplicateIcon({
width: '20',
height: '20',
});
export const DeleteIcon = icons.DeleteIcon({
width: '20',
height: '20',
});
// Date & Time icons
export const TodayIcon = icons.TodayIcon({
width: '20',
height: '20',
});
export const TomorrowIcon = icons.TomorrowIcon({
width: '20',
height: '20',
});
export const YesterdayIcon = icons.YesterdayIcon({
width: '20',
height: '20',
});
export const NowIcon = icons.NowIcon({
width: '20',
height: '20',
});
// Misc icons
export const CrossIcon = icons.CloseIcon({
width: '24',
height: '24',
});
export const SearchIcon = icons.SearchIcon({
width: '20',
height: '20',
});
export const RefreshIcon = icons.ResetIcon({
width: '20',
height: '20',
});
export const WebIcon16 = icons.PublishIcon({
width: '16',
height: '16',
});
// Link Icon
/**
* ✅
*/
export const ConfirmIcon = icons.DoneIcon({
width: '20',
height: '20',
});
/**
* 🖊️
*/
export const OpenIcon = icons.OpenInNewIcon({
width: '20',
height: '20',
});
export const CenterPeekIcon = icons.CenterPeekIcon({
width: '20',
height: '20',
});
export const ExpandFullSmallIcon = icons.ExpandFullIcon({
width: '20',
height: '20',
});
export const SplitViewIcon = icons.SplitViewIcon({
width: '20',
height: '20',
});
export const EditIcon = icons.EditIcon({
width: '20',
height: '20',
});
export const UnlinkIcon = icons.UnlinkIcon({
width: '20',
height: '20',
});
// Image Icon
export const CaptionIcon = icons.CaptionIcon({
width: '20',
height: '20',
});
export const DownloadIcon = icons.DownloadIcon({
width: '20',
height: '20',
});
export const WrapIcon = icons.WrapIcon({
width: '20',
height: '20',
});
export const CancelWrapIcon = icons.CancelWrapIcon({
width: '20',
height: '20',
});
// Attachment
export const ViewIcon = icons.ViewIcon({
width: '20',
height: '20',
});
export const EmbedWebIcon = icons.EmbedWebIcon({
width: '20',
height: '20',
});
export const ArrowDownIcon = icons.ArrowDownSmallIcon({
width: '20',
height: '20',
});
// Linked Doc
export const FontDocIcon = icons.PageIcon({
width: '1.25em',
height: '1.25em',
style: 'vertical-align: middle; font-size: inherit; margin-bottom: 0.1em;',
});
export const DocIcon = icons.PageIcon({
width: '20',
height: '20',
});
export const SmallDocIcon = icons.PageIcon({
width: '16',
height: '16',
});
export const FontLinkedDocIcon = icons.LinkedPageIcon({
width: '1.25em',
height: '1.25em',
style: 'vertical-align: middle; font-size: inherit; margin-bottom: 0.1em;',
});
export const BlockLinkIcon = icons.BlockLinkIcon({
width: '1.25em',
height: '1.25em',
style: 'vertical-align: middle; font-size: inherit; margin-bottom: 0.1em;',
});
export const LinkedEdgelessIcon = icons.LinkedEdgelessIcon({
width: '20',
height: '20',
});
export const NewDocIcon = icons.PlusIcon({
width: '20',
height: '20',
});
export const DualLinkIcon16 = icons.DualLinkIcon({
width: '16',
height: '16',
});
export const ArrowDownSmallIcon = icons.ArrowDownSmallIcon({
width: '24',
height: '24',
});
export const AddCursorIcon = icons.PlusIcon({
width: '24',
height: '24',
});
export const FontFamilyIcon = icons.FontIcon({
width: '20',
height: '20',
});
export const AttachmentIcon = icons.AttachmentIcon({
width: '20',
height: '20',
});
export const AttachmentIcon16 = icons.AttachmentIcon({
width: '16',
height: '16',
});
export const TextBackgroundDuotoneIcon = html` <svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M2.62503 4.58336C2.62503 3.50181 3.50181 2.62503 4.58336 2.62503H15.4167C16.4983 2.62503 17.375 3.50181 17.375 4.58336V15.4167C17.375 16.4983 16.4983 17.375 15.4167 17.375H4.58336C3.50181 17.375 2.62503 16.4983 2.62503 15.4167V4.58336Z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M2.625 4.58333C2.625 3.50178 3.50178 2.625 4.58333 2.625H15.4167C16.4982 2.625 17.375 3.50178 17.375 4.58333V15.4167C17.375 16.4982 16.4982 17.375 15.4167 17.375H4.58333C3.50178 17.375 2.625 16.4982 2.625 15.4167V4.58333ZM4.58333 3.20833C3.82394 3.20833 3.20833 3.82394 3.20833 4.58333V15.4167C3.20833 16.1761 3.82394 16.7917 4.58333 16.7917H15.4167C16.1761 16.7917 16.7917 16.1761 16.7917 15.4167V4.58333C16.7917 3.82394 16.1761 3.20833 15.4167 3.20833H4.58333Z"
fill="black"
fill-opacity="0.3"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M8.85183 6.254C9.03131 5.77539 9.48885 5.45831 10 5.45831C10.5112 5.45831 10.9687 5.77539 11.1482 6.254L13.9185 13.6416C14.0397 13.9648 13.876 14.3251 13.5528 14.4463C13.2296 14.5675 12.8693 14.4037 12.7481 14.0805L11.9654 11.9932H8.03465L7.25188 14.0805C7.13068 14.4037 6.77042 14.5675 6.44722 14.4463C6.12402 14.3251 5.96027 13.9648 6.08147 13.6416L8.85183 6.254ZM8.5034 10.7432H11.4966L10 6.7522L8.5034 10.7432Z"
fill="var(--affine-text-primary-color)"
/>
</svg>`;
export const TextForegroundDuotoneIcon = html` <svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M2.625 4.58333C2.625 3.50178 3.50178 2.625 4.58333 2.625H15.4167C16.4982 2.625 17.375 3.50178 17.375 4.58333V15.4167C17.375 16.4982 16.4982 17.375 15.4167 17.375H4.58333C3.50178 17.375 2.625 16.4982 2.625 15.4167V4.58333ZM4.58333 3.20833C3.82394 3.20833 3.20833 3.82394 3.20833 4.58333V15.4167C3.20833 16.1761 3.82394 16.7917 4.58333 16.7917H15.4167C16.1761 16.7917 16.7917 16.1761 16.7917 15.4167V4.58333C16.7917 3.82394 16.1761 3.20833 15.4167 3.20833H4.58333Z"
fill="black"
fill-opacity="0.3"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M8.85183 6.254C9.03131 5.77539 9.48885 5.45831 10 5.45831C10.5112 5.45831 10.9687 5.77539 11.1482 6.254L13.9185 13.6416C14.0397 13.9648 13.876 14.3251 13.5528 14.4463C13.2296 14.5675 12.8693 14.4037 12.7481 14.0805L11.9654 11.9932H8.03465L7.25188 14.0805C7.13068 14.4037 6.77042 14.5675 6.44722 14.4463C6.12402 14.3251 5.96027 13.9648 6.08147 13.6416L8.85183 6.254ZM8.5034 10.7432H11.4966L10 6.7522L8.5034 10.7432Z"
fill="currentColor"
/>
</svg>`;
const HighLightDuotoneSVG = svg`
<path d="M5.82912 16.3197L7.91758 18.4082L6.57812 19.7476C6.53121 19.7945 6.46407 19.8151 6.39891 19.8026L3.37038 19.2199C3.21286 19.1896 3.15332 18.9955 3.26674 18.8821L5.82912 16.3197Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.0095 3.51624C17.9526 2.45932 16.26 2.39465 15.1255 3.36783L7.3214 10.0624C6.35443 10.8919 6.0528 12.261 6.58168 13.42L6.73506 13.7562L5.67702 14.8142C5.30174 15.1895 5.30174 15.7979 5.67702 16.1732L8.06384 18.56C8.43912 18.9353 9.04757 18.9353 9.42285 18.56L10.4809 17.502L10.8171 17.6555C11.9761 18.1843 13.3453 17.8827 14.1747 16.9157L20.8693 9.11159C21.8425 7.9771 21.7778 6.28452 20.7209 5.22761L19.0095 3.51624ZM16.1022 4.50634C16.6416 4.04362 17.4463 4.07437 17.9489 4.5769L19.6602 6.28827C20.1628 6.79079 20.1935 7.59555 19.7308 8.13496L14.6424 14.0667L10.1705 9.59469L16.1022 4.50634ZM9.02862 10.5742L8.29803 11.2009C7.83827 11.5953 7.69486 12.2462 7.94632 12.7973L8.2979 13.5678C8.43485 13.8679 8.37953 14.233 8.13377 14.4788L7.11884 15.4937L8.74335 17.1182L9.75818 16.1034C10.004 15.8576 10.3692 15.8023 10.6693 15.9392L11.4398 16.2908C11.9909 16.5423 12.6419 16.3989 13.0362 15.9391L13.663 15.2085L9.02862 10.5742Z" fill="#77757D"/>
`;
export const HighLightDuotoneIcon = icon(HighLightDuotoneSVG, 20);
export const ArrowRightBigIcon = icons.ArrowRightBigIcon({
width: '24',
height: '24',
});
export const ArrowLeftBigIcon = icons.ArrowLeftBigIcon({
width: '24',
height: '24',
});
export const ExpandWideIcon = icons.ExpandWideIcon({
width: '24',
height: '24',
});
export const ExpandFullIcon = icons.ExpandFullIcon({
width: '24',
height: '24',
});
export const ExpandCloseIcon = icons.ExpandCloseIcon({
width: '24',
height: '24',
});
export const MoveLeftIcon = icons.MoveLeftIcon({
width: '24',
height: '24',
});
export const MoveRightIcon = icons.MoveRightIcon({
width: '24',
height: '24',
});
export const ArrowUpBigIcon = icons.ArrowUpBigIcon({
width: '20',
height: '20',
});
export const ArrowDownBigIcon = icons.ArrowDownBigIcon({
width: '20',
height: '20',
});
export const PaletteIcon = icons.PaletteIcon({
width: '20',
height: '20',
});
const LoadingIcon = (color: string) => {
return html`<svg
width="16"
height="16"
viewBox="0 0 16 16"
xmlns="http://www.w3.org/2000/svg"
>
<style xmlns="http://www.w3.org/2000/svg">
.spinner {
transform-origin: center;
animation: spinner_animate 0.75s infinite linear;
}
@keyframes spinner_animate {
100% {
transform: rotate(360deg);
}
}
</style>
<path
d="M14.6666 8.00004C14.6666 11.6819 11.6818 14.6667 7.99992 14.6667C4.31802 14.6667 1.33325 11.6819 1.33325 8.00004C1.33325 4.31814 4.31802 1.33337 7.99992 1.33337C11.6818 1.33337 14.6666 4.31814 14.6666 8.00004ZM3.30003 8.00004C3.30003 10.5957 5.40424 12.6999 7.99992 12.6999C10.5956 12.6999 12.6998 10.5957 12.6998 8.00004C12.6998 5.40436 10.5956 3.30015 7.99992 3.30015C5.40424 3.30015 3.30003 5.40436 3.30003 8.00004Z"
fill=${color}
fill-opacity="0.1"
/>
<path
d="M13.6833 8.00004C14.2263 8.00004 14.674 7.55745 14.5942 7.02026C14.5142 6.48183 14.3684 5.954 14.1591 5.44882C13.8241 4.63998 13.333 3.90505 12.714 3.286C12.0949 2.66694 11.36 2.17588 10.5511 1.84084C10.046 1.63159 9.51812 1.48576 8.9797 1.40576C8.44251 1.32595 7.99992 1.77363 7.99992 2.31671C7.99992 2.85979 8.44486 3.28974 8.9761 3.40253C9.25681 3.46214 9.53214 3.54746 9.79853 3.65781C10.3688 3.894 10.8869 4.2402 11.3233 4.67664C11.7598 5.11307 12.106 5.6312 12.3422 6.20143C12.4525 6.46782 12.5378 6.74315 12.5974 7.02386C12.7102 7.5551 13.1402 8.00004 13.6833 8.00004Z"
fill="#1C9EE4"
class="spinner"
/>
</svg>`;
};
export const LightLoadingIcon = LoadingIcon('white');
export const DarkLoadingIcon = LoadingIcon('black');
export const EmbedCardLightBannerIcon = html`<svg
width="340"
height="170"
viewBox="0 0 340 170"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M0.000108291 4C4.84837e-05 1.79086 1.79086 0 4 0H336C338.209 0 340 1.79086 340 4L340.005 170H0.00460238L0.000108291 4Z"
fill="#F4F4F5"
/>
<path
d="M47.4226 181.578L133.723 53.5251C136.164 49.904 141.057 48.9089 144.718 51.2892L345.111 181.578H47.4226Z"
fill="#C0BFC1"
/>
<path
d="M0.00305283 184.375L71.1716 78.1816C73.6115 74.5409 78.5267 73.5413 82.195 75.9397L248.044 184.375H0.00305283Z"
fill="#E3E2E4"
/>
<ellipse
cx="19.6135"
cy="19.8036"
rx="19.6135"
ry="19.8036"
transform="matrix(1 0 2.70729e-05 1 38 17)"
fill="#C0BFC1"
/>
</svg>`;
export const EmbedCardDarkBannerIcon = html`<svg
width="340"
height="170"
viewBox="0 0 340 170"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M0.000108291 4C4.84837e-05 1.79086 1.79086 0 4 0H336C338.209 0 340 1.79086 340 4L340.005 170H0.00460238L0.000108291 4Z"
fill="#252525"
/>
<path
d="M47.4226 181.578L133.723 53.5251C136.164 49.904 141.057 48.9089 144.718 51.2892L345.111 181.578H47.4226Z"
fill="#3E3E3F"
/>
<path
d="M0.00305283 184.375L71.1716 78.1816C73.6115 74.5409 78.5267 73.5413 82.195 75.9397L248.044 184.375H0.00305283Z"
fill="#727272"
/>
<ellipse
cx="19.6135"
cy="19.8036"
rx="19.6135"
ry="19.8036"
transform="matrix(1 0 2.70729e-05 1 38 17)"
fill="#3E3E3F"
/>
</svg>`;
export const EmbedCardLightHorizontalIcon = html`
<svg
width="70"
height="32"
viewBox="0 0 70 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="0.25" y="0.25" width="69.5" height="31.5" rx="3.75" fill="white" />
<rect
x="0.25"
y="0.25"
width="69.5"
height="31.5"
rx="3.75"
stroke="#E3E2E4"
stroke-width="0.5"
/>
<rect x="4" y="4" width="21" height="3" rx="1.5" fill="#DBDBDB" />
<rect x="4" y="11" width="34.1467" height="2" rx="1" fill="#E9E9E9" />
<rect x="4" y="17" width="34.1467" height="2" rx="1" fill="#E9E9E9" />
<rect x="4" y="23" width="30" height="2" rx="1" fill="#E9E9E9" />
<g clip-path="url(#clip0_8629_12484)">
<rect
width="23.8527"
height="24"
rx="2"
transform="matrix(1 0 2.70729e-05 1 42.1467 4)"
fill="#EDEDED"
/>
<path
d="M47.0005 30.0001L57.6988 14.2824C58.6142 12.9375 60.4347 12.5674 61.8027 13.448L87.5143 30.0001H47.0005Z"
fill="#CCCCCC"
/>
<circle
cx="2.10328"
cy="2.10328"
r="2.10328"
transform="matrix(1 0 2.70729e-05 1 47.7163 9)"
fill="#CCCCCC"
/>
</g>
<defs>
<clipPath id="clip0_8629_12484">
<rect
width="23.8527"
height="24"
rx="2"
transform="matrix(1 0 2.70729e-05 1 42.1467 4)"
fill="white"
/>
</clipPath>
</defs>
</svg>
`;
export const EmbedCardDarkHorizontalIcon = html`
<svg
width="70"
height="32"
viewBox="0 0 70 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="0.25"
y="0.25"
width="69.5"
height="31.5"
rx="3.75"
fill="#141414"
/>
<rect
x="0.25"
y="0.25"
width="69.5"
height="31.5"
rx="3.75"
stroke="#484848"
stroke-width="0.5"
/>
<rect x="4" y="4" width="21" height="3" rx="1.5" fill="#4A4A4A" />
<rect x="4" y="11" width="34.1467" height="2" rx="1" fill="#323232" />
<rect x="4" y="17" width="34.1467" height="2" rx="1" fill="#323232" />
<rect x="4" y="23" width="30" height="2" rx="1" fill="#323232" />
<g clip-path="url(#clip0_8635_14321)">
<rect
width="23.8527"
height="24"
rx="2"
transform="matrix(1 0 2.70729e-05 1 42.1467 4)"
fill="#2E2E2E"
/>
<path
d="M47.0005 30.0001L57.6988 14.2824C58.6142 12.9375 60.4347 12.5674 61.8027 13.448L87.5143 30.0001H47.0005Z"
fill="#5D5D5D"
/>
<circle
cx="2.10328"
cy="2.10328"
r="2.10328"
transform="matrix(1 0 2.70729e-05 1 47.7163 9)"
fill="#5D5D5D"
/>
</g>
<defs>
<clipPath id="clip0_8635_14321">
<rect
width="23.8527"
height="24"
rx="2"
transform="matrix(1 0 2.70729e-05 1 42.1467 4)"
fill="white"
/>
</clipPath>
</defs>
</svg>
`;
export const EmbedCardLightListIcon = html`
<svg
width="70"
height="14"
viewBox="0 0 70 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="0.25" y="0.25" width="69.5" height="13.5" rx="2.75" fill="white" />
<rect
x="0.25"
y="0.25"
width="69.5"
height="13.5"
rx="2.75"
stroke="#E3E2E4"
stroke-width="0.5"
/>
<circle cx="6.5" cy="7" r="2.5" fill="#D9D9D9" />
<rect x="13" y="5" width="53" height="4" rx="2" fill="#DBDBDB" />
</svg>
`;
export const EmbedCardDarkListIcon = html`
<svg
width="70"
height="14"
viewBox="0 0 70 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="0.25"
y="0.25"
width="69.5"
height="13.5"
rx="2.75"
fill="#141414"
/>
<rect
x="0.25"
y="0.25"
width="69.5"
height="13.5"
rx="2.75"
stroke="#484848"
stroke-width="0.5"
/>
<circle cx="6.5" cy="7" r="2.5" fill="#4A4A4A" />
<rect x="13" y="5" width="53" height="4" rx="2" fill="#4A4A4A" />
</svg>
`;
export const EmbedCardLightVerticalIcon = html`
<svg
width="60"
height="66"
viewBox="0 0 60 66"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="0.25"
y="1.10718"
width="59.5"
height="63.7857"
rx="3.75"
fill="white"
/>
<rect
x="0.25"
y="1.10718"
width="59.5"
height="63.7857"
rx="3.75"
stroke="#E3E2E4"
stroke-width="0.5"
/>
<g clip-path="url(#clip0_8629_12498)">
<path
d="M3.00005 5.85718C3.00002 4.75261 3.89543 3.85718 5 3.85718H55C56.1046 3.85718 57 4.75261 57.0001 5.85718L57.0008 34.7143H3.00084L3.00005 5.85718Z"
fill="#EDEDED"
/>
<path
d="M18.0007 37.3179L33.5882 14.4172C34.5036 13.0723 36.3241 12.7021 37.692 13.5828L74.5616 37.3179H18.0007Z"
fill="#CCCCCC"
/>
<circle
cx="2.93637"
cy="2.93637"
r="2.93637"
transform="matrix(1 0 2.70729e-05 1 19 8)"
fill="#CCCCCC"
/>
</g>
<rect x="3" y="38.7144" width="30" height="3" rx="1.5" fill="#DBDBDB" />
<rect x="3" y="45.7144" width="54" height="3" rx="1.5" fill="#E9E9E9" />
<rect x="3" y="52.7144" width="54" height="3" rx="1.5" fill="#E9E9E9" />
<defs>
<clipPath id="clip0_8629_12498">
<path
d="M3.00005 5.85718C3.00002 4.75261 3.89543 3.85718 5 3.85718H55C56.1046 3.85718 57 4.75261 57.0001 5.85718L57.0008 34.7143H3.00084L3.00005 5.85718Z"
fill="white"
/>
</clipPath>
</defs>
</svg>
`;
export const EmbedCardDarkVerticalIcon = html`
<svg
width="60"
height="66"
viewBox="0 0 60 66"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="0.25"
y="1.10718"
width="59.5"
height="63.7857"
rx="3.75"
fill="#141414"
/>
<rect
x="0.25"
y="1.10718"
width="59.5"
height="63.7857"
rx="3.75"
stroke="#484848"
stroke-width="0.5"
/>
<g clip-path="url(#clip0_8635_14335)">
<path
d="M3.00005 5.85718C3.00002 4.75261 3.89543 3.85718 5 3.85718H55C56.1046 3.85718 57 4.75261 57.0001 5.85718L57.0008 34.7143H3.00084L3.00005 5.85718Z"
fill="#2E2E2E"
/>
<path
d="M18.0007 37.3179L33.5882 14.4172C34.5036 13.0723 36.3241 12.7021 37.692 13.5828L74.5616 37.3179H18.0007Z"
fill="#5D5D5D"
/>
<circle
cx="2.93637"
cy="2.93637"
r="2.93637"
transform="matrix(1 0 2.70729e-05 1 19 8)"
fill="#5D5D5D"
/>
</g>
<rect x="3" y="38.7144" width="30" height="3" rx="1.5" fill="#4A4A4A" />
<rect x="3" y="45.7144" width="54" height="3" rx="1.5" fill="#323232" />
<rect x="3" y="52.7144" width="54" height="3" rx="1.5" fill="#323232" />
<defs>
<clipPath id="clip0_8635_14335">
<path
d="M3.00005 5.85718C3.00002 4.75261 3.89543 3.85718 5 3.85718H55C56.1046 3.85718 57 4.75261 57.0001 5.85718L57.0008 34.7143H3.00084L3.00005 5.85718Z"
fill="white"
/>
</clipPath>
</defs>
</svg>
`;
export const EmbedCardLightCubeIcon = html`
<svg
width="38"
height="30"
viewBox="0 0 38 30"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="0.25" y="0.25" width="37.5" height="29.5" rx="3.75" fill="white" />
<rect
x="0.25"
y="0.25"
width="37.5"
height="29.5"
rx="3.75"
stroke="#E3E2E4"
stroke-width="0.5"
/>
<circle cx="6.5" cy="6.5" r="2.5" fill="#D9D9D9" />
<rect x="4" y="13" width="30" height="3" rx="1.5" fill="#E9E9E9" />
<rect x="4" y="20" width="30" height="3" rx="1.5" fill="#E9E9E9" />
</svg>
`;
export const EmbedCardDarkCubeIcon = html`
<svg
width="38"
height="30"
viewBox="0 0 38 30"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="0.25"
y="0.25"
width="37.5"
height="29.5"
rx="3.75"
fill="#141414"
/>
<rect
x="0.25"
y="0.25"
width="37.5"
height="29.5"
rx="3.75"
stroke="#484848"
stroke-width="0.5"
/>
<circle cx="6.5" cy="6.5" r="2.5" fill="#4A4A4A" />
<rect x="4" y="13" width="30" height="3" rx="1.5" fill="#4A4A4A" />
<rect x="4" y="20" width="30" height="3" rx="1.5" fill="#4A4A4A" />
</svg>
`;
export const ReloadIcon = html`<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clip0_6505_24239)">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M1.625 6C1.625 3.58375 3.58375 1.625 6 1.625C7.12028 1.625 8.14299 2.04656 8.91676 2.7391L8.91796 2.74017L9.625 3.37847V2C9.625 1.79289 9.79289 1.625 10 1.625C10.2071 1.625 10.375 1.79289 10.375 2V4.22222C10.375 4.42933 10.2071 4.59722 10 4.59722H7.77778C7.57067 4.59722 7.40278 4.42933 7.40278 4.22222C7.40278 4.01512 7.57067 3.84722 7.77778 3.84722H9.025L8.41657 3.29795C8.41637 3.29777 8.41617 3.29759 8.41597 3.29741C7.77447 2.7235 6.92838 2.375 6 2.375C3.99797 2.375 2.375 3.99797 2.375 6C2.375 8.00203 3.99797 9.625 6 9.625C7.72469 9.625 9.16888 8.42017 9.53518 6.80591C9.58101 6.60393 9.78189 6.47736 9.98386 6.52319C10.1858 6.56902 10.3124 6.7699 10.2666 6.97187C9.82447 8.92025 8.08257 10.375 6 10.375C3.58375 10.375 1.625 8.41625 1.625 6Z"
fill="#1E96EB"
/>
</g>
<defs>
<clipPath id="clip0_6505_24239">
<rect width="12" height="12" fill="white" />
</clipPath>
</defs>
</svg>`;
export const EmbedPageIcon = icons.LinkedPageIcon({
width: '16',
height: '16',
});
export const EmbedEdgelessIcon = icons.LinkedEdgelessIcon({
width: '16',
height: '16',
});
export const LinkedDocIcon = icons.LinkedPageIcon({
width: '20',
height: '20',
});
@@ -0,0 +1,12 @@
import { html, type TemplateResult } from 'lit';
export function icon(svg: TemplateResult<2>, size = 24) {
return html`<svg
width="${size}"
height="${size}"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
${svg}
</svg>`;
}
@@ -0,0 +1 @@
export {};
@@ -0,0 +1 @@
export * from './linked-doc.js';
@@ -0,0 +1,70 @@
import { NotificationProvider } from '@blocksuite/affine-shared/services';
import type { BlockStdScope } from '@blocksuite/block-std';
import { toast } from '../toast/toast.js';
function notify(std: BlockStdScope, title: string, message: string) {
const notification = std.getOptional(NotificationProvider);
const { doc, host } = std;
if (!notification) {
toast(host, title);
return;
}
const abortController = new AbortController();
const clear = () => {
doc.history.off('stack-item-added', addHandler);
doc.history.off('stack-item-popped', popHandler);
disposable.dispose();
};
const closeNotify = () => {
abortController.abort();
clear();
};
// edit or undo or switch doc, close notify toast
const addHandler = doc.history.on('stack-item-added', closeNotify);
const popHandler = doc.history.on('stack-item-popped', closeNotify);
const disposable = host.slots.unmounted.on(closeNotify);
notification.notify({
title,
message,
accent: 'info',
duration: 10 * 1000,
action: {
label: 'Undo',
onClick: () => {
doc.undo();
clear();
},
},
abort: abortController.signal,
onClose: clear,
});
}
export function notifyLinkedDocSwitchedToCard(std: BlockStdScope) {
notify(
std,
'View Updated',
'The alias modification has disabled sync. The embed has been updated to a card view.'
);
}
export function notifyLinkedDocSwitchedToEmbed(std: BlockStdScope) {
notify(
std,
'Embed View Restored',
'Custom alias removed. The linked doc now displays the original title and description.'
);
}
export function notifyLinkedDocClearedAliases(std: BlockStdScope) {
notify(
std,
'Reset successful',
`Card view has been restored to original doc title and description. All custom aliases have been removed.`
);
}
@@ -0,0 +1,55 @@
/// <reference types="@blocksuite/affine-shared/commands" />
import type {
BlockComponent,
Command,
InitCommandCtx,
} from '@blocksuite/block-std';
import { isPeekable, peek } from './peekable.js';
const getSelectedPeekableBlocks = (cmd: InitCommandCtx) => {
const [result, ctx] = cmd.std.command
.chain()
.tryAll(chain => [chain.getTextSelection(), chain.getBlockSelections()])
.getSelectedBlocks({ types: ['text', 'block'] })
.run();
return ((result ? ctx.selectedBlocks : []) || []).filter(isPeekable);
};
export const getSelectedPeekableBlocksCommand: Command<
'selectedBlocks',
'selectedPeekableBlocks'
> = (ctx, next) => {
const selectedPeekableBlocks = getSelectedPeekableBlocks(ctx);
if (selectedPeekableBlocks.length > 0) {
next({ selectedPeekableBlocks });
}
};
export const peekSelectedBlockCommand: Command<'selectedBlocks'> = (
ctx,
next
) => {
const peekableBlocks = getSelectedPeekableBlocks(ctx);
// if there are multiple blocks, peek the first one
const block = peekableBlocks.at(0);
if (block) {
peek(block);
next();
}
};
declare global {
namespace BlockSuite {
interface CommandContext {
selectedPeekableBlocks?: BlockComponent[];
}
interface Commands {
peekSelectedBlock: typeof peekSelectedBlockCommand;
getSelectedPeekableBlocks: typeof getSelectedPeekableBlocksCommand;
// todo: add command for peek an inline element?
}
}
}
@@ -0,0 +1,31 @@
import type { TemplateResult } from 'lit';
import { PeekViewProvider } from './service.js';
import type { PeekableClass, PeekViewService } from './type.js';
export class PeekableController<T extends PeekableClass> {
private _getPeekViewService = (): PeekViewService | null => {
return this.target.std.getOptional(PeekViewProvider);
};
peek = (template?: TemplateResult) => {
return Promise.resolve<void>(
this._getPeekViewService()?.peek({
target: this.target,
template,
})
);
};
get peekable() {
return (
!!this._getPeekViewService() &&
(this.enable ? this.enable(this.target) : true)
);
}
constructor(
private target: T,
private enable?: (e: T) => boolean
) {}
}
@@ -0,0 +1,8 @@
export {
getSelectedPeekableBlocksCommand,
peekSelectedBlockCommand,
} from './commands.js';
export { PeekableController } from './controller.js';
export { isPeekable, peek, Peekable } from './peekable.js';
export * from './service.js';
export type { PeekableOptions, PeekOptions, PeekViewService } from './type.js';
@@ -0,0 +1,81 @@
import { isInsideEdgelessEditor } from '@blocksuite/affine-shared/utils';
import type { Constructor } from '@blocksuite/global/utils';
import type { LitElement, TemplateResult } from 'lit';
import { PeekableController } from './controller.js';
import type { PeekableClass, PeekableOptions } from './type.js';
const symbol = Symbol('peekable');
export const isPeekable = <Element extends LitElement>(e: Element): boolean => {
return Reflect.has(e, symbol) && (e as any)[symbol]?.peekable;
};
export const peek = <Element extends LitElement>(
e: Element,
template?: TemplateResult
): void => {
isPeekable(e) && (e as any)[symbol]?.peek(template);
};
/**
* Mark a class as peekable, which means the class can be peeked by the peek view service.
*
* Note: This class must be syntactically below the `@customElement` decorator (it will be applied before customElement).
*/
export const Peekable =
<T extends PeekableClass, C extends Constructor<PeekableClass>>(
options: PeekableOptions<T> = {
action: ['double-click', 'shift-click'],
}
) =>
(Class: C, context: ClassDecoratorContext) => {
if (context.kind !== 'class') {
console.error('@Peekable() can only be applied to a class');
return;
}
if (options.action === undefined)
options.action = ['double-click', 'shift-click'];
const actions = Array.isArray(options.action)
? options.action
: options.action
? [options.action]
: [];
const derivedClass = class extends Class {
[symbol] = new PeekableController(this as unknown as T, options.enableOn);
override connectedCallback() {
super.connectedCallback();
const target: HTMLElement =
(options.selector ? this.querySelector(options.selector) : this) ||
this;
if (actions.includes('double-click')) {
this.disposables.addFromEvent(target, 'dblclick', e => {
if (this[symbol].peekable) {
e.stopPropagation();
this[symbol].peek().catch(console.error);
}
});
}
if (
actions.includes('shift-click') &&
// shift click in edgeless should be selection
!isInsideEdgelessEditor(this.std.host)
) {
this.disposables.addFromEvent(target, 'click', e => {
if (e.shiftKey && this[symbol].peekable) {
e.stopPropagation();
e.stopImmediatePropagation();
this[symbol].peek().catch(console.error);
}
});
}
}
};
return derivedClass as unknown as C;
};
@@ -0,0 +1,16 @@
import type { ExtensionType } from '@blocksuite/block-std';
import { createIdentifier } from '@blocksuite/global/di';
import type { PeekViewService } from './type.js';
export const PeekViewProvider = createIdentifier<PeekViewService>(
'AffinePeekViewProvider'
);
export function PeekViewExtension(service: PeekViewService): ExtensionType {
return {
setup: di => {
di.addImpl(PeekViewProvider, () => service);
},
};
}
@@ -0,0 +1,70 @@
import type { BlockComponent, BlockStdScope } from '@blocksuite/block-std';
import type { DisposableClass } from '@blocksuite/global/utils';
import type { LitElement, TemplateResult } from 'lit';
export type PeekableClass = { std: BlockStdScope } & DisposableClass &
LitElement;
export interface PeekOptions {
/**
* Abort signal to abort the peek view
*/
abortSignal?: AbortSignal;
}
export interface PeekViewService {
/**
* Peek a target element page ref info
* @param pageRef The page ref info to peek.
* @returns A promise that resolves when the peek view is closed.
*/
peek(
pageRef: {
docId: string;
blockIds?: string[];
databaseId?: string;
databaseDocId?: string;
databaseRowId?: string;
elementIds?: string[];
target?: HTMLElement;
},
options?: PeekOptions
): Promise<void>;
/**
* Peek a target element with a optional template
* @param target The target element to peek. There are two use cases:
* 1. If the template is not given, peek view content rendering will be delegated to the implementation of peek view service.
* 2. To determine the origin of the peek view modal animation
* @param template Optional template to render in the peek view modal. If not given, the peek view service will render the content.
* @returns A promise that resolves when the peek view is closed.
*/
peek(
// eslint-disable-next-line @typescript-eslint/unified-signatures
element: { target: HTMLElement; template?: TemplateResult },
options?: PeekOptions
): Promise<void>;
peek<Element extends BlockComponent>(
element: { target: Element; template?: TemplateResult },
options?: PeekOptions
): Promise<void>;
}
type PeekableAction = 'double-click' | 'shift-click';
export type PeekableOptions<T extends PeekableClass> = {
/**
* Action to bind to the peekable element. default to ['double-click', 'shift-click']
* false means do not bind any action.
*/
action?: PeekableAction | PeekableAction[] | false;
/**
* It will check the block is enable to peek or not
*/
enableOn?: (block: T) => boolean;
/**
* Selector inside of the peekable element to bind the action
*/
selector?: string;
};
@@ -0,0 +1,221 @@
import { assertExists, Slot } from '@blocksuite/global/utils';
import {
autoUpdate,
computePosition,
type ComputePositionReturn,
} from '@floating-ui/dom';
import { cssVar } from '@toeverything/theme';
import { render } from 'lit';
import type { AdvancedPortalOptions, PortalOptions } from './types.js';
/**
* Similar to `<blocksuite-portal>`, but only renders once when called.
*
* The template should be a **static** template since it will not be re-rendered unless `updatePortal` is called.
*
* See {@link Portal} for more details.
*/
export function createSimplePortal({
template,
container = document.body,
signal = new AbortController().signal,
renderOptions,
shadowDom = true,
identifyWrapper = true,
}: PortalOptions) {
const portalRoot = document.createElement('div');
if (identifyWrapper) {
portalRoot.classList.add('blocksuite-portal');
}
if (shadowDom) {
portalRoot.attachShadow({
mode: 'open',
...(typeof shadowDom !== 'boolean' ? shadowDom : {}),
});
}
signal.addEventListener('abort', () => {
portalRoot.remove();
});
const root = shadowDom ? portalRoot.shadowRoot : portalRoot;
assertExists(root);
let updateId = 0;
const updatePortal: (id: number) => void = id => {
if (id !== updateId) {
console.warn(
'Potentially infinite recursion! Please clean up the old event listeners before `updatePortal`'
);
return;
}
updateId++;
const curId = updateId;
const templateResult =
template instanceof Function
? template({ updatePortal: () => updatePortal(curId) })
: template;
assertExists(templateResult);
render(templateResult, root, renderOptions);
};
updatePortal(updateId);
container.append(portalRoot);
// affine's modal will set pointer-events: none to body
// in order to avoid the issue that the floating element in blocksuite cannot be clicked
// we add pointer-events: auto here
portalRoot.style.pointerEvents = 'auto';
return portalRoot;
}
/**
* Where el is the DOM element you'd like to test for visibility
*/
function isElementVisible(el: Element) {
// The API is not stable, so we need to check the existence of the function first
// See also https://caniuse.com/?search=checkVisibility
if (el.checkVisibility) {
// See https://drafts.csswg.org/cssom-view-1/#dom-element-checkvisibility
return el.checkVisibility();
}
// Fallback to the old way
// Remove this when the `checkVisibility` API is stable
if (!el.isConnected) return false;
if (el instanceof HTMLElement) {
// See https://stackoverflow.com/questions/19669786/check-if-element-is-visible-in-dom
return !(el.offsetParent === null);
}
return true;
}
/**
* Similar to `createSimplePortal`, but supports auto update position.
*
* The template should be a **static** template since it will not be re-rendered.
*
* See {@link createSimplePortal} for more details.
*
* @example
* ```ts
* createLitPortal({
* template: RenameModal({
* model,
* abortController: renameAbortController,
* }),
* computePosition: {
* referenceElement: anchor,
* placement: 'top-end',
* middleware: [flip(), offset(4)],
* autoUpdate: true,
* },
* abortController: renameAbortController,
* });
* ```
*/
export function createLitPortal({
computePosition: positionConfigOrFn,
abortController,
closeOnClickAway = false,
positionStrategy = 'absolute',
...portalOptions
}: AdvancedPortalOptions) {
let positionSlot = new Slot<ComputePositionReturn>();
const template = portalOptions.template;
const templateWithPosition =
template instanceof Function
? ({ updatePortal }: { updatePortal: () => void }) => {
// We need to create a new slot for each template, otherwise the slot may be used in the old template
positionSlot = new Slot<ComputePositionReturn>();
return template({ updatePortal, positionSlot });
}
: template;
const portalRoot = createSimplePortal({
...portalOptions,
signal: abortController.signal,
template: templateWithPosition,
});
if (closeOnClickAway) {
// Avoid triggering click away listener on initial render
setTimeout(() =>
document.addEventListener(
'click',
e => {
if (portalRoot.contains(e.target as Node)) return;
abortController.abort();
},
{
signal: abortController.signal,
}
)
);
}
if (!positionConfigOrFn) {
return portalRoot;
}
const visibility = portalRoot.style.visibility;
portalRoot.style.visibility = 'hidden';
portalRoot.style.position = positionStrategy;
portalRoot.style.left = '0';
portalRoot.style.top = '0';
portalRoot.style.zIndex = cssVar('zIndexPopover');
Object.assign(portalRoot.style, portalOptions.portalStyles);
const computePositionOptions =
positionConfigOrFn instanceof Function
? positionConfigOrFn(portalRoot)
: positionConfigOrFn;
const { referenceElement, ...options } = computePositionOptions;
assertExists(referenceElement, 'referenceElement is required');
const update = () => {
if (
computePositionOptions.abortWhenRefRemoved !== false &&
referenceElement instanceof Element &&
!isElementVisible(referenceElement)
) {
abortController.abort();
}
computePosition(referenceElement, portalRoot, {
strategy: positionStrategy,
...options,
})
.then(positionReturn => {
const { x, y } = positionReturn;
// Use transform maybe cause overlay-mask offset issue
// portalRoot.style.transform = `translate(${x}px, ${y}px)`;
portalRoot.style.left = `${x}px`;
portalRoot.style.top = `${y}px`;
if (portalRoot.style.visibility === 'hidden') {
portalRoot.style.visibility = visibility;
}
positionSlot.emit(positionReturn);
})
.catch(console.error);
};
if (!computePositionOptions.autoUpdate) {
update();
} else {
const autoUpdateOptions =
computePositionOptions.autoUpdate === true
? {}
: computePositionOptions.autoUpdate;
const cleanup = autoUpdate(
referenceElement,
portalRoot,
update,
autoUpdateOptions
);
abortController.signal.addEventListener('abort', () => {
cleanup();
});
}
return portalRoot;
}
@@ -0,0 +1,9 @@
export { createLitPortal, createSimplePortal } from './helper.js';
export { Portal } from './portal.js';
export type * from './types.js';
import { Portal } from './portal.js';
export function effects() {
customElements.define('blocksuite-portal', Portal);
}
@@ -0,0 +1,60 @@
import { html, LitElement, type TemplateResult } from 'lit';
import { property } from 'lit/decorators.js';
/**
* Renders a template into a portal. Defaults to `document.body`.
*
* Note that every time the parent component re-renders, the portal will be re-called.
*
* See https://lit.dev/docs/components/rendering/#writing-a-good-render()-method
*
* @example
* ```ts
* render() {
* return html`${showPortal
* ? html`<blocksuite-portal .template=${portalTemplate}></blocksuite-portal>`
* : null}`;
* };
* ```
*/
export class Portal extends LitElement {
private _portalRoot: HTMLElement | null = null;
override createRenderRoot() {
const portalRoot = document.createElement('div');
const renderRoot = this.shadowDom
? portalRoot.attachShadow({
mode: 'open',
...(typeof this.shadowDom !== 'boolean' ? this.shadowDom : {}),
})
: portalRoot;
portalRoot.classList.add('blocksuite-portal');
this.container.append(portalRoot);
this._portalRoot = portalRoot;
return renderRoot;
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this._portalRoot?.remove();
}
override render() {
return this.template;
}
@property({ attribute: false })
accessor container = document.body;
@property({ attribute: false })
accessor shadowDom: boolean | ShadowRootInit = true;
@property({ attribute: false })
accessor template: TemplateResult | undefined = html``;
}
declare global {
interface HTMLElementTagNameMap {
'blocksuite-portal': Portal;
}
}
@@ -0,0 +1,82 @@
import type { Slot } from '@blocksuite/global/utils';
import type {
AutoUpdateOptions,
ComputePositionConfig,
ComputePositionReturn,
ReferenceElement,
} from '@floating-ui/dom';
import type { RenderOptions, TemplateResult } from 'lit';
/**
* See https://lit.dev/docs/templates/expressions/#child-expressions
*/
type Renderable =
| TemplateResult<1>
// Any DOM node can be passed to a child expression.
| HTMLElement
// Numbers values like 5 will render the string '5'. Bigints are treated similarly.
| number
// A boolean value true will render 'true', and false will render 'false', but rendering a boolean like this is uncommon.
| boolean
// The empty string '', null, and undefined are specially treated and render nothing.
| string
| null
| undefined;
export type PortalOptions = {
template: Renderable | ((ctx: { updatePortal: () => void }) => Renderable);
container?: Element;
/**
* The portal is removed when the AbortSignal is aborted.
*/
signal?: AbortSignal;
/**
* Defaults to `true`.
*/
shadowDom?: boolean | ShadowRootInit;
renderOptions?: RenderOptions;
/**
* Defaults to `true`.
* If true, the portalRoot will be added a class `blocksuite-portal`. It's useful for finding the portalRoot.
*/
identifyWrapper?: boolean;
portalStyles?: Record<string, string | number | undefined | null>;
};
type ComputePositionOptions = {
referenceElement: ReferenceElement;
/**
* Default `false`.
*/
autoUpdate?: true | AutoUpdateOptions;
/**
* Default `true`. Only work when `referenceElement` is an `Element`. Check when position update (`autoUpdate` is `true` or first tick)
*/
abortWhenRefRemoved?: boolean;
} & Partial<ComputePositionConfig>;
export type AdvancedPortalOptions = Omit<
PortalOptions,
'template' | 'signal'
> & {
abortController: AbortController;
template:
| Renderable
| ((context: {
positionSlot: Slot<ComputePositionReturn>;
updatePortal: () => void;
}) => Renderable);
positionStrategy?: 'absolute' | 'fixed';
/**
* See https://floating-ui.com/docs/computePosition
*/
computePosition?:
| ComputePositionOptions
| ((portalRoot: Element) => ComputePositionOptions);
/**
* Whether to close the portal when click away(click outside).
* @default false
*/
closeOnClickAway?: boolean;
};
@@ -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);
}
}

Some files were not shown because too many files have changed in this diff Show More