mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-02 18:09:58 +08:00
chore: merge blocksuite source code (#9213)
This commit is contained in:
@@ -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>`;
|
||||
}
|
||||
Reference in New Issue
Block a user