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,211 @@
import type { Placement } from '@floating-ui/dom';
import type { TemplateResult } from 'lit';
import { css, html, LitElement, nothing } from 'lit';
import { property } from 'lit/decorators.js';
import { cache } from 'lit/directives/cache.js';
import { styleMap } from 'lit/directives/style-map.js';
export class EditorIconButton extends LitElement {
static override styles = css`
:host([disabled]),
:host(:disabled) {
pointer-events: none;
cursor: not-allowed;
color: var(--affine-text-disable-color);
}
.icon-container {
position: relative;
display: flex;
align-items: center;
padding: var(--icon-container-padding);
color: var(--affine-icon-color);
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
box-sizing: border-box;
width: var(--icon-container-width, unset);
justify-content: var(--justify, unset);
user-select: none;
}
:host([active]) .icon-container.active-mode-color {
color: var(--affine-primary-color);
}
:host([active]) .icon-container.active-mode-background {
background: var(--affine-hover-color);
}
.icon-container[coming] {
cursor: not-allowed;
color: var(--affine-text-disable-color);
}
::slotted(svg) {
flex-shrink: 0;
width: var(--icon-size, unset);
height: var(--icon-size, unset);
}
::slotted(.label) {
flex: 1;
padding: 0 4px;
overflow: hidden;
white-space: nowrap;
line-height: var(--label-height, inherit);
}
::slotted(.label.padding0) {
padding: 0;
}
::slotted(.label.ellipsis) {
text-overflow: ellipsis;
}
::slotted(.label.medium) {
font-weight: 500;
}
.icon-container[with-hover]::before {
content: '';
display: block;
background: var(--affine-hover-color);
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
border-radius: 4px;
}
`;
constructor() {
super();
// Allow activate button by pressing Enter key
this.addEventListener('keypress', event => {
if (this.disabled) {
return;
}
if (event.key === 'Enter' && !event.isComposing) {
this.click();
}
});
// Prevent click event when disabled
this.addEventListener(
'click',
event => {
if (this.disabled) {
event.stopPropagation();
event.preventDefault();
}
},
{ capture: true }
);
}
override connectedCallback() {
super.connectedCallback();
this.tabIndex = 0;
this.role = 'button';
}
override render() {
const tooltip = this.coming ? '(Coming soon)' : this.tooltip;
const classnames = `icon-container active-mode-${this.activeMode} ${this.hoverState ? 'hovered' : ''}`;
const padding = this.iconContainerPadding;
const iconContainerStyles = styleMap({
'--icon-container-width': this.iconContainerWidth,
'--icon-container-padding': Array.isArray(padding)
? padding.map(v => `${v}px`).join(' ')
: `${padding}px`,
'--icon-size': this.iconSize,
'--justify': this.justify,
'--label-height': this.labelHeight,
});
return html`
<style>
.icon-container:hover,
.icon-container.hovered {
background: ${this.hover ? `var(--affine-hover-color)` : 'inherit'};
}
</style>
<div
class=${classnames}
style=${iconContainerStyles}
?with-hover=${this.withHover}
?disabled=${this.disabled}
>
<slot></slot>
${cache(
this.showTooltip && tooltip
? html`<affine-tooltip
tip-position=${this.tipPosition}
.arrow=${this.arrow}
.offset=${this.tooltipOffset}
>${tooltip}</affine-tooltip
>`
: nothing
)}
</div>
`;
}
@property({ type: Boolean, reflect: true })
accessor active = false;
@property({ attribute: false })
accessor activeMode: 'color' | 'background' = 'color';
@property({ attribute: false })
accessor arrow = true;
@property({ attribute: false })
accessor coming = false;
@property({ type: Boolean, reflect: true })
accessor disabled = false;
@property({ attribute: false })
accessor hover = true;
@property({ attribute: false })
accessor hoverState = false;
@property({ attribute: false })
accessor iconContainerPadding: number | number[] = 2;
@property({ attribute: false })
accessor iconContainerWidth: string | undefined = undefined;
@property({ attribute: false })
accessor iconSize: string | undefined = undefined;
@property({ attribute: false })
accessor justify: string | undefined = undefined;
@property({ attribute: false })
accessor labelHeight: string | undefined = undefined;
@property({ type: Boolean })
accessor showTooltip = true;
@property({ attribute: false })
accessor tipPosition: Placement = 'top';
@property({ attribute: false })
accessor tooltip!: string | TemplateResult<1>;
@property({ attribute: false })
accessor tooltipOffset = 8;
@property({ attribute: false })
accessor withHover: boolean | undefined = undefined;
}
declare global {
interface HTMLElementTagNameMap {
'editor-icon-button': EditorIconButton;
}
}
@@ -0,0 +1,43 @@
import { EditorIconButton } from './icon-button.js';
import {
EditorMenuAction,
EditorMenuButton,
EditorMenuContent,
} from './menu-button.js';
import { EditorToolbarSeparator } from './separator.js';
import { EditorToolbar } from './toolbar.js';
import { Tooltip } from './tooltip.js';
export { EditorIconButton } from './icon-button.js';
export {
EditorMenuAction,
EditorMenuButton,
EditorMenuContent,
} from './menu-button.js';
export { EditorToolbarSeparator } from './separator.js';
export { darkToolbarStyles, lightToolbarStyles } from './styles.js';
export { EditorToolbar } from './toolbar.js';
export { Tooltip } from './tooltip.js';
export type {
AdvancedMenuItem,
FatMenuItems,
MenuItem,
MenuItemGroup,
} from './types.js';
export {
cloneGroups,
groupsToActions,
renderActions,
renderGroups,
renderToolbarSeparator,
} from './utils.js';
export function effects() {
customElements.define('editor-toolbar-separator', EditorToolbarSeparator);
customElements.define('editor-toolbar', EditorToolbar);
customElements.define('editor-icon-button', EditorIconButton);
customElements.define('editor-menu-button', EditorMenuButton);
customElements.define('editor-menu-content', EditorMenuContent);
customElements.define('editor-menu-action', EditorMenuAction);
customElements.define('affine-tooltip', Tooltip);
}
@@ -0,0 +1,226 @@
import { PANEL_BASE } from '@blocksuite/affine-shared/styles';
import { createButtonPopper } from '@blocksuite/affine-shared/utils';
import { WithDisposable } from '@blocksuite/global/utils';
import {
css,
html,
LitElement,
type PropertyValues,
type TemplateResult,
} from 'lit';
import { property, query } from 'lit/decorators.js';
import type { EditorIconButton } from './icon-button.js';
export class EditorMenuButton extends WithDisposable(LitElement) {
static override styles = css`
:host {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
`;
private _popper!: ReturnType<typeof createButtonPopper>;
override firstUpdated() {
this._popper = createButtonPopper(
this._trigger,
this._content,
({ display }) => {
const opened = display === 'show';
this._trigger.showTooltip = !opened;
this.dispatchEvent(
new CustomEvent('toggle', {
detail: opened,
bubbles: false,
cancelable: false,
composed: true,
})
);
},
{
mainAxis: 12,
ignoreShift: true,
}
);
this._disposables.addFromEvent(this, 'keydown', (e: KeyboardEvent) => {
e.stopPropagation();
if (e.key === 'Escape') {
this._popper.hide();
}
});
this._disposables.addFromEvent(this._trigger, 'click', (_: MouseEvent) => {
this._popper.toggle();
if (this._popper.state === 'show') {
this._content.focus({ preventScroll: true });
}
});
this._disposables.add(this._popper);
}
hide() {
this._popper?.hide();
}
override render() {
return html`
${this.button}
<editor-menu-content role="menu" tabindex="-1">
<slot></slot>
</editor-menu-content>
`;
}
show(force = false) {
this._popper?.show(force);
}
override willUpdate(changedProperties: PropertyValues) {
if (changedProperties.has('contentPadding')) {
this.style.setProperty('--content-padding', this.contentPadding ?? '');
}
}
@query('editor-menu-content')
private accessor _content!: EditorMenuContent;
@query('editor-icon-button')
private accessor _trigger!: EditorIconButton;
@property({ attribute: false })
accessor button!: string | TemplateResult<1>;
@property({ attribute: false })
accessor contentPadding: string | undefined = undefined;
}
export class EditorMenuContent extends LitElement {
static override styles = css`
:host {
--packed-height: 6px;
--offset-height: calc(-1 * var(--packed-height));
display: none;
outline: none;
}
:host::before,
:host::after {
content: '';
display: block;
position: absolute;
height: var(--packed-height);
width: 100%;
}
:host::before {
top: var(--offset-height);
}
:host::after {
bottom: var(--offset-height);
}
:host([data-show]) {
${PANEL_BASE};
justify-content: center;
padding: var(--content-padding, 0 6px);
}
::slotted(:not(.custom)) {
display: flex;
align-items: center;
align-self: stretch;
gap: 8px;
min-height: 36px;
}
::slotted([data-size]) {
min-width: 146px;
}
::slotted([data-size='small']) {
min-width: 164px;
}
::slotted([data-size='large']) {
min-width: 176px;
}
::slotted([data-orientation='vertical']) {
flex-direction: column;
align-items: stretch;
gap: unset;
min-height: unset;
}
`;
override render() {
return html`<slot></slot>`;
}
}
export class EditorMenuAction extends LitElement {
static override styles = css`
:host {
display: flex;
width: 100%;
align-items: center;
white-space: nowrap;
box-sizing: border-box;
padding: 4px 8px;
border-radius: 4px;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
gap: 8px;
color: var(--affine-text-primary-color);
font-weight: 400;
min-height: 30px; // 22 + 8
}
:host(:hover),
:host([data-selected]) {
background-color: var(--affine-hover-color);
}
:host([data-selected]) {
pointer-events: none;
}
:host(:hover.delete),
:host(:hover.delete) ::slotted(svg) {
background-color: var(--affine-background-error-color);
color: var(--affine-error-color);
}
:host([disabled]) {
pointer-events: none;
cursor: not-allowed;
color: var(--affine-text-disable-color);
}
::slotted(svg) {
color: var(--affine-icon-color);
}
`;
override connectedCallback() {
super.connectedCallback();
this.role = 'button';
}
override render() {
return html`<slot></slot>`;
}
}
declare global {
interface HTMLElementTagNameMap {
'editor-menu-button': EditorMenuButton;
'editor-menu-content': EditorMenuContent;
'editor-menu-action': EditorMenuAction;
}
}
@@ -0,0 +1,39 @@
import { css, LitElement } from 'lit';
export class EditorToolbarSeparator extends LitElement {
static override styles = css`
:host {
display: flex;
align-items: center;
justify-content: center;
align-self: stretch;
flex-shrink: 0;
width: 4px;
}
:host::after {
content: '';
display: flex;
width: 0.5px;
height: 100%;
background-color: var(--affine-border-color);
}
:host([data-orientation='horizontal']) {
height: 8px;
width: unset;
}
:host([data-orientation='horizontal'])::after {
height: 0.5px;
width: 100%;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
'editor-toolbar-separator': EditorToolbarSeparator;
}
}
@@ -0,0 +1,29 @@
import {
type AffineCssVariables,
combinedDarkCssVariables,
combinedLightCssVariables,
} from '@toeverything/theme';
import { unsafeCSS } from 'lit';
const toolbarColorKeys: Array<keyof AffineCssVariables> = [
'--affine-background-overlay-panel-color',
'--affine-v2-layer-background-overlayPanel' as never,
'--affine-background-error-color',
'--affine-background-primary-color',
'--affine-background-tertiary-color',
'--affine-icon-color',
'--affine-icon-secondary',
'--affine-border-color',
'--affine-divider-color',
'--affine-text-primary-color',
'--affine-hover-color',
'--affine-hover-color-filled',
];
export const lightToolbarStyles = toolbarColorKeys.map(
key => `${key}: ${unsafeCSS(combinedLightCssVariables[key])};`
);
export const darkToolbarStyles = toolbarColorKeys.map(
key => `${key}: ${unsafeCSS(combinedDarkCssVariables[key])};`
);
@@ -0,0 +1,50 @@
import { PANEL_BASE } from '@blocksuite/affine-shared/styles';
import { stopPropagation } from '@blocksuite/affine-shared/utils';
import { WithDisposable } from '@blocksuite/global/utils';
import { css, html, LitElement } from 'lit';
export class EditorToolbar extends WithDisposable(LitElement) {
static override styles = css`
:host {
${PANEL_BASE};
height: 36px;
box-sizing: content-box;
}
:host([data-without-bg]) {
border-color: transparent;
background: transparent;
box-shadow: none;
}
::slotted(*) {
display: flex;
height: 100%;
justify-content: center;
align-items: center;
gap: 8px;
color: var(--affine-text-primary-color);
fill: currentColor;
}
`;
override connectedCallback() {
super.connectedCallback();
this._disposables.addFromEvent(this, 'pointerdown', (e: PointerEvent) => {
e.stopPropagation();
e.preventDefault();
});
this._disposables.addFromEvent(this, 'wheel', stopPropagation);
}
override render() {
return html`<slot></slot>`;
}
}
declare global {
interface HTMLElementTagNameMap {
'editor-toolbar': EditorToolbar;
}
}
@@ -0,0 +1,279 @@
import { assertExists } from '@blocksuite/global/utils';
import {
arrow,
type ComputePositionReturn,
flip,
offset,
type Placement,
} from '@floating-ui/dom';
import type { CSSResult } from 'lit';
import { css, html, LitElement, unsafeCSS } from 'lit';
import { property } from 'lit/decorators.js';
import { type StyleInfo, styleMap } from 'lit/directives/style-map.js';
import { HoverController, type HoverOptions } from '../hover/index.js';
const styles = css`
.affine-tooltip {
box-sizing: border-box;
max-width: 280px;
min-height: 32px;
font-family: var(--affine-font-family);
font-size: var(--affine-font-sm);
border-radius: 4px;
padding: 6px 12px;
color: var(--affine-white);
background: var(--affine-tooltip);
display: flex;
justify-content: center;
align-items: center;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.arrow {
position: absolute;
width: 0;
height: 0;
}
`;
// See http://apps.eky.hk/css-triangle-generator/
const TRIANGLE_HEIGHT = 6;
const triangleMap = {
top: {
bottom: '-6px',
borderStyle: 'solid',
borderWidth: '6px 5px 0 5px',
borderColor: 'var(--affine-tooltip) transparent transparent transparent',
},
right: {
left: '-6px',
borderStyle: 'solid',
borderWidth: '5px 6px 5px 0',
borderColor: 'transparent var(--affine-tooltip) transparent transparent',
},
bottom: {
top: '-6px',
borderStyle: 'solid',
borderWidth: '0 5px 6px 5px',
borderColor: 'transparent transparent var(--affine-tooltip) transparent',
},
left: {
right: '-6px',
borderStyle: 'solid',
borderWidth: '5px 0 5px 6px',
borderColor: 'transparent transparent transparent var(--affine-tooltip)',
},
};
// Ported from https://floating-ui.com/docs/tutorial#arrow-middleware
const updateArrowStyles = ({
placement,
middlewareData,
}: ComputePositionReturn): StyleInfo => {
const arrowX = middlewareData.arrow?.x;
const arrowY = middlewareData.arrow?.y;
const triangleStyles =
triangleMap[placement.split('-')[0] as keyof typeof triangleMap];
return {
left: arrowX != null ? `${arrowX}px` : '',
top: arrowY != null ? `${arrowY}px` : '',
...triangleStyles,
};
};
/**
* @example
* ```ts
* // Simple usage
* html`
* <affine-tooltip>Content</affine-tooltip>
* `
* // With placement
* html`
* <affine-tooltip tip-position="top">
* Content
* </affine-tooltip>
* `
*
* // With custom properties
* html`
* <affine-tooltip
* .zIndex=${0}
* .offset=${4}
* .autoFlip=${true}
* .arrow=${true}
* .tooltipStyle=${css`:host { z-index: 0; --affine-tooltip: #fff; }`}
* .allowInteractive=${false}
* >
* Content
* </affine-tooltip>
* `
* ```
*/
export class Tooltip extends LitElement {
static override styles = css`
:host {
display: none;
}
`;
private _hoverController!: HoverController;
private _setUpHoverController = () => {
this._hoverController = new HoverController(
this,
() => {
// const parentElement = this.parentElement;
// if (
// parentElement &&
// 'disabled' in parentElement &&
// parentElement.disabled
// )
// return null;
if (this.hidden) return null;
let arrowStyles: StyleInfo = {};
return {
template: ({ positionSlot, updatePortal }) => {
positionSlot.on(data => {
// The tooltip placement may change,
// so we need to update the arrow position
if (this.arrow) {
arrowStyles = updateArrowStyles(data);
} else {
arrowStyles = {};
}
updatePortal();
});
const children = Array.from(this.childNodes).map(node =>
node.cloneNode(true)
);
return html`
<style>
${this._getStyles()}
</style>
<div class="affine-tooltip" role="tooltip">${children}</div>
<div class="arrow" style=${styleMap(arrowStyles)}></div>
`;
},
computePosition: portalRoot => ({
referenceElement: this.parentElement!,
placement: this.placement,
middleware: [
this.autoFlip && flip({ padding: 12 }),
offset((this.arrow ? TRIANGLE_HEIGHT : 0) + this.offset),
arrow({
element: portalRoot.shadowRoot!.querySelector('.arrow')!,
}),
],
autoUpdate: true,
}),
};
},
{
leaveDelay: 0,
// The tooltip is not interactive by default
safeBridge: false,
allowMultiple: true,
...this.hoverOptions,
}
);
const parent = this.parentElement;
assertExists(parent, 'Tooltip must have a parent element');
// Wait for render
setTimeout(() => {
this._hoverController.setReference(parent);
}, 0);
};
private _getStyles() {
return css`
${styles}
:host {
z-index: ${unsafeCSS(this.zIndex)};
opacity: 0;
${
// All the styles are applied to the portal element
unsafeCSS(this.style.cssText)
}
}
${this.allowInteractive
? css``
: css`
:host {
pointer-events: none;
}
`}
${this.tooltipStyle}
`;
}
override connectedCallback() {
super.connectedCallback();
this._setUpHoverController();
}
getPortal() {
return this._hoverController.portal;
}
/**
* Allow the tooltip to be interactive.
* eg. allow the user to select text in the tooltip.
*/
@property({ attribute: false })
accessor allowInteractive = false;
/**
* Show a triangle arrow pointing to the reference element.
*/
@property({ attribute: false })
accessor arrow = true;
/**
* changes the placement of the floating element in order to keep it in view,
* with the ability to flip to any placement.
*
* See https://floating-ui.com/docs/flip
*/
@property({ attribute: false })
accessor autoFlip = true;
@property({ attribute: false })
accessor hoverOptions: Partial<HoverOptions> = {};
/**
* Default is `4px`
*
* See https://floating-ui.com/docs/offset
*/
@property({ attribute: false })
accessor offset = 4;
@property({ attribute: 'tip-position' })
accessor placement: Placement = 'top';
@property({ attribute: false })
accessor tooltipStyle: CSSResult = css``;
@property({ attribute: false })
accessor zIndex: number | string = 'var(--affine-z-index-popover)';
}
declare global {
interface HTMLElementTagNameMap {
'affine-tooltip': Tooltip;
}
}
@@ -0,0 +1,31 @@
import type { nothing, TemplateResult } from 'lit';
export type MenuItemPart = {
action: () => void;
disabled?: boolean;
render?: (item: MenuItem) => TemplateResult<1>;
};
export type MenuItem = {
type: string;
label?: string;
tooltip?: string;
icon?: TemplateResult<1>;
} & MenuItemPart;
export type AdvancedMenuItem<T> = Omit<MenuItem, 'action' | 'disabled'> & {
action?: (context: T) => void | Promise<void>;
disabled?: boolean | ((context: T) => boolean);
when?: (context: T) => boolean;
// Generates action at runtime
generate?: (context: T) => MenuItemPart | void;
};
export type MenuItemGroup<T> = {
type: string;
items: AdvancedMenuItem<T>[];
when?: (context: T) => boolean;
};
// Group Actions
export type FatMenuItems = (MenuItem | typeof nothing)[][];
@@ -0,0 +1,103 @@
import { html, nothing } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.js';
import { join } from 'lit/directives/join.js';
import { repeat } from 'lit/directives/repeat.js';
import type { FatMenuItems, MenuItem, MenuItemGroup } from './types.js';
export function groupsToActions<T>(
groups: MenuItemGroup<T>[],
context: T
): MenuItem[][] {
return groups
.filter(group => group.when?.(context) ?? true)
.map(({ items }) =>
items
.filter(item => item.when?.(context) ?? true)
.map(({ type, label, tooltip, icon, action, disabled, generate }) => {
if (action && typeof action === 'function') {
return {
type,
label,
tooltip,
icon,
action: () => {
action(context)?.catch(console.error);
},
disabled:
typeof disabled === 'function' ? disabled(context) : disabled,
};
}
if (generate && typeof generate === 'function') {
const result = generate(context);
if (!result) return null;
return {
type,
label,
tooltip,
icon,
...result,
};
}
return null;
})
.filter(item => !!item)
);
}
export function renderActions(
fatMenuItems: FatMenuItems,
action?: (item: MenuItem) => Promise<void> | void,
selectedName?: string
) {
return join(
fatMenuItems
.filter(g => g.length)
.map(g => g.filter(a => a !== nothing) as MenuItem[])
.filter(g => g.length)
.map(items =>
repeat(
items,
item => item.type,
item =>
item.render?.(item) ??
html`
<editor-menu-action
class=${ifDefined(
item.type === 'delete' ? 'delete' : undefined
)}
aria-label=${ifDefined(item.label)}
?data-selected=${selectedName === item.label}
?disabled=${item.disabled}
@click=${item.action ? item.action : () => action?.(item)}
>
${item.icon}${item.label
? html`<span class="label">${item.label}</span>`
: nothing}
</editor-menu-action>
`
)
),
() => html`
<editor-toolbar-separator
data-orientation="horizontal"
></editor-toolbar-separator>
`
);
}
export function cloneGroups<T>(groups: MenuItemGroup<T>[]) {
return groups.map(group => ({ ...group, items: [...group.items] }));
}
export function renderGroups<T>(groups: MenuItemGroup<T>[], context: T) {
return renderActions(groupsToActions(groups, context));
}
export function renderToolbarSeparator() {
return html`<editor-toolbar-separator></editor-toolbar-separator>`;
}