snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 3s
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-16 04:01:20 +05:00
commit 702cc698a7
9990 changed files with 1073618 additions and 0 deletions

View File

@@ -0,0 +1,78 @@
{
"name": "@blocksuite/affine-components",
"description": "Default BlockSuite editable blocks.",
"type": "module",
"scripts": {
"build": "yarn run -T tsc"
},
"sideEffects": false,
"keywords": [],
"author": "toeverything",
"license": "MIT",
"dependencies": {
"@blocksuite/affine-model": "workspace:*",
"@blocksuite/affine-shared": "workspace:*",
"@blocksuite/global": "workspace:*",
"@blocksuite/icons": "^2.2.17",
"@blocksuite/std": "workspace:*",
"@blocksuite/store": "workspace:*",
"@blocksuite/sync": "workspace:*",
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@lottiefiles/dotlottie-wc": "^0.9.4",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.23",
"@types/lodash-es": "^4.17.12",
"date-fns": "^4.4.0",
"lit": "^3.2.0",
"lit-html": "^3.2.1",
"lodash-es": "^4.17.23",
"rxjs": "^7.8.2",
"yjs": "^13.6.27"
},
"exports": {
".": "./src/index.ts",
"./color-picker": "./src/color-picker/index.ts",
"./icons": "./src/icons/index.ts",
"./peek": "./src/peek/index.ts",
"./resource": "./src/resource/index.ts",
"./portal": "./src/portal/index.ts",
"./hover": "./src/hover/index.ts",
"./icon-button": "./src/icon-button/index.ts",
"./toolbar": "./src/toolbar/index.ts",
"./toast": "./src/toast/index.ts",
"./smooth-corner": "./src/smooth-corner/index.ts",
"./caption": "./src/caption/index.ts",
"./context-menu": "./src/context-menu/index.ts",
"./date-picker": "./src/date-picker/index.ts",
"./drop-indicator": "./src/drop-indicator/index.ts",
"./filterable-list": "./src/filterable-list/index.ts",
"./toggle-button": "./src/toggle-button/index.ts",
"./toggle-switch": "./src/toggle-switch/index.ts",
"./notification": "./src/notification/index.ts",
"./block-zero-width": "./src/block-zero-width/index.ts",
"./block-selection": "./src/block-selection/index.ts",
"./embed-card-modal": "./src/embed-card-modal/index.ts",
"./link-preview": "./src/link-preview/index.ts",
"./linked-doc-title": "./src/linked-doc-title/index.ts",
"./view-dropdown-menu": "./src/view-dropdown-menu/index.ts",
"./card-style-dropdown-menu": "./src/card-style-dropdown-menu/index.ts",
"./citation": "./src/citation/index.ts",
"./highlight-dropdown-menu": "./src/highlight-dropdown-menu/index.ts",
"./tooltip-content-with-shortcut": "./src/tooltip-content-with-shortcut/index.ts",
"./size-dropdown-menu": "./src/size-dropdown-menu/index.ts",
"./edgeless-line-width-panel": "./src/edgeless-line-width-panel/index.ts",
"./edgeless-line-styles-panel": "./src/edgeless-line-styles-panel/index.ts",
"./edgeless-shape-color-picker": "./src/edgeless-shape-color-picker/index.ts",
"./open-doc-dropdown-menu": "./src/open-doc-dropdown-menu/index.ts",
"./slider": "./src/slider/index.ts",
"./tooltip": "./src/tooltip/index.ts"
},
"files": [
"src",
"dist",
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.27.5"
}

View File

@@ -0,0 +1,69 @@
import { SignalWatcher } from '@blocksuite/global/lit';
import { css, LitElement, type PropertyValues } from 'lit';
import { property } from 'lit/decorators.js';
/**
* Renders a the block selection.
*
* @example
* ```ts
* class Block extends LitElement {
* state override styles = css`
* :host {
* position: relative;
* }
*
* render() {
* return html`<affine-block-selection></affine-block-selection>
* };
* }
* ```
*/
export class BlockSelection extends SignalWatcher(LitElement) {
static override styles = css`
:host {
position: absolute;
z-index: 1;
left: 0;
top: 0;
width: 100%;
height: 100%;
pointer-events: none;
background-color: var(--affine-hover-color);
border-color: transparent;
border-style: solid;
}
`;
override connectedCallback(): void {
super.connectedCallback();
this.style.borderRadius = `${this.borderRadius}px`;
if (this.borderWidth !== 0) {
this.style.boxSizing = 'content-box';
this.style.transform = `translate(-${this.borderWidth}px, -${this.borderWidth}px)`;
}
this.style.borderWidth = `${this.borderWidth}px`;
}
protected override updated(changed: PropertyValues) {
if (changed.has('selected')) {
this.style.display = this.selected ? 'block' : 'none';
}
}
@property({ attribute: false })
accessor selected = false;
@property({ attribute: false })
accessor borderRadius: number = 5;
@property({ attribute: false })
accessor borderWidth: number = 0;
}
declare global {
interface HTMLElementTagNameMap {
'affine-block-selection': BlockSelection;
}
}

View File

@@ -0,0 +1,59 @@
import { stopPropagation } from '@blocksuite/affine-shared/utils';
import { type BlockComponent, TextSelection } from '@blocksuite/std';
import { css, html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
export class BlockZeroWidth extends LitElement {
static override styles = css`
.block-zero-width {
position: absolute;
bottom: -15px;
height: 10px;
width: 100%;
cursor: text;
z-index: 1;
}
`;
_handleClick = (e: MouseEvent) => {
stopPropagation(e);
if (this.block.store.readonly) return;
const nextBlock = this.block.store.getNext(this.block.model);
if (nextBlock?.flavour !== 'affine:paragraph') {
const [paragraphId] = this.block.store.addSiblingBlocks(
this.block.model,
[{ flavour: 'affine:paragraph' }]
);
const std = this.block.std;
std.selection.setGroup('note', [
std.selection.create(TextSelection, {
from: { blockId: paragraphId, index: 0, length: 0 },
to: null,
}),
]);
}
};
override connectedCallback(): void {
super.connectedCallback();
this.addEventListener('click', this._handleClick);
}
override disconnectedCallback(): void {
this.removeEventListener('click', this._handleClick);
super.disconnectedCallback();
}
override render() {
return html`<div class="block-zero-width"></div>`;
}
@property({ attribute: false })
accessor block!: BlockComponent;
}
declare global {
interface HTMLElementTagNameMap {
'block-zero-width': BlockZeroWidth;
}
}

View File

@@ -0,0 +1,188 @@
import type { DocMode } from '@blocksuite/affine-model';
import { stopPropagation } from '@blocksuite/affine-shared/utils';
import { WithDisposable } from '@blocksuite/global/lit';
import type { BlockStdScope } from '@blocksuite/std';
import {
modelContext,
ShadowlessElement,
stdContext,
storeContext,
TextSelection,
} from '@blocksuite/std';
import { RANGE_SYNC_EXCLUDE_ATTR } from '@blocksuite/std/inline';
import type { BlockModel, Store } 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';
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
);
const std = this.std;
std.selection.setGroup('note', [
std.selection.create(TextSelection, {
from: { blockId: id, index: 0, length: 0 },
to: null,
}),
]);
}
}
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.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true');
this.caption = this.model.props.caption;
this.disposables.add(
this.model.propsUpdated.subscribe(({ key }) => {
if (key === 'caption') {
this.caption = this.model.props.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: storeContext })
accessor doc!: Store;
@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;
}
}

View File

@@ -0,0 +1,89 @@
import { ThemeProvider } from '@blocksuite/affine-shared/services';
import { watch } from '@blocksuite/global/lit';
import { BlockComponent, type BlockService } from '@blocksuite/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
.selected=${watch(this.selected$)}
></affine-block-selection>`
: null
}
${
this.useZeroWidth && !this.store.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;
}

View File

@@ -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);
}

View File

@@ -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;
}
}
`;

View File

@@ -0,0 +1,118 @@
import type { ColorScheme } from '@blocksuite/affine-model';
import {
type ToolbarAction,
ToolbarContext,
} from '@blocksuite/affine-shared/services';
import { SignalWatcher } from '@blocksuite/global/lit';
import { PaletteIcon } from '@blocksuite/icons/lit';
import { PropTypes, requiredProperties } from '@blocksuite/std';
import {
computed,
type ReadonlySignal,
type Signal,
} from '@preact/signals-core';
import { LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import { html, type TemplateResult } from 'lit-html';
import { ifDefined } from 'lit-html/directives/if-defined.js';
import { repeat } from 'lit-html/directives/repeat.js';
import {
EmbedCardDarkCubeIcon,
EmbedCardDarkHorizontalIcon,
EmbedCardDarkListIcon,
EmbedCardDarkVerticalIcon,
EmbedCardLightCubeIcon,
EmbedCardLightHorizontalIcon,
EmbedCardLightListIcon,
EmbedCardLightVerticalIcon,
} from '../icons';
const cardStyleMap: Record<ColorScheme, Record<string, TemplateResult>> = {
light: {
cube: EmbedCardLightCubeIcon,
cubeThick: EmbedCardLightCubeIcon,
horizontal: EmbedCardLightHorizontalIcon,
horizontalThin: EmbedCardLightListIcon,
list: EmbedCardLightListIcon,
vertical: EmbedCardLightVerticalIcon,
},
dark: {
cube: EmbedCardDarkCubeIcon,
cubeThick: EmbedCardDarkCubeIcon,
horizontal: EmbedCardDarkHorizontalIcon,
horizontalThin: EmbedCardDarkListIcon,
list: EmbedCardDarkListIcon,
vertical: EmbedCardDarkVerticalIcon,
},
};
@requiredProperties({
actions: PropTypes.array,
context: PropTypes.instanceOf(ToolbarContext),
styleSignal: PropTypes.object,
})
export class CardStyleDropdownMenu extends SignalWatcher(LitElement) {
@property({ attribute: false })
accessor actions!: ToolbarAction[];
@property({ attribute: false })
accessor context!: ToolbarContext;
@property({ attribute: false })
accessor styleSignal!: Signal<string> | ReadonlySignal<string>;
icons$ = computed(() => cardStyleMap[this.context.theme.theme$.value]);
override render() {
const {
actions,
context,
styleSignal: { value: style },
icons$: { value: icons },
} = this;
return html`
<editor-menu-button
.contentPadding="${'8px'}"
.button=${html`
<editor-icon-button
aria-label="Card style"
.tooltip="${'Card style'}"
>
${PaletteIcon()}
</editor-icon-button>
`}
>
<div>
${repeat(
actions,
action => action.id,
({ id, label, icon, disabled, run }) => html`
<editor-icon-button
aria-label="${ifDefined(label)}"
data-testid="${id}"
.tooltip="${label}"
.activeMode="${'border'}"
.iconContainerWidth="${'76px'}"
.iconContainerHeight="${'76px'}"
.justify="${'center'}"
?active="${id === style}"
?disabled="${ifDefined(disabled)}"
@click=${() => run?.(context)}
>
${icon || icons[id]}
</editor-icon-button>
`
)}
</div>
</editor-menu-button>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'affine-card-style-dropdown-menu': CardStyleDropdownMenu;
}
}

View File

@@ -0,0 +1,10 @@
import { CardStyleDropdownMenu } from './dropdown-menu';
export * from './dropdown-menu';
export function effects() {
customElements.define(
'affine-card-style-dropdown-menu',
CardStyleDropdownMenu
);
}

View File

@@ -0,0 +1,171 @@
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { baseTheme } from '@toeverything/theme';
import {
css,
html,
LitElement,
nothing,
type TemplateResult,
unsafeCSS,
} from 'lit';
import { property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
export class CitationCard extends SignalWatcher(WithDisposable(LitElement)) {
static override styles = css`
.citation-container {
width: 100%;
box-sizing: border-box;
border-radius: 8px;
display: flex;
gap: 2px;
flex-direction: column;
align-items: flex-start;
align-self: stretch;
padding: 4px 8px;
background-color: ${unsafeCSSVarV2('layer/background/primary')};
border: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
cursor: pointer;
}
.citation-header {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
box-sizing: border-box;
.citation-icon {
display: flex;
align-items: center;
justify-content: center;
height: 16px;
width: 16px;
color: ${unsafeCSSVarV2('icon/primary')};
border-radius: 4px;
svg,
img {
width: 16px;
height: 16px;
fill: ${unsafeCSSVarV2('icon/primary')};
}
}
.citation-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: left;
line-height: 22px;
color: ${unsafeCSSVarV2('text/primary')};
font-size: var(--affine-font-sm);
font-weight: 500;
}
.citation-identifier {
display: flex;
width: 14px;
height: 14px;
justify-content: center;
align-items: center;
border-radius: 36px;
background: ${unsafeCSSVarV2('block/footnote/numberBg')};
color: ${unsafeCSSVarV2('text/primary')};
text-align: center;
font-size: 10px;
font-style: normal;
font-weight: 400;
line-height: 22px; /* 220% */
transition: background-color 0.3s ease-in-out;
}
}
.citation-container:hover .citation-identifier,
.citation-identifier.active {
background: ${unsafeCSSVarV2('button/primary')};
color: ${unsafeCSSVarV2('button/pureWhiteText')};
}
.citation-content {
width: 100%;
box-sizing: border-box;
overflow: hidden;
color: ${unsafeCSSVarV2('text/primary')};
font-feature-settings:
'liga' off,
'clig' off;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--affine-font-xs);
font-style: normal;
font-weight: 400;
line-height: 20px; /* 166.667% */
}
`;
private readonly _IconTemplate = (icon: TemplateResult | string) => {
if (typeof icon === 'string') {
return html`<img src="${icon}" alt="favicon" />`;
}
return icon;
};
override render() {
const citationIdentifierClasses = classMap({
'citation-identifier': true,
active: this.active,
});
return html`
<div
class="citation-container"
@click=${this.onClickCallback}
@dblclick=${this.onDoubleClickCallback}
>
<div class="citation-header">
${
this.icon
? html`<div class="citation-icon">
${this._IconTemplate(this.icon)}
</div>`
: nothing
}
<div class="citation-title">${this.citationTitle}</div>
<div class=${citationIdentifierClasses}>
${this.citationIdentifier}
</div>
</div>
${
this.citationContent
? html`<div class="citation-content">${this.citationContent}</div>`
: nothing
}
</div>
`;
}
@property({ attribute: false })
accessor icon: TemplateResult | string | undefined = undefined;
@property({ attribute: false })
accessor citationTitle: string = '';
@property({ attribute: false })
accessor citationContent: string | undefined = undefined;
@property({ attribute: false })
accessor citationIdentifier: string = '';
@property({ attribute: false })
accessor onClickCallback: ((e: MouseEvent) => void) | undefined = undefined;
@property({ attribute: false })
accessor onDoubleClickCallback: ((e: MouseEvent) => void) | undefined =
undefined;
@property({ attribute: false })
accessor active: boolean = false;
}

View File

@@ -0,0 +1,7 @@
import { CitationCard } from './citation';
export * from './citation';
export function effects() {
customElements.define('affine-citation-card', CitationCard);
}

View File

@@ -0,0 +1,207 @@
import type { Color, ColorScheme, Palette } from '@blocksuite/affine-model';
import { DefaultTheme, resolveColor } from '@blocksuite/affine-model';
import type { ColorEvent } from '@blocksuite/affine-shared/utils';
import { WithDisposable } from '@blocksuite/global/lit';
import { html, LitElement } from 'lit';
import { property, query, state } from 'lit/decorators.js';
import { choose } from 'lit/directives/choose.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { styleMap } from 'lit/directives/style-map.js';
import { when } from 'lit-html/directives/when.js';
import type { EditorMenuButton } from '../toolbar/menu-button';
import type { PickColorEvent } from './types';
import {
calcCustomButtonStyle,
keepColor,
packColorsWith,
preprocessColor,
rgbaToHex8,
} from './utils.js';
type Type = 'normal' | 'custom';
export class EdgelessColorPickerButton extends WithDisposable(LitElement) {
readonly #select = (e: ColorEvent) => {
e.stopPropagation();
this.#pick(e.detail);
};
switchToCustomTab = (e: MouseEvent) => {
e.stopPropagation();
this.tabType = 'custom';
// refresh menu's position
this.menuButton.show(true);
};
get colorWithoutAlpha() {
return keepColor(
this.color.startsWith('--')
? rgbaToHex8(
preprocessColor(window.getComputedStyle(this))({
type: 'normal',
value: this.color,
}).rgba
)
: this.color
);
}
get customButtonStyle() {
return calcCustomButtonStyle(this.color, this.isCustomColor, this);
}
get isCustomColor() {
return !this.palettes
.map(({ value }) => resolveColor(value, this.theme))
.includes(this.color);
}
get tabContentPadding() {
return `${this.tabType === 'custom' ? 0 : 8}px`;
}
#pick(detail: Palette) {
this.pick?.({ type: 'start' });
this.pick?.({ type: 'pick', detail });
this.pick?.({ type: 'end' });
}
override firstUpdated() {
this.disposables.addFromEvent(
this.menuButton,
'toggle',
(e: CustomEvent<boolean>) => {
const opened = e.detail;
if (!opened && this.tabType !== 'normal') {
this.tabType = 'normal';
}
}
);
}
override render() {
return html`
<editor-menu-button
.contentPadding=${this.tabContentPadding}
.button=${html`
<editor-icon-button
aria-label=${this.label}
.tooltip=${this.tooltip || this.label}
>
${when(
this.isText,
() => html`
<edgeless-text-color-icon
.color=${this.colorWithoutAlpha}
></edgeless-text-color-icon>
`,
() => html`
<edgeless-color-button
.color=${this.colorWithoutAlpha}
.hollowCircle=${this.hollowCircle}
></edgeless-color-button>
`
)}
</editor-icon-button>
`}
>
${choose(this.tabType, [
[
'normal',
() => html`
<div data-orientation="vertical">
<slot name="other"></slot>
<slot name="separator"></slot>
<edgeless-color-panel
role="listbox"
class=${ifDefined(this.colorPanelClass)}
.value=${this.color}
.theme=${this.theme}
.palettes=${this.palettes}
.hollowCircle=${this.hollowCircle}
.hasTransparent=${false}
@select=${this.#select}
>
${when(
this.enableCustomColor,
() => html`
<edgeless-color-custom-button
slot="custom"
style=${styleMap(this.customButtonStyle)}
?active=${this.isCustomColor}
@click=${this.switchToCustomTab}
></edgeless-color-custom-button>
`
)}
</edgeless-color-panel>
</div>
`,
],
[
'custom',
() => {
const packed = packColorsWith(
this.theme,
this.color,
this.originalColor
);
const type = packed.type === 'palette' ? 'normal' : packed.type;
const modes = packed.colors.map(
preprocessColor(window.getComputedStyle(this))
);
return html`
<edgeless-color-picker
class="custom"
.pick=${this.pick}
.colors=${{ type, modes }}
></edgeless-color-picker>
`;
},
],
])}
</editor-menu-button>
`;
}
@property()
accessor originalColor!: Color;
@property()
accessor color!: string;
@property()
accessor colorPanelClass: string | undefined = undefined;
@property({ attribute: false })
accessor hollowCircle: boolean = false;
@property({ attribute: false })
accessor isText!: boolean;
@property()
accessor label!: string;
@query('editor-menu-button')
accessor menuButton!: EditorMenuButton;
@property({ attribute: false })
accessor palettes: Palette[] = DefaultTheme.Palettes;
@property({ attribute: false })
accessor pick!: (event: PickColorEvent) => void;
@state()
accessor tabType: Type = 'normal';
@property({ attribute: false })
accessor theme!: ColorScheme;
@property()
accessor tooltip: string | undefined = undefined;
@property()
accessor enableCustomColor: boolean = true;
}

View File

@@ -0,0 +1,260 @@
import type { Color, ColorScheme, Palette } from '@blocksuite/affine-model';
import { DefaultTheme, resolveColor } from '@blocksuite/affine-model';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { ColorEvent } from '@blocksuite/affine-shared/utils';
import { css, html, LitElement, type PropertyValues } from 'lit';
import { property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { repeat } from 'lit/directives/repeat.js';
import isEqual from 'lodash-es/isEqual';
import { AdditionIcon } from './icons';
export class EdgelessColorButton extends LitElement {
static override styles = css`
:host {
position: relative;
width: 20px;
height: 20px;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
}
.color-unit {
position: relative;
width: 16px;
height: 16px;
display: flex;
justify-content: center;
align-items: center;
border-radius: 50%;
box-sizing: border-box;
}
.color-unit svg {
width: 100%;
height: 100%;
border-radius: 50%;
overflow: hidden;
}
:host .color-unit::after {
position: absolute;
display: block;
content: '';
width: 100%;
height: 100%;
border-radius: 50%;
box-sizing: border-box;
overflow: hidden;
border-width: 0.5px;
border-style: solid;
border-color: ${unsafeCSSVarV2('layer/insideBorder/blackBorder')};
}
:host(.black) .color-unit:after {
border-color: ${unsafeCSSVarV2('layer/insideBorder/border')};
}
:host(.large) {
width: 24px;
height: 24px;
}
:host(.large) .color-unit {
width: 20px;
height: 20px;
}
:host::after {
position: absolute;
display: block;
content: '';
width: 27px;
height: 27px;
border-radius: 50%;
box-sizing: border-box;
overflow: hidden;
pointer-events: none;
}
:host([active])::after {
border: 1.5px solid var(--affine-primary-color);
}
`;
get preprocessColor() {
const value = resolveColor(this.color, this.theme);
return value.startsWith('--') ? `var(${value})` : value;
}
override render() {
const { label, preprocessColor, hollowCircle } = this;
const additionIcon = AdditionIcon(preprocessColor, !!hollowCircle);
return html`<div class="color-unit" aria-label=${ifDefined(label)}>
${additionIcon}
</div>`;
}
@property({ attribute: true, type: Boolean })
accessor active: boolean = false;
@property({ attribute: false })
accessor color!: Color;
@property({ attribute: false })
accessor hollowCircle: boolean = false;
@property({ attribute: false })
accessor label: string | undefined = undefined;
@property({ attribute: false })
accessor theme!: ColorScheme;
}
export class EdgelessColorPanel extends LitElement {
static override styles = css`
:host {
display: grid;
grid-gap: 4px;
grid-template-columns: repeat(var(--columns, 9), 1fr);
}
/* note */
:host(.small) {
grid-template-columns: repeat(var(--columns, 6), 1fr);
grid-gap: 8px;
}
/* edgeless toolbar */
:host(.one-way) {
display: flex;
flex-wrap: nowrap;
padding: 0 2px;
gap: 14px;
box-sizing: border-box;
background: var(--affine-background-overlay-panel-color);
}
:host(.one-way.small) {
display: flex;
gap: 4px;
background: unset;
}
`;
select(palette: Palette) {
this.dispatchEvent(
new ColorEvent('select', {
detail: palette,
bubbles: true,
composed: true,
cancelable: true,
})
);
}
get resolvedValue() {
return this.value && resolveColor(this.value, this.theme);
}
override willUpdate(changedProperties: PropertyValues<this>) {
if (changedProperties.has('columns')) {
if (this.columns) {
this.style.setProperty('--columns', this.columns.toString());
} else {
this.style.removeProperty('--columns');
}
}
}
override render() {
return html`
${repeat(
this.palettes,
palette => palette.key,
palette => {
const resolvedColor = resolveColor(palette.value, this.theme);
const activated = isEqual(resolvedColor, this.resolvedValue);
return html`<edgeless-color-button
class=${classMap({ large: true })}
.label=${palette.key}
.color=${palette.value}
.theme=${this.theme}
.hollowCircle=${this.hollowCircle}
?active=${activated}
@click=${() => {
this.select(palette);
this.value = resolvedColor;
}}
>
</edgeless-color-button>`;
}
)}
<slot name="custom"></slot>
`;
}
@property({ attribute: false })
accessor hasTransparent: boolean = true;
@property({ attribute: false })
accessor hollowCircle = false;
@property({ type: Array })
accessor palettes: readonly Palette[] = DefaultTheme.Palettes;
@property({ attribute: false })
accessor theme!: ColorScheme;
@property({ attribute: false })
accessor value: Color | null = null;
@property({ attribute: false })
accessor columns: number | undefined = undefined;
}
export class EdgelessTextColorIcon extends LitElement {
static override styles = css`
:host {
display: flex;
justify-content: center;
align-items: center;
width: 20px;
height: 20px;
}
`;
get preprocessColor() {
const color = this.color;
return color.startsWith('--') ? `var(${color})` : color;
}
override render() {
return 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"
fill="currentColor"
d="M8.71093 3.85123C8.91241 3.31395 9.42603 2.95801 9.99984 2.95801C10.5737 2.95801 11.0873 3.31395 11.2888 3.85123L14.7517 13.0858C14.8729 13.409 14.7092 13.7692 14.386 13.8904C14.0628 14.0116 13.7025 13.8479 13.5813 13.5247L12.5648 10.8141H7.43487L6.41838 13.5247C6.29718 13.8479 5.93693 14.0116 5.61373 13.8904C5.29052 13.7692 5.12677 13.409 5.24797 13.0858L8.71093 3.85123ZM7.90362 9.56405H12.0961L10.1183 4.29013C10.0998 4.24073 10.0526 4.20801 9.99984 4.20801C9.94709 4.20801 9.89986 4.24073 9.88134 4.29013L7.90362 9.56405Z"
/>
<rect
x="3.3335"
y="15"
width="13.3333"
height="2.08333"
rx="1"
fill=${this.preprocessColor}
/>
</svg>
`;
}
@property({ attribute: false })
accessor color!: string;
}

View File

@@ -0,0 +1,677 @@
import type { Color } from '@blocksuite/affine-model';
import { on, once, stopPropagation } from '@blocksuite/affine-shared/utils';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { batch, computed, signal } from '@preact/signals-core';
import { html, LitElement } from 'lit';
import { property, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { live } from 'lit/directives/live.js';
import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js';
import clamp from 'lodash-es/clamp';
import { AREA_CIRCLE_R, MATCHERS, SLIDER_CIRCLE_R } from './consts.js';
import { TransparentIcon } from './icons.js';
import { COLOR_PICKER_STYLE } from './styles.js';
import type {
Hsva,
ModeRgba,
ModeTab,
ModeType,
NavTab,
NavType,
PickColorEvent,
Point,
Rgb,
} from './types.js';
import {
bound01,
defaultHsva,
eq,
hsvaToHex8,
hsvaToRgba,
linearGradientAt,
parseHexToHsva,
renderCanvas,
rgbaToHex8,
rgbaToHsva,
rgbToHex,
rgbToHsv,
} from './utils.js';
const TABS: NavTab<NavType>[] = [
{ type: 'colors', name: 'Colors' },
{ type: 'custom', name: 'Custom' },
];
export class EdgelessColorPicker extends SignalWatcher(
WithDisposable(LitElement)
) {
static override styles = COLOR_PICKER_STYLE;
#alphaRect = new DOMRect();
readonly #editAlpha = (e: InputEvent) => {
const target = e.target as HTMLInputElement;
const orignalValue = target.value;
let value = orignalValue.trim().replace(/[^0-9]/, '');
const alpha = clamp(Number(value), 0, 100);
const a = bound01(alpha, 100);
const hsva = this.hsva$.peek();
value = `${alpha}`;
if (orignalValue !== value) {
target.value = value;
}
if (hsva.a === a) return;
const x = this.#alphaRect.width * a;
this.alphaPosX$.value = x;
this.#pick();
};
readonly #editHex = (e: KeyboardEvent) => {
e.stopPropagation();
const target = e.target as HTMLInputElement;
if (e.key === 'Enter') {
const orignalValue = target.value;
let value = orignalValue.trim().replace(MATCHERS.other, '');
let matched;
if (
(matched = value.match(MATCHERS.hex3)) ||
(matched = value.match(MATCHERS.hex6))
) {
const oldHsva = this.hsva$.peek();
const hsv = parseHexToHsva(matched[1]);
const newHsva = { ...oldHsva, ...hsv };
value = rgbToHex(hsvaToRgba(newHsva));
if (orignalValue !== value) {
target.value = value;
}
if (eq(newHsva, oldHsva)) return;
this.#setControlsPos(newHsva);
this.#pick();
} else {
target.value = this.hex6WithoutHash$.peek();
}
}
};
#hueRect = new DOMRect();
#paletteRect = new DOMRect();
#pick() {
const hsva = this.hsva$.peek();
const type = this.modeType$.peek();
const value = { [type]: hsvaToHex8(hsva) };
const key = 'Custom';
if (type !== 'normal') {
const another = type === 'light' ? 'dark' : 'light';
const { hsva } = this[`${another}$`].peek();
value[another] = hsvaToHex8(hsva);
}
this.pick?.({ type: 'pick', detail: { key, value: value as Color } });
}
#pickEnd() {
this.pick?.({ type: 'end' });
}
#pickStart() {
this.pick?.({ type: 'start' });
}
#setAlphaPos(clientX: number) {
const { left, width } = this.#alphaRect;
const x = clamp(clientX - left, 0, width);
this.alphaPosX$.value = x;
}
#setAlphaPosWithWheel(y: number) {
const { width } = this.#alphaRect;
const px = this.alphaPosX$.peek();
const ax = clamp(px + (y * width) / 100, 0, width);
this.alphaPosX$.value = ax;
}
#setControlsPos({ h, s, v, a }: Hsva) {
const hx = this.#hueRect.width * h;
const px = this.#paletteRect.width * s;
const py = this.#paletteRect.height * (1 - v);
const ax = this.#alphaRect.width * a;
batch(() => {
this.huePosX$.value = hx;
this.alphaPosX$.value = ax;
this.palettePos$.value = { x: px, y: py };
});
}
#setHuePos(clientX: number) {
const { left, width } = this.#hueRect;
const x = clamp(clientX - left, 0, width);
this.huePosX$.value = x;
}
#setHuePosWithWheel(y: number) {
const { width } = this.#hueRect;
const px = this.huePosX$.peek();
const ax = clamp(px + (y * width) / 100, 0, width);
this.huePosX$.value = ax;
}
#setPalettePos(clientX: number, clientY: number) {
const { left, top, width, height } = this.#paletteRect;
const x = clamp(clientX - left, 0, width);
const y = clamp(clientY - top, 0, height);
this.palettePos$.value = { x, y };
}
#setPalettePosWithWheel(x: number, y: number) {
const { width, height } = this.#paletteRect;
const pos = this.palettePos$.peek();
const px = clamp(pos.x + (x * width) / 100, 0, width);
const py = clamp(pos.y + (y * height) / 100, 0, height);
this.palettePos$.value = { x: px, y: py };
}
#setRect({ left, top, width, height }: DOMRect, offset: number) {
return new DOMRect(
left + offset,
top + offset,
Math.round(width - offset * 2),
Math.round(height - offset * 2)
);
}
#setRects() {
this.#paletteRect = this.#setRect(
this.paletteControl.getBoundingClientRect(),
AREA_CIRCLE_R
);
this.#hueRect = this.#setRect(
this.hueControl.getBoundingClientRect(),
SLIDER_CIRCLE_R
);
this.#alphaRect = this.#setRect(
this.alphaControl.getBoundingClientRect(),
SLIDER_CIRCLE_R
);
}
#switchModeTab(type: ModeType) {
this.modeType$.value = type;
this.#setControlsPos(this.mode$.peek().hsva);
}
#switchNavTab(type: NavType) {
this.navType$.value = type;
if (type === 'colors') {
const mode = this.mode$.peek();
this.modes$.value[0].hsva = { ...mode.hsva };
this.modeType$.value = 'normal';
} else {
const [normal, light, dark] = this.modes$.value;
light.hsva = { ...normal.hsva };
dark.hsva = { ...normal.hsva };
this.modeType$.value = 'light';
}
}
override firstUpdated() {
let clicked = false;
let dragged = false;
let outed = false;
let picked = false;
let pointerenter: (() => void) | null;
let pointermove: (() => void) | null;
let pointerout: (() => void) | null;
let timerId = 0;
this.disposables.addFromEvent(this, 'wheel', (e: WheelEvent) => {
e.stopPropagation();
const target = e.composedPath()[0] as HTMLElement;
const isInHue = target === this.hueControl;
const isInAlpha = !isInHue && target === this.alphaControl;
const isInPalette = !isInAlpha && target === this.paletteControl;
picked = isInHue || isInAlpha || isInPalette;
if (timerId) {
clearTimeout(timerId);
}
// update target rect
if (picked) {
if (!timerId) {
this.#pickStart();
}
timerId = window.setTimeout(() => {
this.#pickEnd();
timerId = 0;
}, 110);
}
const update = (x: number, y: number) => {
if (!picked) return;
const absX = Math.abs(x);
const absY = Math.abs(y);
x = Math.sign(x);
y = Math.sign(y);
if (Math.hypot(x, y) === 0) return;
x *= Math.max(1, Math.log10(absX)) * -1;
y *= Math.max(1, Math.log10(absY)) * -1;
if (isInHue) {
this.#setHuePosWithWheel(x | y);
}
if (isInAlpha) {
this.#setAlphaPosWithWheel(x | y);
}
if (isInPalette) {
this.#setPalettePosWithWheel(x, y);
}
this.#pick();
};
update(e.deltaX, e.deltaY);
});
this.disposables.addFromEvent(this, 'pointerdown', (e: PointerEvent) => {
e.stopPropagation();
if (timerId) {
clearTimeout(timerId);
timerId = 0;
}
// checks pointer enter/out
pointerenter = on(this, 'pointerenter', () => (outed = false));
pointerout = on(this, 'pointerout', () => (outed = true));
// cleanups
once(document, 'pointerup', () => {
pointerenter?.();
pointermove?.();
pointerout?.();
if (picked) {
this.#pickEnd();
}
// When dragging the points, the color picker panel should not be triggered to close.
if (dragged && outed) {
once(document, 'click', stopPropagation, true);
}
pointerenter = pointermove = pointerout = null;
clicked = dragged = outed = picked = false;
});
clicked = true;
const target = e.composedPath()[0] as HTMLElement;
const isInHue = target === this.hueControl;
const isInAlpha = !isInHue && target === this.alphaControl;
const isInPalette = !isInAlpha && target === this.paletteControl;
picked = isInHue || isInAlpha || isInPalette;
// update target rect
if (picked) {
this.#pickStart();
const rect = target.getBoundingClientRect();
if (isInHue) {
this.#hueRect = this.#setRect(rect, SLIDER_CIRCLE_R);
} else if (isInAlpha) {
this.#alphaRect = this.#setRect(rect, SLIDER_CIRCLE_R);
} else if (isInPalette) {
this.#paletteRect = this.#setRect(rect, AREA_CIRCLE_R);
}
}
const update = (x: number, y: number) => {
if (!picked) return;
if (isInHue) {
this.#setHuePos(x);
}
if (isInAlpha) {
this.#setAlphaPos(x);
}
if (isInPalette) {
this.#setPalettePos(x, y);
}
this.#pick();
};
update(e.x, e.y);
pointermove = on(document, 'pointermove', (e: PointerEvent) => {
if (!clicked) return;
if (!dragged) dragged = true;
update(e.x, e.y);
});
});
this.disposables.addFromEvent(this, 'click', stopPropagation);
const batches: (() => void)[] = [];
const { type, modes } = this.colors;
// Updates UI states
if (['dark', 'light'].includes(type)) {
batches.push(() => {
this.modeType$.value = type;
this.navType$.value = 'custom';
});
}
// Updates modes
if (modes?.length) {
batches.push(() => {
let value = defaultHsva();
this.modes$.value.forEach((curr, n) => {
const m = modes[n];
curr.hsva = m ? rgbaToHsva(m.rgba) : value;
value = curr.hsva;
});
});
}
// Updates controls' positions
batches.push(() => {
const mode = this.mode$.peek();
this.#setControlsPos(mode.hsva);
});
// Updates controls' rects
this.#setRects();
batch(() => batches.forEach(fn => fn()));
this.updateComplete
.then(() => {
this.disposables.add(
this.hsva$.subscribe((hsva: Hsva) => {
const type = this.modeType$.peek();
const mode = this.modes$.value.find(mode => mode.type === type);
if (mode) {
mode.hsva = { ...hsva };
}
})
);
this.disposables.add(
this.huePosX$.subscribe((x: number) => {
const { width } = this.#hueRect;
const rgb = linearGradientAt(bound01(x, width));
// Updates palette canvas
renderCanvas(this.canvas, rgb);
this.hue$.value = rgb;
})
);
this.disposables.add(
this.hue$.subscribe((rgb: Rgb) => {
const hsva = this.hsva$.peek();
const h = rgbToHsv(rgb).h;
this.hsva$.value = { ...hsva, h };
})
);
this.disposables.add(
this.alphaPosX$.subscribe((x: number) => {
const hsva = this.hsva$.peek();
const { width } = this.#alphaRect;
const a = bound01(x, width);
this.hsva$.value = { ...hsva, a };
})
);
this.disposables.add(
this.palettePos$.subscribe(({ x, y }: Point) => {
const hsva = this.hsva$.peek();
const { width, height } = this.#paletteRect;
const s = bound01(x, width);
const v = bound01(height - y, height);
this.hsva$.value = { ...hsva, s, v };
})
);
})
.catch(console.error);
}
override render() {
return html`
<header>
<nav>
${repeat(
TABS,
tab => tab.type,
({ type, name }) => html`
<button
?active=${type === this.navType$.value}
@click=${() => this.#switchNavTab(type)}
>
${name}
</button>
`
)}
</nav>
</header>
<div class="modes" ?active=${this.navType$.value === 'custom'}>
${repeat(
[this.light$.value, this.dark$.value],
mode => mode.type,
({ type, name, hsva }) => html`
<div
class="${classMap({ mode: true, [type]: true })}"
style=${styleMap({ '--c': hsvaToHex8(hsva) })}
>
<button
?active=${this.modeType$.value === type}
@click=${() => this.#switchModeTab(type)}
>
<div class="color-wrapper">
${TransparentIcon()}
<div class="color"></div>
</div>
<div>${name}</div>
</button>
</div>
`
)}
</div>
<div class="content">
<div
class="color-palette-wrapper"
style=${styleMap(this.paletteStyle$.value)}
>
<canvas></canvas>
<div class="color-circle"></div>
<div class="color-palette"></div>
</div>
<div
class="color-slider-wrapper hue"
style=${styleMap(this.hueStyle$.value)}
>
<div class="color-circle"></div>
<div class="color-slider"></div>
</div>
<div
class="color-slider-wrapper alpha"
style=${styleMap(this.alphaStyle$.value)}
>
<div class="color-circle"></div>
<div class="color-slider"></div>
</div>
</div>
<footer>
<label class="field color">
<span>#</span>
<input
autocomplete="off"
spellcheck="false"
minlength="1"
maxlength="6"
.value=${live(this.hex6WithoutHash$.value)}
@keydown=${this.#editHex}
@cut=${stopPropagation}
@copy=${stopPropagation}
@paste=${stopPropagation}
/>
</label>
<label class="field alpha">
<input
type="number"
min="0"
max="100"
.value=${live(this.alpha100$.value)}
@input=${this.#editAlpha}
@cut=${stopPropagation}
@copy=${stopPropagation}
@paste=${stopPropagation}
/>
<span>%</span>
</label>
</footer>
`;
}
// 0-100
accessor alpha100$ = computed(
() => `${Math.round(this.hsva$.value.a * 100)}`
);
@query('.color-slider-wrapper.alpha .color-slider')
accessor alphaControl!: HTMLDivElement;
accessor alphaPosX$ = signal<number>(0);
accessor alphaStyle$ = computed(() => {
const x = this.alphaPosX$.value;
const rgba = this.rgba$.value;
const hex = `#${rgbToHex(rgba)}`;
return {
'--o': rgba.a,
'--s': `${hex}00`,
'--c': `${hex}ff`,
'--x': `${x}px`,
'--r': `${SLIDER_CIRCLE_R}px`,
};
});
@query('canvas')
accessor canvas!: HTMLCanvasElement;
@property({ attribute: false })
accessor colors: { type: ModeType; modes?: ModeRgba[] } = { type: 'normal' };
accessor dark$ = computed<ModeTab<ModeType>>(() => this.modes$.value[2]);
// #ffffff
accessor hex6$ = computed(() => this.hex8$.value.substring(0, 7));
// ffffff
accessor hex6WithoutHash$ = computed(() => this.hex6$.value.substring(1));
// #ffffffff
accessor hex8$ = computed(() => rgbaToHex8(this.rgba$.value));
accessor hsva$ = signal<Hsva>(defaultHsva());
accessor hue$ = signal<Rgb>({ r: 0, g: 0, b: 0 });
@query('.color-slider-wrapper.hue .color-slider')
accessor hueControl!: HTMLDivElement;
accessor huePosX$ = signal<number>(0);
accessor hueStyle$ = computed(() => {
const x = this.huePosX$.value;
const rgb = this.hue$.value;
return {
'--x': `${x}px`,
'--c': `#${rgbToHex(rgb)}`,
'--r': `${SLIDER_CIRCLE_R}px`,
};
});
accessor light$ = computed<ModeTab<ModeType>>(() => this.modes$.value[1]);
accessor mode$ = computed<ModeTab<ModeType>>(() => {
const modeType = this.modeType$.value;
return this.modes$.value.find(mode => mode.type === modeType)!;
});
accessor modes$ = signal<ModeTab<ModeType>[]>([
{ type: 'normal', name: 'Normal', hsva: defaultHsva() },
{ type: 'light', name: 'Light', hsva: defaultHsva() },
{ type: 'dark', name: 'Dark', hsva: defaultHsva() },
]);
accessor modeType$ = signal<ModeType>('normal');
accessor navType$ = signal<NavType>('colors');
@query('.color-palette')
accessor paletteControl!: HTMLDivElement;
accessor palettePos$ = signal<Point>({ x: 0, y: 0 });
accessor paletteStyle$ = computed(() => {
const { x, y } = this.palettePos$.value;
const c = this.hex6$.value;
return {
'--c': c,
'--x': `${x}px`,
'--y': `${y}px`,
'--r': `${AREA_CIRCLE_R}px`,
};
});
@property({ attribute: false })
accessor pick!: (event: PickColorEvent) => void;
accessor rgba$ = computed(() => hsvaToRgba(this.hsva$.value));
}

View File

@@ -0,0 +1,25 @@
import type { Rgb } from './types.js';
export const AREA_CIRCLE_R = 12.5;
export const SLIDER_CIRCLE_R = 10.5;
// [Rgb, stop][]
export const COLORS: [Rgb, number][] = [
[{ r: 1, g: 0, b: 0 }, 0 / 6],
[{ r: 1, g: 1, b: 0 }, 1 / 6],
[{ r: 0, g: 1, b: 0 }, 2 / 6],
[{ r: 0, g: 1, b: 1 }, 3 / 6],
[{ r: 0, g: 0, b: 1 }, 4 / 6],
[{ r: 1, g: 0, b: 1 }, 5 / 6],
[{ r: 1, g: 0, b: 0 }, 1],
];
export const FIRST_COLOR = COLORS[0][0];
export const MATCHERS = {
hex3: /^#?([0-9a-fA-F]{3})$/,
hex6: /^#?([0-9a-fA-F]{6})$/,
hex4: /^#?([0-9a-fA-F]{4})$/,
hex8: /^#?([0-9a-fA-F]{8})$/,
other: /[^0-9a-fA-F]/,
};

View File

@@ -0,0 +1,69 @@
import { css, html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
export class EdgelessColorCustomButton extends LitElement {
static override styles = css`
:host {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
cursor: pointer;
}
:host([active]):after {
position: absolute;
display: block;
content: '';
width: 27px;
height: 27px;
border: 1.5px solid var(--affine-primary-color);
border-radius: 50%;
box-sizing: border-box;
overflow: hidden;
pointer-events: none;
}
.color-custom {
display: flex;
align-items: center;
justify-content: center;
width: 21px;
height: 21px;
border-radius: 50%;
box-sizing: border-box;
overflow: hidden;
padding: 2px;
border: 2px solid transparent;
background:
linear-gradient(var(--c, transparent), var(--c, transparent))
content-box,
linear-gradient(var(--b, transparent), var(--b, transparent))
padding-box,
conic-gradient(
from 180deg at 50% 50%,
#d21c7e 0deg,
#c240f0 30.697514712810516deg,
#434af5 62.052921652793884deg,
#3cb5f9 93.59999656677246deg,
#3ceefa 131.40000343322754deg,
#37f7bd 167.40000128746033deg,
#2df541 203.39999914169312deg,
#e7f738 239.40000772476196deg,
#fbaf3e 273.07027101516724deg,
#fd904e 300.73712825775146deg,
#f64545 329.47510957717896deg,
#f040a9 359.0167021751404deg
)
border-box;
}
`;
override render() {
return html`<div class="color-unit color-custom"></div>`;
}
@property({ attribute: true, type: Boolean })
accessor active: boolean = false;
}

View File

@@ -0,0 +1,71 @@
import { isTransparent } from '@blocksuite/affine-model';
import { cssVarV2 } from '@blocksuite/affine-shared/theme';
import { html, svg, type TemplateResult } from 'lit';
export function TransparentIcon(hollowCircle = false) {
const CircleIcon: TemplateResult | null = hollowCircle
? svg`<circle cx="10" cy="10" r="8" fill="white" />`
: null;
return 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="M-1.17405 5.17857C-1.2241 5.5285 -1.25 5.88623 -1.25 6.25V8.39286H1.96429V11.6071H-1.25V13.75C-1.25 14.1138 -1.2241 14.4715 -1.17405 14.8214H1.96429V18.0357H0.0943102C0.602244 18.7639 1.23609 19.3978 1.96429 19.9057V18.0357L5.17857 18.0357V21.174C5.5285 21.2241 5.88623 21.25 6.25 21.25H8.39286V18.0357H11.6071V21.25H13.75C14.1138 21.25 14.4715 21.2241 14.8214 21.174V18.0357H18.0357L18.0357 19.9057C18.7639 19.3978 19.3978 18.7639 19.9057 18.0357L18.0357 18.0357V14.8214H21.174C21.2241 14.4715 21.25 14.1138 21.25 13.75V11.6071H18.0357V8.39286H21.25V6.25C21.25 5.88623 21.2241 5.5285 21.174 5.17857H18.0357V1.96429H19.9057C19.3978 1.23609 18.7639 0.602244 18.0357 0.09431L18.0357 1.96429H14.8214V-1.17405C14.4715 -1.2241 14.1138 -1.25 13.75 -1.25H11.6071V1.96429H8.39286V-1.25H6.25C5.88623 -1.25 5.5285 -1.2241 5.17857 -1.17405V1.96429H1.96429V0.0943099C1.23609 0.602244 0.602244 1.23609 0.0943099 1.96429H1.96429V5.17857H-1.17405ZM5.17857 5.17857V1.96429H8.39286V5.17857H5.17857ZM5.17857 8.39286H1.96429V5.17857H5.17857V8.39286ZM8.39286 8.39286V5.17857H11.6071V8.39286H8.39286ZM8.39286 11.6071V8.39286H5.17857V11.6071H1.96429V14.8214H5.17857V18.0357H8.39286V14.8214H11.6071V18.0357H14.8214V14.8214H18.0357V11.6071H14.8214V8.39286H18.0357V5.17857H14.8214V1.96429H11.6071V5.17857H14.8214V8.39286H11.6071V11.6071H8.39286ZM8.39286 11.6071V14.8214H5.17857V11.6071H8.39286ZM11.6071 11.6071H14.8214V14.8214H11.6071V11.6071Z"
fill="${cssVarV2('icon/tertiary')}"
/>
${CircleIcon}
</svg>
`;
}
export function CircleIcon(color: string) {
return html`
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 20 20"
fill="${color}"
>
<circle cx="10" cy="10" r="10" />
</svg>
`;
}
export function HollowCircleIcon(color: string) {
return html`
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 20 20"
fill="${color}"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M10 17C13.866 17 17 13.866 17 10C17 6.13401 13.866 3 10 3C6.13401 3 3 6.13401 3 10C3 13.866 6.13401 17 10 17ZM10 20C15.5228 20 20 15.5228 20 10C20 4.47715 15.5228 0 10 0C4.47715 0 0 4.47715 0 10C0 15.5228 4.47715 20 10 20Z"
/>
</svg>
`;
}
export function AdditionIcon(color: string, hollowCircle: boolean) {
if (isTransparent(color)) {
return TransparentIcon(hollowCircle);
}
if (hollowCircle) {
return HollowCircleIcon(color);
}
return CircleIcon(color);
}

View File

@@ -0,0 +1,41 @@
import { EdgelessColorPickerButton } from './button';
import {
EdgelessColorButton,
EdgelessColorPanel,
EdgelessTextColorIcon,
} from './color-panel';
import { EdgelessColorPicker } from './color-picker';
import { EdgelessColorCustomButton } from './custom-button';
export * from './button';
export * from './color-panel';
export * from './color-picker';
export * from './types';
export * from './utils';
export function effects() {
customElements.define('edgeless-color-picker', EdgelessColorPicker);
customElements.define(
'edgeless-color-picker-button',
EdgelessColorPickerButton
);
customElements.define(
'edgeless-color-custom-button',
EdgelessColorCustomButton
);
customElements.define('edgeless-color-button', EdgelessColorButton);
customElements.define('edgeless-color-panel', EdgelessColorPanel);
customElements.define('edgeless-text-color-icon', EdgelessTextColorIcon);
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-color-picker-button': EdgelessColorPickerButton;
'edgeless-color-picker': EdgelessColorPicker;
'edgeless-color-custom-button': EdgelessColorCustomButton;
'edgeless-color-panel': EdgelessColorPanel;
'edgeless-color-button': EdgelessColorButton;
'edgeless-text-color-icon': EdgelessTextColorIcon;
}
}

View File

@@ -0,0 +1,308 @@
import { fontSMStyle, fontXSStyle } from '@blocksuite/affine-shared/styles';
import { css } from 'lit';
export const COLOR_PICKER_STYLE = css`
:host {
display: flex;
flex-direction: column;
align-items: normal;
gap: 12px;
min-width: 198px;
padding: 16px;
}
nav {
display: flex;
padding: 2px;
align-items: flex-start;
gap: 4px;
align-self: stretch;
border-radius: 8px;
background: var(--affine-hover-color);
}
${fontXSStyle('nav button')}
nav button {
display: flex;
padding: 4px 8px;
flex-direction: column;
justify-content: center;
align-items: center;
flex: 1 0 0;
color: var(--affine-text-secondary-color);
font-weight: 600;
border-radius: 8px;
background: transparent;
border: none;
}
nav button[active] {
color: var(--affine-text-primary-color, #121212);
background: var(--affine-background-primary-color);
box-shadow: var(--affine-shadow-1);
pointer-events: none;
}
.modes {
display: none;
gap: 8px;
align-self: stretch;
}
.modes[active] {
display: flex;
}
.modes .mode {
display: flex;
padding: 2px;
flex-direction: column;
flex: 1 0 0;
}
${fontXSStyle('.modes .mode button')}
.modes .mode button {
position: relative;
display: flex;
height: 60px;
padding: 12px 12px 8px;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 4px;
align-self: stretch;
border-radius: 8px;
border: 1px solid var(--affine-border-color);
box-sizing: border-box;
font-weight: 400;
color: #8e8d91;
}
.modes .mode.light button {
background: white;
}
.modes .mode.dark button {
background: #141414;
}
.modes .mode button .color-wrapper {
display: flex;
position: relative;
z-index: 0;
flex-shrink: 0;
width: 22px;
height: 22px;
border-radius: 50%;
overflow: hidden;
}
.modes .mode button .color-wrapper .color {
width: 100%;
height: 100%;
background: var(--c);
}
.modes .mode button .color-wrapper svg {
position: absolute;
top: 0;
left: 0;
z-index: -1;
width: 100%;
height: 100%;
}
.modes .mode button[active] {
pointer-events: none;
outline: 2px solid var(--affine-brand-color, #1e96eb);
}
.content {
display: flex;
flex-direction: column;
gap: 16px;
}
.color-palette-wrapper {
position: relative;
width: 100%;
height: 170px;
}
.color-palette-wrapper canvas {
position: absolute;
width: 100%;
height: 100%;
border-radius: 8px;
}
.color-palette-wrapper::after {
content: '';
position: absolute;
top: 0;
right: 0;
left: 0;
bottom: 0;
border: 1px solid rgba(0, 0, 0, 0.1);
box-sizing: border-box;
border-radius: 8px;
overflow: hidden;
pointer-events: none;
}
.color-circle {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
width: var(--size);
height: var(--size);
left: calc(-1 * var(--size) / 2);
transform: translate(var(--x, 0), var(--y, 0));
background: transparent;
border: 0.5px solid #e3e2e4;
border-radius: 50%;
box-sizing: border-box;
box-shadow: 0px 0px 0px 0.5px #e3e3e4 inset;
filter: drop-shadow(0px 0px 12px rgba(66, 65, 73, 0.14));
pointer-events: none;
z-index: 2;
}
.color-circle::before {
content: '';
position: absolute;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--c);
box-sizing: border-box;
}
.color-circle::after {
content: '';
position: absolute;
width: calc(var(--size) - 1px);
height: calc(var(--size) - 1px);
background: transparent;
border-style: solid;
border-color: white;
border-radius: 50%;
box-sizing: border-box;
}
.color-palette-wrapper {
--size: calc(var(--r, 12.5px) * 2);
}
.color-palette-wrapper .color-circle {
top: calc(-1 * var(--size) / 2);
}
.color-palette-wrapper .color-circle::before {
opacity: var(--o, 1);
}
.color-palette-wrapper .color-circle::after {
border-width: 4px;
}
.color-palette,
.color-slider {
position: absolute;
inset: calc(-1 * var(--size) / 2);
}
.color-slider-wrapper:last-of-type {
margin-bottom: 12px;
}
.color-slider-wrapper {
display: flex;
align-items: center;
position: relative;
width: 100%;
height: 12px;
}
.color-slider-wrapper::before {
content: '';
position: absolute;
top: 0;
right: 0;
left: 0;
bottom: 0;
border-radius: 12px;
overflow: hidden;
}
.color-slider-wrapper {
--size: calc(var(--r, 10.5px) * 2);
}
.color-slider-wrapper .color-circle::after {
border-width: 2px;
}
.color-slider-wrapper.hue::before {
background: linear-gradient(
to right,
#f00 0%,
#ff0 calc(100% / 6),
#0f0 calc(200% / 6),
#0ff 50%,
#00f calc(400% / 6),
#f0f calc(500% / 6),
#f00 100%
);
}
.color-slider-wrapper.alpha::before {
background:
linear-gradient(to right, var(--s) 0%, var(--c) 100%),
conic-gradient(
#fff 25%,
#d9d9d9 0deg,
#d9d9d9 50%,
#fff 0deg,
#fff 75%,
#d9d9d9 0deg
)
0% 0% / 8px 8px;
}
.color-slider-wrapper.alpha .color-circle::before {
opacity: var(--o, 1);
}
footer {
display: flex;
justify-content: space-between;
}
.field {
display: flex;
padding: 7px 9px;
align-items: center;
gap: 4px;
border-radius: 8px;
border: 1px solid var(--affine-border-color);
background: var(--affine-background-primary-color);
box-sizing: border-box;
}
.field.color {
width: 132px;
}
.field.alpha {
width: 58px;
gap: 0;
}
${fontSMStyle('input')}
input {
display: flex;
width: 100%;
padding: 0;
background: transparent;
border: none;
outline: none;
font-weight: 400;
color: var(--affine-text-primary-color);
}
/* Chrome, Safari, Edge, Opera */
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Firefox */
input[type='number'] {
-moz-appearance: textfield;
}
`;

View File

@@ -0,0 +1,53 @@
// https://www.w3.org/TR/css-color-4/
import type { ColorScheme, Palette } from '@blocksuite/affine-model';
// Red, green, blue. All in the range [0, 1].
export type Rgb = {
// red 0-1
r: number;
// green 0-1
g: number;
// blue 0-1
b: number;
};
// Red, green, blue, alpha. All in the range [0, 1].
export type Rgba = Rgb & {
// alpha 0-1
a: number;
};
// Hue, saturation, value. All in the range [0, 1].
export type Hsv = {
// hue 0-1
h: number;
// saturation 0-1
s: number;
// value 0-1
v: number;
};
// Hue, saturation, value, alpha. All in the range [0, 1].
export type Hsva = Hsv & {
// alpha 0-1
a: number;
};
export type Point = { x: number; y: number };
export type NavType = 'colors' | 'custom';
export type NavTab<Type> = { type: Type; name: string };
export type ModeType = 'normal' | `${ColorScheme}`;
export type ModeTab<Type> = NavTab<Type> & { hsva: Hsva };
export type ModeRgba = { type: ModeType; rgba: Rgba };
export type PickColorType = 'palette' | ModeType;
export type PickColorEvent =
| { type: 'start' | 'end' }
| { type: 'pick'; detail: Palette };

View File

@@ -0,0 +1,368 @@
// https://www.w3.org/TR/css-color-4/
import type { Color, ColorScheme } from '@blocksuite/affine-model';
import clamp from 'lodash-es/clamp';
import { COLORS, FIRST_COLOR } from './consts.js';
import type {
Hsv,
Hsva,
ModeType,
PickColorType,
Point,
Rgb,
Rgba,
} from './types.js';
export const defaultPoint = (x = 0, y = 0): Point => ({ x, y });
export const defaultHsva = (): Hsva => ({ ...rgbToHsv(FIRST_COLOR), a: 1 });
export function linearGradientAt(t: number): Rgb {
if (t < 0) return COLORS[0][0];
if (t > 1) return COLORS[COLORS.length - 1][0];
let low = 0;
let high = COLORS.length;
while (low < high) {
const mid = Math.floor((low + high) / 2);
const color = COLORS[mid];
if (color[1] < t) {
low = mid + 1;
} else {
high = mid;
}
}
if (low === 0) {
low = 1;
}
const [rgb0, s0] = COLORS[low - 1];
const [rgb1, s1] = COLORS[low];
t = (t - s0) / (s1 - s0);
const [r, g, b] = [
lerp(rgb0.r, rgb1.r, t),
lerp(rgb0.g, rgb1.g, t),
lerp(rgb0.b, rgb1.b, t),
];
return { r, g, b };
}
const lerp = (a: number, b: number, t: number) => a + t * (b - a);
export const bound01 = (n: number, max: number) => {
n = clamp(n, 0, max);
// Handle floating point rounding errors
if (Math.abs(n - max) < 0.000001) {
return 1;
}
// Convert into [0, 1] range if it isn't already
return (n % max) / max;
};
// Converts an RGB color value to HSV
export const rgbToHsv = ({ r, g, b }: Rgb): Hsv => {
const v = Math.max(r, g, b); // value
const d = v - Math.min(r, g, b);
if (d === 0) {
return { h: 0, s: 0, v };
}
const s = d / v;
let h = 0;
if (v === r) {
h = (g - b) / d + (g < b ? 6 : 0);
} else if (v === g) {
h = (b - r) / d + 2;
} else {
h = (r - g) / d + 4;
}
h /= 6;
return { h, s, v };
};
// Converts an HSV color value to RGB
export const hsvToRgb = ({ h, s, v }: Hsv): Rgb => {
if (h < 0) h = (h + 1) % 1; // wrap
h *= 6;
s = clamp(s, 0, 1);
const i = Math.floor(h),
f = h - i,
p = v * (1 - s),
q = v * (1 - f * s),
t = v * (1 - (1 - f) * s),
m = i % 6;
let rgb = [0, 0, 0];
if (m === 0) rgb = [v, t, p];
else if (m === 1) rgb = [q, v, p];
else if (m === 2) rgb = [p, v, t];
else if (m === 3) rgb = [p, q, v];
else if (m === 4) rgb = [t, p, v];
else if (m === 5) rgb = [v, p, q];
const [r, g, b] = rgb;
return { r, g, b };
};
// Converts a RGBA color value to HSVA
export const rgbaToHsva = (rgba: Rgba): Hsva => ({
...rgbToHsv(rgba),
a: rgba.a,
});
// Converts an HSVA color value to RGBA
export const hsvaToRgba = (hsva: Hsva): Rgba => ({
...hsvToRgb(hsva),
a: hsva.a,
});
// Converts a RGB color to hex
export const rgbToHex = ({ r, g, b }: Rgb) =>
[r, g, b]
.map(n => n * 255)
.map(Math.round)
.map(s => s.toString(16).padStart(2, '0'))
.join('');
// Converts an RGBA color to CSS's hex8 string
export const rgbaToHex8 = ({ r, g, b, a }: Rgba) => {
const hex = [r, g, b, a]
.map(n => n * 255)
.map(Math.round)
.map(n => n.toString(16).padStart(2, '0'))
.join('');
return `#${hex}`;
};
// Converts an HSVA color to CSS's hex8 string
export const hsvaToHex8 = (hsva: Hsva) => rgbaToHex8(hsvaToRgba(hsva));
// Parses an hex string to RGBA.
export const parseHexToRgba = (hex: string) => {
if (hex.startsWith('#')) {
hex = hex.substring(1);
}
const len = hex.length;
let arr: string[] = [];
if (len === 3 || len === 4) {
arr = hex.split('').map(s => s.repeat(2));
} else if (len === 6 || len === 8) {
arr = Array.from<number>({ length: len / 2 })
.fill(0)
.map((n, i) => n + i * 2)
.map(n => hex.substring(n, n + 2));
}
const [r, g, b, a = 1] = arr
.map(s => parseInt(s, 16))
.map(n => bound01(n, 255));
return { r, g, b, a };
};
// Parses an hex string to HSVA
export const parseHexToHsva = (hex: string) => rgbaToHsva(parseHexToRgba(hex));
// Compares two hsvs.
export const eq = (lhs: Hsv, rhs: Hsv) =>
lhs.h === rhs.h && lhs.s === rhs.s && lhs.v === rhs.v;
export const renderCanvas = (canvas: HTMLCanvasElement, rgb: Rgb) => {
const { width, height } = canvas;
const ctx = canvas.getContext('2d')!;
ctx.globalCompositeOperation = 'color';
ctx.clearRect(0, 0, width, height);
// Saturation: from top to bottom
const s = ctx.createLinearGradient(0, 0, 0, height);
s.addColorStop(0, '#0000'); // transparent
s.addColorStop(1, '#000'); // black
ctx.fillStyle = s;
ctx.fillRect(0, 0, width, height);
// Value: from left to right
const v = ctx.createLinearGradient(0, 0, width, 0);
v.addColorStop(0, '#fff'); // white
v.addColorStop(1, `#${rgbToHex(rgb)}`); // picked color
ctx.fillStyle = v;
ctx.fillRect(0, 0, width, height);
};
// Drops alpha value
export const keepColor = (color: string) =>
color.length > 7 && !color.endsWith('transparent')
? color.substring(0, 7)
: color;
export const parseStringToRgba = (value: string) => {
value = value.trim();
// Compatible old format: `--affine-palette-transparent`
if (value.endsWith('transparent')) {
return { r: 1, g: 1, b: 1, a: 0 };
}
if (value.startsWith('#')) {
return parseHexToRgba(value);
}
if (value.startsWith('rgb')) {
const [r, g, b, a = 1] = value
.replace(/^rgba?/, '')
.replace(/\(|\)/, '')
.split(',')
.map(s => parseFloat(s.trim()))
// In CSS, the alpha is already in the range [0, 1]
.map((n, i) => bound01(n, i === 3 ? 1 : 255));
return { r, g, b, a };
}
return { r: 0, g: 0, b: 0, a: 1 };
};
// Preprocess Color
export const preprocessColor = (style: CSSStyleDeclaration) => {
return ({ type, value }: { type: ModeType; value: string }) => {
if (value.startsWith('--')) {
// Compatible old format: `--affine-palette-transparent`
value = value.endsWith('transparent')
? 'transparent'
: style.getPropertyValue(value);
}
const rgba = parseStringToRgba(value);
return { type, rgba };
};
};
/**
* Packs to generate an object with a field name and picked color detail
*
* @param key - The model's field name
* @param detail - The picked color detail
* @returns An object
*
* @example
*
* ```json
* { 'fillColor': '--affine-palette-shape-yellow' }
* { 'fillColor': '#ffffff' }
* { 'fillColor': { normal: '#ffffffff' }}
* { 'fillColor': { light: '#fff000ff', 'dark': '#0000fff00' }}
* ```
*/
export const packColor = (key: string, color: Color) => {
return { [key]: typeof color === 'object' ? { ...color } : color };
};
/**
* Packs to generate a color array with the color-scheme
*
* @param colorScheme - The current color theme
* @param value - The color value
* @param oldColor - The old color
* @returns A color array
*/
export const packColorsWith = (
colorScheme: ColorScheme,
value: string,
oldColor: Color
) => {
const colors: { type: ModeType; value: string }[] = [
{ type: 'normal', value },
{ type: 'light', value },
{ type: 'dark', value },
];
let type: PickColorType = 'palette';
if (typeof oldColor === 'object') {
if ('normal' in oldColor) {
type = 'normal';
colors[0].value = oldColor.normal ?? value;
} else {
type = colorScheme;
colors[1].value = oldColor.light ?? value;
colors[2].value = oldColor.dark ?? value;
}
}
return { type, colors };
};
export const calcCustomButtonStyle = (
color: string,
isCustomColor: boolean,
ele: Element
) => {
let b = 'transparent';
let c = 'transparent';
if (!isCustomColor) {
return { '--b': b, '--c': c };
}
if (color.startsWith('--')) {
if (!color.endsWith('transparent')) {
b = 'var(--affine-background-overlay-panel-color)';
c = keepColor(
rgbaToHex8(
preprocessColor(window.getComputedStyle(ele))({
type: 'normal',
value: color,
}).rgba
)
);
}
} else {
b = 'var(--affine-background-overlay-panel-color)';
c = keepColor(color);
}
return { '--b': b, '--c': c };
};
export const adjustColorAlpha = (color: Color, a: number): Color => {
let newColor;
if (typeof color === 'object') {
if ('normal' in color) {
const rgba = parseStringToRgba(color.normal);
rgba.a = a;
newColor = { normal: rgbaToHex8(rgba) };
} else {
const newDarkRgba = parseStringToRgba(color.dark);
newDarkRgba.a = a;
const newLightRgba = parseStringToRgba(color.light);
newLightRgba.a = a;
newColor = {
dark: rgbaToHex8(newDarkRgba),
light: rgbaToHex8(newLightRgba),
};
}
} else {
const rgba = parseStringToRgba(color);
rgba.a = a;
newColor = rgbaToHex8(rgba);
}
return newColor;
};

View File

@@ -0,0 +1,312 @@
import { IS_MOBILE } from '@blocksuite/global/env';
import {
CheckBoxCheckSolidIcon,
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 { ifDefined } from 'lit-html/directives/if-defined.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;
testId?: string;
closeOnSelect?: boolean;
};
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?.();
if (this.data.closeOnSelect !== false) {
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}"
data-testid=${ifDefined(this.data.testId)}
>
${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);
}
// oxlint-disable-next-line sonarjs/no-identical-functions
onClick() {
if (this.data.select(this) !== false) {
this.menu.options.onComplete?.();
if (this.data.closeOnSelect !== false) {
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}"
data-testid=${ifDefined(this.data.testId)}
>
${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;
info?: TemplateResult;
prefix?: TemplateResult;
postfix?: TemplateResult;
isSelected?: boolean;
select: (ele: HTMLElement) => void | false;
onHover?: (hover: boolean) => void;
class?: MenuClass;
closeOnSelect?: boolean;
hide?: () => boolean;
testId?: string;
}) =>
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} ${config.info}
</div>
${config.postfix ?? (config.isSelected ? DoneIcon() : undefined)}
`;
},
onHover: config.onHover,
select: config.select,
closeOnSelect: config.closeOnSelect,
class: {
'selected-item': config.isSelected ?? false,
...config.class,
},
testId: config.testId,
};
return renderButton(data, menu);
},
checkbox:
(config: {
name: string;
checked: ReadonlySignal<boolean>;
postfix?: TemplateResult;
label?: () => TemplateResult;
select: (checked: boolean) => boolean;
class?: ClassInfo;
testId?: string;
}) =>
menu => {
if (!menu.search(config.name)) {
return;
}
const data: MenuButtonData = {
content: () => html`
${
config.checked.value
? CheckBoxCheckSolidIcon({ 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 ?? {},
testId: config.testId,
};
return html`${keyed(config.name, renderButton(data, menu))}`;
},
toggleSwitch:
(config: {
name: string;
on: boolean;
prefix?: TemplateResult;
postfix?: TemplateResult;
label?: () => TemplateResult;
onChange: (on: boolean) => void;
class?: ClassInfo;
testId?: string;
}) =>
menu => {
if (!menu.search(config.name)) {
return;
}
const onChange = (on: boolean) => {
config.onChange(on);
};
const data: MenuButtonData = {
content: () => html`
${config.prefix}
<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 ?? {},
testId: config.testId,
};
return html`${keyed(config.name, renderButton(data, menu))}`;
},
} satisfies Record<string, MenuItemRender<never>>;

View File

@@ -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>>;

View File

@@ -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;
}

View File

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

View File

@@ -0,0 +1,30 @@
import { MenuButton, MobileMenuButton } from './button';
import { MenuInput, MobileMenuInput } from './input';
import { MenuDivider } from './menu-divider';
import { MenuComponent, MobileMenuComponent } from './menu-renderer';
import { MenuSubMenu, MobileSubMenu } from './sub-menu';
export * from './button';
export * from './focusable';
export * from './group';
export * from './input';
export * from './item';
export * from './menu';
export * from './menu-all';
export * from './menu-divider';
export * from './menu-renderer';
export * from './sub-menu';
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);
customElements.define('menu-divider', MenuDivider);
}
export * from './types.js';

View File

@@ -0,0 +1,267 @@
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;
onBlur?: (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 readonly onCompositionEnd = () => {
this.data.onChange?.(this.inputRef.value);
};
private readonly onBlur = () => {
this.data.onBlur?.(this.inputRef.value);
};
private readonly onInput = (e: InputEvent) => {
e.stopPropagation();
if (e.isComposing) return;
this.data.onChange?.(this.inputRef.value);
};
private readonly 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.inputRef.blur();
this.menu.close();
return;
}
};
private readonly 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(() => {
if (!this.data.disableAutoFocus) {
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);
}}"
@blur="${this.onBlur}"
@input="${this.onInput}"
placeholder="${this.data.placeholder ?? ''}"
@keypress="${this.stopPropagation}"
@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 readonly onCompositionEnd = () => {
this.data.onChange?.(this.inputRef.value);
};
private readonly onInput = (e: InputEvent) => {
e.stopPropagation();
if (e.isComposing) return;
this.data.onChange?.(this.inputRef.value);
};
private readonly 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;
onBlur?: (value: string) => void;
disableAutoFocus?: boolean;
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,
onBlur: config.onBlur,
disableAutoFocus: config.disableAutoFocus,
};
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>>;

View File

@@ -0,0 +1,12 @@
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { ShadowlessElement } from '@blocksuite/std';
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;
}

View File

@@ -0,0 +1,14 @@
import { menuButtonItems } from './button';
import { menuDynamicItems } from './dynamic';
import { menuGroupItems } from './group';
import { menuInputItems } from './input';
import { subMenuItems } from './sub-menu';
import type { MenuItemRender } from './types';
export const menu = {
...menuButtonItems,
...subMenuItems,
...menuInputItems,
...menuGroupItems,
...menuDynamicItems,
} satisfies Record<string, MenuItemRender<never>>;

View File

@@ -0,0 +1,50 @@
import { css, html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
// FIXME: horizontal
export class MenuDivider extends LitElement {
static override styles = css`
:host {
display: inline-block;
}
.divider {
background-color: var(--affine-border-color);
}
.divider.vertical {
width: 1px;
height: 100%;
margin: 0 var(--divider-margin);
}
.divider.horizontal {
width: 100%;
height: 1px;
margin: var(--divider-margin) 0;
}
`;
override render() {
const dividerStyles = styleMap({
'--divider-margin': `${this.dividerMargin}px`,
});
return html`<div
class="divider ${this.vertical ? 'vertical' : 'horizontal'}"
style=${dividerStyles}
></div>`;
}
@property({ attribute: false })
accessor dividerMargin = 7;
@property({ attribute: false })
accessor vertical = false;
}
declare global {
interface HTMLElementTagNameMap {
'menu-divider': MenuDivider;
}
}

View File

@@ -0,0 +1,596 @@
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { IS_MOBILE } from '@blocksuite/global/env';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import {
ArrowLeftBigIcon,
ArrowLeftSmallIcon,
CloseIcon,
SearchIcon,
} from '@blocksuite/icons/lit';
import { ShadowlessElement } from '@blocksuite/std';
import { RANGE_SYNC_EXCLUDE_ATTR } from '@blocksuite/std/inline';
import {
autoPlacement,
autoUpdate,
computePosition,
type Middleware,
offset,
type Placement,
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: 320px;
max-width: 320px;
max-height: 700px;
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 readonly _clickContainer = (e: MouseEvent) => {
e.stopPropagation();
this.focusInput();
this.menu.closeSubMenu();
};
private readonly 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) {
const result = onBack(this.menu);
if (result !== false) {
this.menu.close();
}
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;padding:3px 4px 3px 2px"
@mouseenter="${() => this.menu.closeSubMenu()}"
>
${
title.onBack
? html` <div
@click="${() => {
const result = title.onBack?.(this.menu);
if (result !== false) {
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 {
'mobile-menu': 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.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true');
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;
placement?: Placement;
}
) => {
const close = () => {
modal.remove();
options?.onClose?.();
};
const modal = createModal(target.root);
autoUpdate(target.targetRect, content, () => {
computePosition(target.targetRect, content, {
placement: options?.placement,
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);
model.style.position = 'fixed';
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;
placement?: Placement;
}
): 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),
shift({ padding: 8 }),
],
container: props.container,
placement: props.placement,
});
return {
close: closePopup,
menu,
reopen: () => {
popMenu(target, props);
},
};
};
export const popFilterableSimpleMenu = (
target: PopupTarget,
options: MenuConfig[],
onClose?: () => void,
placement: Placement = 'bottom-start'
) => {
popMenu(target, {
options: {
items: options,
onClose,
},
placement,
});
};

View File

@@ -0,0 +1,170 @@
import { IS_MOBILE } from '@blocksuite/global/env';
import { computed, signal } from '@preact/signals-core';
import type { TemplateResult } from 'lit';
import { MenuFocusable } from './focusable.js';
import type { MenuComponentInterface } from './types.js';
export type MenuConfig = (
menu: Menu,
index: number
) => TemplateResult | undefined;
export type MenuOptions = {
onComplete?: () => void;
onClose?: () => void;
title?: {
text: string;
onBack?: (menu: Menu) => boolean | void;
onClose?: () => void;
postfix?: () => TemplateResult;
};
search?: {
placeholder?: string;
};
items: MenuConfig[];
testId?: string;
};
// 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 readonly _currentFocused$ = signal<MenuFocusable>();
private readonly _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
? document.createElement('mobile-menu')
: document.createElement('affine-menu');
this.menuElement.menu = this;
if (this.options.testId) {
this.menuElement.dataset.testid = this.options.testId;
}
// 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 = [];
// oxlint-disable-next-line typescript/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;
}
}

View File

@@ -0,0 +1,273 @@
import { IS_MOBILE } from '@blocksuite/global/env';
import { ArrowRightSmallIcon } from '@blocksuite/icons/lit';
import {
autoPlacement,
autoUpdate,
computePosition,
type Middleware,
offset,
shift,
} from '@floating-ui/dom';
import { css, html, nothing, type TemplateResult } from 'lit';
import { property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { MenuButton } from './button.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;
openOnHover?: boolean;
middleware?: Middleware[];
autoHeight?: boolean;
closeOnSelect?: boolean;
};
export const subMenuOffset = offset({
mainAxis: 16,
crossAxis: 0,
});
export const subMenuPlacements = autoPlacement({
allowedPlacements: ['bottom-end'],
});
export const subMenuMiddleware = [subMenuOffset, subMenuPlacements];
export const dropdownSubMenuMiddleware = [
autoPlacement({ allowedPlacements: ['bottom-end'] }),
offset({ mainAxis: 8, crossAxis: 0 }),
shift({ crossAxis: true }),
];
export class MenuSubMenu extends MenuFocusable {
static override styles = [
MenuButton.styles,
css`
.affine-menu-button svg:last-child {
transition: transform 150ms cubic-bezier(0.42, 0, 1, 1);
}
affine-menu-sub-menu.active .affine-menu-button svg:last-child {
transform: rotate(90deg);
}
`,
];
createTime = 0;
override connectedCallback() {
super.connectedCallback();
this.createTime = Date.now();
if (this.data.openOnHover !== false) {
this.disposables.addFromEvent(this, 'mouseenter', this.onMouseEnter);
}
this.disposables.addFromEvent(this, 'click', e => {
e.preventDefault();
e.stopPropagation();
if (this.data.select) {
this.data.select();
if (this.data.closeOnSelect !== false) {
this.menu.close();
}
} else {
this.openSubMenu();
}
});
}
onMouseEnter() {
if (Date.now() - this.createTime > 100) {
this.openSubMenu();
}
}
override onPressEnter() {
this.onMouseEnter();
}
openSubMenu() {
if (this.data.openOnHover === false) {
const { menu } = popMenu(popupTargetFromElement(this), {
options: {
...this.data.options,
onComplete: () => {
if (this.data.closeOnSelect !== false) {
this.menu.close();
}
},
onClose: () => {
menu.menuElement.remove();
this.data.options.onClose?.();
},
},
middleware: this.data.middleware,
});
if (this.data.autoHeight) {
menu.menuElement.style.minHeight = 'fit-content';
menu.menuElement.style.maxHeight = 'fit-content';
}
menu.menuElement.style.minWidth = '200px';
this.menu.openSubMenu(menu);
return;
}
const focus = this.menu.currentFocused$.value;
const menu = new Menu({
...this.data.options,
onComplete: () => {
if (this.data.closeOnSelect !== false) {
this.menu.close();
}
},
onClose: () => {
menu.menuElement.remove();
this.menu.focusTo(focus);
this.data.options.onClose?.();
unsub();
},
});
this.menu.menuElement.parentElement?.append(menu.menuElement);
if (this.data.autoHeight) {
menu.menuElement.style.minHeight = 'fit-content';
menu.menuElement.style.maxHeight = 'fit-content';
}
menu.menuElement.style.minWidth = '200px';
const unsub = autoUpdate(this, menu.menuElement, () => {
computePosition(this, menu.menuElement, {
middleware: this.data.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);
// in case that the menu is not closed, but the component is removed,
this.disposables.add(unsub);
}
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: () => {
if (this.data.closeOnSelect !== false) {
this.menu.close();
}
},
onClose: () => {
menu.menuElement.remove();
this.data.options.onClose?.();
},
},
middleware: this.data.middleware,
});
if (this.data.autoHeight) {
menu.menuElement.style.minHeight = 'fit-content';
menu.menuElement.style.maxHeight = 'fit-content';
}
menu.menuElement.style.minWidth = '200px';
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;
openOnHover?: boolean;
middleware?: Middleware[];
autoHeight?: boolean;
closeOnSelect?: 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,
openOnHover: config.openOnHover,
middleware: config.middleware,
autoHeight: config.autoHeight,
closeOnSelect: config.closeOnSelect,
};
return renderSubMenu(data, menu);
},
} satisfies Record<string, MenuItemRender<never>>;

View File

@@ -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;
}

View File

@@ -0,0 +1,639 @@
import { clamp } from '@blocksuite/global/gfx';
import { WithDisposable } from '@blocksuite/global/lit';
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 { 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 readonly _maxYear = 2099;
private readonly _minYear = 1000;
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._yearCursor + offset,
this._minYear,
this._maxYear
);
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 _clampCursorYear() {
const year = this._cursor.getFullYear();
if (year < this._minYear) {
this._cursor = new Date(this._minYear, 0, 1);
} else if (year > this._maxYear) {
this._cursor = new Date(this._maxYear, 11, 31);
}
}
private _moveMonth(offset: number) {
this._cursor.setMonth(this._cursor.getMonth() + offset);
this._clampCursorYear();
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._clampCursorYear();
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.year, this._minYear, this._maxYear);
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;
}
}

View File

@@ -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>
`;

View File

@@ -0,0 +1,7 @@
import { DatePicker } from './date-picker.js';
export * from './date-picker.js';
export function effects() {
customElements.define('date-picker', DatePicker);
}

View File

@@ -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);
}
`;

View File

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

View File

@@ -0,0 +1,51 @@
import { type Rect } from '@blocksuite/global/gfx';
import { css, html, LitElement } from 'lit';
import { property, state } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
export class DropIndicator extends LitElement {
static override styles = css`
.affine-drop-indicator {
position: absolute;
top: 0;
left: 0;
background: var(--affine-primary-color);
transition-property: 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`,
top: `${top}px`,
left: `${left}px`,
zIndex: this.zIndex,
});
return html`<div class="affine-drop-indicator" style=${style}></div>`;
}
@property({ attribute: false })
accessor rect: Rect | null = null;
@state()
accessor zIndex = 2;
}
declare global {
interface HTMLElementTagNameMap {
'affine-drop-indicator': DropIndicator;
}
}

View File

@@ -0,0 +1,300 @@
import { NoteBlockModel } from '@blocksuite/affine-model';
import {
calcDropTarget,
type DropTarget,
getClosestBlockComponentByPoint,
isInsidePageEditor,
matchModels,
} from '@blocksuite/affine-shared/utils';
import { createIdentifier } from '@blocksuite/global/di';
import type { IVec } from '@blocksuite/global/gfx';
import { Point } from '@blocksuite/global/gfx';
import {
type BlockComponent,
type BlockStdScope,
type EditorHost,
LifeCycleWatcher,
} from '@blocksuite/std';
import { SurfaceBlockModel } from '@blocksuite/std/gfx';
import type { BlockModel, ExtensionType } from '@blocksuite/store';
import { computed, signal } from '@preact/signals-core';
import throttle from 'lodash-es/throttle';
import { DropIndicator } from './drop-indicator';
export type DropProps = {
std: BlockStdScope;
files: File[];
targetModel: BlockModel | null;
placement: 'before' | 'after';
point: IVec;
};
export type FileDropOptions = {
flavour: string;
onDrop?: (props: DropProps) => boolean;
};
/**
* Handles resources from outside.
* Uses `drag over` to handle it.
*/
export class FileDropExtension extends LifeCycleWatcher {
static override readonly key = 'FileDropExtension';
indicator: DropIndicator = new DropIndicator();
dragging$ = signal(false);
point$ = signal<Point | null>(null);
private _disableIndicator = false;
closestElement$ = signal<BlockComponent | null>(null);
dropTarget$ = computed<DropTarget | null>(() => {
let target = null;
const element = this.closestElement$.value;
if (!element) return target;
const model = element.model;
const parent = this.std.store.getParent(model);
if (!matchModels(parent, [SurfaceBlockModel])) {
const point = this.point$.value;
target = point && calcDropTarget(point, model, element);
}
return target;
});
getDropTargetModel(model: BlockModel | null) {
// Existed or In Edgeless
if (model || !isInsidePageEditor(this.editorHost)) return model;
const rootModel = this.doc.root;
if (!rootModel) return null;
let lastNote = rootModel.children[rootModel.children.length - 1];
if (!lastNote || !matchModels(lastNote, [NoteBlockModel])) {
const newNoteId = this.doc.addBlock('affine:note', {}, rootModel.id);
const newNote = this.doc.getBlock(newNoteId)?.model;
if (!newNote) return null;
lastNote = newNote;
}
const lastItem = lastNote.children[lastNote.children.length - 1];
if (lastItem) {
model = lastItem;
} else {
const newParagraphId = this.doc.addBlock(
'affine:paragraph',
{},
lastNote,
0
);
model = this.doc.getBlock(newParagraphId)?.model ?? null;
}
return model;
}
shouldIgnoreEvent = (event: DragEvent, shouldCheckFiles?: boolean) => {
const dataTransfer = event.dataTransfer;
if (!dataTransfer) return true;
const effectAllowed = dataTransfer.effectAllowed;
if (effectAllowed === 'none') return true;
if (!shouldCheckFiles) return false;
const droppedFiles = dataTransfer.files;
if (!droppedFiles || !droppedFiles.length) return true;
return false;
};
updatePoint = (event: DragEvent) => {
const { clientX, clientY } = event;
const oldPoint = this.point$.peek();
if (
oldPoint &&
Math.round(oldPoint.x) === Math.round(clientX) &&
Math.round(oldPoint.y) === Math.round(clientY)
)
return;
this.point$.value = new Point(clientX, clientY);
};
onDragLeave = () => {
this.point$.value = null;
};
onDragOver = (event: DragEvent) => {
event.preventDefault();
if (this.shouldIgnoreEvent(event)) return;
this.updatePoint(event);
};
onDrop = (event: DragEvent) => {
event.preventDefault();
if (this.shouldIgnoreEvent(event, true)) return;
this.updatePoint(event);
};
get doc() {
return this.std.store;
}
get editorHost(): EditorHost {
return this.std.host;
}
override unmounted(): void {
super.unmounted();
this.indicator.remove();
}
override mounted() {
super.mounted();
const std = this.std;
std.host.ownerDocument.body.append(this.indicator);
std.event.disposables.add(
this.point$.subscribe(
throttle(
value => {
if (!value) {
this.closestElement$.value = null;
return;
}
const element = getClosestBlockComponentByPoint(value);
if (!element) {
return;
}
if (element === this.closestElement$.value) {
return;
}
this.closestElement$.value = element;
},
144,
{ leading: true, trailing: true }
)
)
);
std.event.disposables.add(
this.dropTarget$.subscribe(target => {
this.indicator.rect = this._disableIndicator
? null
: (target?.rect ?? null);
})
);
std.event.disposables.add(
std.event.add('nativeDragStart', () => {
this.dragging$.value = true;
})
);
std.event.disposables.add(
std.event.add('nativeDragEnd', () => {
this.dragging$.value = false;
})
);
std.event.disposables.add(
std.dnd.monitor({
onDragStart: () => {
this._disableIndicator = true;
},
onDrop: () => {
this._disableIndicator = false;
},
})
);
std.event.disposables.add(
std.event.add('nativeDragOver', context => {
const event = context.get('dndState').raw;
if (this.dragging$.peek()) {
event.preventDefault();
event.stopPropagation();
return;
}
this.onDragOver(event);
})
);
std.event.disposables.add(
std.event.add('nativeDragLeave', () => {
this.onDragLeave();
})
);
std.event.disposables.add(
std.event.add('nativeDrop', context => {
const event = context.get('dndState').raw;
const { x, y, dataTransfer } = event;
const droppedFiles = dataTransfer?.files;
if (!droppedFiles || !droppedFiles.length) {
this.onDragLeave();
return;
}
this.onDrop(event);
const target = this.dropTarget$.peek();
const std = this.std;
const targetModel = this.getDropTargetModel(
target?.modelState.model ?? null
);
const placement = target?.placement === 'before' ? 'before' : 'after';
const values = std.provider
.getAll(FileDropConfigExtensionIdentifier)
.values();
for (const ext of values) {
if (!ext.onDrop) continue;
const options = {
std,
files: [...droppedFiles],
targetModel,
placement,
point: [x, y],
} satisfies DropProps;
if (ext.onDrop(options)) break;
}
this.onDragLeave();
})
);
}
}
const FileDropConfigExtensionIdentifier = createIdentifier<FileDropOptions>(
'FileDropConfigExtension'
);
export const FileDropConfigExtension = (
options: FileDropOptions
): ExtensionType => {
const identifier = FileDropConfigExtensionIdentifier(options.flavour);
return {
setup: di => {
di.addImpl(identifier, () => options);
},
};
};

View File

@@ -0,0 +1,13 @@
import { DropIndicator } from './drop-indicator';
export {
type DropProps,
FileDropConfigExtension,
FileDropExtension,
type FileDropOptions,
} from './file-drop-manager';
export { DropIndicator };
export function effects() {
customElements.define('affine-drop-indicator', DropIndicator);
}

View File

@@ -0,0 +1,7 @@
import { EdgelessLineStylesPanel } from './line-styles-panel';
export * from './line-styles-panel';
export function effects() {
customElements.define('edgeless-line-styles-panel', EdgelessLineStylesPanel);
}

View File

@@ -0,0 +1,113 @@
import { LineWidth, StrokeStyle } from '@blocksuite/affine-model';
import { WithDisposable } from '@blocksuite/global/lit';
import { BanIcon, DashLineIcon, StraightLineIcon } from '@blocksuite/icons/lit';
import { css, html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { repeat } from 'lit/directives/repeat.js';
export type LineDetailType =
| {
type: 'size';
value: LineWidth;
}
| {
type: 'style';
value: StrokeStyle;
};
const LINE_STYLE_LIST = [
{
key: 'Solid',
value: StrokeStyle.Solid,
icon: StraightLineIcon(),
},
{
key: 'Dash',
value: StrokeStyle.Dash,
icon: DashLineIcon(),
},
{
key: 'None',
value: StrokeStyle.None,
icon: BanIcon(),
},
];
export class EdgelessLineStylesPanel extends WithDisposable(LitElement) {
static override styles = css`
edgeless-line-width-panel {
flex: 1;
}
`;
select(detail: LineDetailType) {
this.dispatchEvent(
new CustomEvent('select', {
detail,
bubbles: true,
composed: true,
cancelable: true,
})
);
}
override render() {
const { lineSize, lineStyle, lineStyles } = this;
return html`
<edgeless-line-width-panel
.disabled=${lineStyle === StrokeStyle.None}
.selectedSize=${lineSize}
@select=${(e: CustomEvent<LineWidth>) => {
e.stopPropagation();
this.select({ type: 'size', value: e.detail });
}}
></edgeless-line-width-panel>
<editor-toolbar-separator></editor-toolbar-separator>
${repeat(
LINE_STYLE_LIST.filter(item => lineStyles.includes(item.value)),
item => item.value,
({ key, icon, value }) => {
const active = lineStyle === value;
const classInfo = {
'line-style-button': true,
[`mode-${value}`]: true,
};
if (active) classInfo['active'] = true;
return html`
<editor-icon-button
class=${classMap(classInfo)}
.tooltip="${key}"
.withHover=${active}
@click=${() => this.select({ type: 'style', value })}
>
${icon}
</editor-icon-button>
`;
}
)}
`;
}
@property({ attribute: false })
accessor lineStyle!: StrokeStyle;
@property({ attribute: false })
accessor lineSize: LineWidth = LineWidth.Two;
@property({ attribute: false })
accessor lineStyles: StrokeStyle[] = [
StrokeStyle.Solid,
StrokeStyle.Dash,
StrokeStyle.None,
];
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-line-styles-panel': EdgelessLineStylesPanel;
}
}

View File

@@ -0,0 +1,7 @@
import { EdgelessLineWidthPanel } from './line-width-panel';
export * from './line-width-panel';
export function effects() {
customElements.define('edgeless-line-width-panel', EdgelessLineWidthPanel);
}

View File

@@ -0,0 +1,50 @@
import { BRUSH_LINE_WIDTHS, LineWidth } from '@blocksuite/affine-model';
import { WithDisposable } from '@blocksuite/global/lit';
import { html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import type { SliderSelectEvent } from '../slider';
export class EdgelessLineWidthPanel extends WithDisposable(LitElement) {
private _onSelect(lineWidth: number) {
this.dispatchEvent(
new CustomEvent('select', {
detail: lineWidth,
bubbles: true,
composed: true,
cancelable: true,
})
);
}
override render() {
return html`<affine-slider
?disabled=${this.disabled}
.range=${{ points: this.lineWidths }}
.value=${this.selectedSize}
.tooltip=${this.hasTooltip ? 'Thickness' : undefined}
@select=${(e: SliderSelectEvent) => {
e.stopPropagation();
this._onSelect(e.detail.value);
}}
></affine-slider>`;
}
@property({ attribute: false })
accessor disabled = false;
@property({ attribute: false })
accessor hasTooltip = true;
@property({ attribute: false })
accessor lineWidths: number[] = BRUSH_LINE_WIDTHS;
@property({ attribute: false })
accessor selectedSize: number = LineWidth.Two;
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-line-width-panel': EdgelessLineWidthPanel;
}
}

View File

@@ -0,0 +1,318 @@
import {
type Color,
type ColorScheme,
DefaultTheme,
type LineWidth,
type Palette,
resolveColor,
type ShapeProps,
type StrokeStyle,
} from '@blocksuite/affine-model';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import {
type ColorEvent,
stopPropagation,
} from '@blocksuite/affine-shared/utils';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { batch, signal } from '@preact/signals-core';
import { css, html, LitElement } from 'lit';
import { property, query } from 'lit/decorators.js';
import { choose } from 'lit-html/directives/choose.js';
import { repeat } from 'lit-html/directives/repeat.js';
import { styleMap } from 'lit-html/directives/style-map.js';
import { when } from 'lit-html/directives/when.js';
import {
calcCustomButtonStyle,
keepColor,
packColorsWith,
type PickColorEvent,
preprocessColor,
rgbaToHex8,
} from '../color-picker';
import type { LineDetailType } from '../edgeless-line-styles-panel';
import type { EditorMenuButton } from '../toolbar';
type TabType = 'normal' | 'custom';
type ColorType = Extract<keyof ShapeProps, 'fillColor' | 'strokeColor'>;
type PickerType = {
label: string;
type: ColorType;
value: string;
hollowCircle: boolean;
onPick: (e: ColorEvent) => void;
};
export class EdgelessShapeColorPicker extends WithDisposable(
SignalWatcher(LitElement)
) {
static override styles = css`
.pickers {
display: flex;
align-self: stretch;
gap: 12px;
}
.picker {
display: flex;
align-self: stretch;
gap: 8px;
}
.picker-label {
color: ${unsafeCSSVarV2('text/secondary')};
font-weight: 400;
}
`;
tabType$ = signal<TabType>('normal');
colorType$ = signal<ColorType>('fillColor');
readonly #pickFillColor = (e: ColorEvent) => {
e.stopPropagation();
this.dispatchEvent(
new CustomEvent<PickColorEvent>('pickFillColor', {
detail: {
type: 'pick',
detail: e.detail,
},
bubbles: true,
composed: true,
cancelable: true,
})
);
};
readonly #pickStrokeColor = (e: ColorEvent) => {
e.stopPropagation();
this.dispatchEvent(
new CustomEvent<PickColorEvent>('pickStrokeColor', {
detail: {
type: 'pick',
detail: e.detail,
},
bubbles: true,
composed: true,
cancelable: true,
})
);
};
readonly #pickColor = (detail: PickColorEvent) => {
const type =
this.colorType$.peek() === 'fillColor'
? 'pickFillColor'
: 'pickStrokeColor';
this.dispatchEvent(
new CustomEvent<PickColorEvent>(type, {
detail,
bubbles: true,
composed: true,
cancelable: true,
})
);
};
readonly #pickStrokeStyle = (e: CustomEvent<LineDetailType>) => {
e.stopPropagation();
this.dispatchEvent(
new CustomEvent('pickStrokeStyle', {
detail: e.detail,
bubbles: true,
composed: true,
cancelable: true,
})
);
};
#calcCustomButtonStyle(color: string, isCustomColor: boolean) {
return calcCustomButtonStyle(color, isCustomColor, this);
}
#calcCustomButtonState(color: string, theme: ColorScheme) {
return !this.palettes
.map(({ value }) => resolveColor(value, theme))
.includes(color);
}
#switchToCustomWith(type: ColorType) {
batch(() => {
this.tabType$.value = 'custom';
this.colorType$.value = type;
});
}
get fillColorWithoutAlpha() {
const { fillColor } = this.payload;
return keepColor(
fillColor.startsWith('--')
? rgbaToHex8(
preprocessColor(window.getComputedStyle(this))({
type: 'normal',
value: fillColor,
}).rgba
)
: fillColor
);
}
override firstUpdated() {
this.disposables.addFromEvent(
this.menuButton,
'toggle',
(e: CustomEvent<boolean>) => {
const opened = e.detail;
if (!opened && this.tabType$.peek() === 'custom') {
this.tabType$.value = 'normal';
}
}
);
}
override render() {
const {
tabType$: { value: tabType },
colorType$: { value: colorType },
palettes,
fillColorWithoutAlpha,
payload: {
fillColor,
strokeColor,
strokeWidth,
strokeStyle,
originalFillColor,
originalStrokeColor,
theme,
enableCustomColor,
},
} = this;
return html`
<editor-menu-button
.contentPadding="${tabType === 'normal' ? '8px' : '0px'}"
@click=${stopPropagation}
.button=${html`
<editor-icon-button aria-label="Color" .tooltip="${'Color'}">
<edgeless-color-button
.color=${fillColorWithoutAlpha}
></edgeless-color-button>
</editor-icon-button>
`}
>
<div class="pickers" data-orientation="vertical">
${choose(tabType, [
[
'normal',
() => {
return html`
${repeat(
[
{
label: 'Fill color',
type: 'fillColor',
value: fillColor,
hollowCircle: false,
onPick: this.#pickFillColor,
},
{
label: 'Border color',
type: 'strokeColor',
value: strokeColor,
hollowCircle: true,
onPick: this.#pickStrokeColor,
},
] satisfies PickerType[],
item => item.type,
({ label, type, value, onPick, hollowCircle }) => html`
<div class="picker-label">${label}</div>
<edgeless-color-panel
aria-label="${label}"
role="listbox"
.hasTransparent=${false}
.hollowCircle=${hollowCircle}
.value=${value}
.theme=${theme}
.palettes=${palettes}
@select=${onPick}
>
${when(enableCustomColor, () => {
const isCustomColor = this.#calcCustomButtonState(
value,
theme
);
const styleInfo = this.#calcCustomButtonStyle(
value,
isCustomColor
);
return html`
<edgeless-color-custom-button
slot="custom"
style=${styleMap(styleInfo)}
?active=${isCustomColor}
@click=${() => this.#switchToCustomWith(type)}
></edgeless-color-custom-button>
`;
})}
</edgeless-color-panel>
`
)}
<div class="picker-label">Border style</div>
<edgeless-line-styles-panel
class="picker"
.lineSize=${strokeWidth}
.lineStyle=${strokeStyle}
@select=${this.#pickStrokeStyle}
></edgeless-line-styles-panel>
`;
},
],
[
'custom',
() => {
const isFillColor = colorType === 'fillColor';
const packed = packColorsWith(
theme,
isFillColor ? fillColor : strokeColor,
isFillColor ? originalFillColor : originalStrokeColor
);
const type = packed.type === 'palette' ? 'normal' : packed.type;
const modes = packed.colors.map(
preprocessColor(window.getComputedStyle(this))
);
return html`
<edgeless-color-picker
class="custom"
.pick=${this.#pickColor}
.colors=${{ type, modes }}
></edgeless-color-picker>
`;
},
],
])}
</div>
</editor-menu-button>
`;
}
@property({ attribute: false })
accessor payload!: {
fillColor: string;
strokeColor: string;
strokeWidth: LineWidth;
strokeStyle: StrokeStyle;
originalFillColor: Color;
originalStrokeColor: Color;
theme: ColorScheme;
enableCustomColor: boolean;
};
@property({ attribute: false })
accessor palettes: Palette[] = DefaultTheme.Palettes;
@query('editor-menu-button')
accessor menuButton!: EditorMenuButton;
}

View File

@@ -0,0 +1,10 @@
import { EdgelessShapeColorPicker } from './color-picker';
export * from './color-picker';
export function effects() {
customElements.define(
'edgeless-shape-color-picker',
EdgelessShapeColorPicker
);
}

View File

@@ -0,0 +1,218 @@
import {
EmbedOptionProvider,
VirtualKeyboardProvider,
} from '@blocksuite/affine-shared/services';
import { isValidUrl, stopPropagation } from '@blocksuite/affine-shared/utils';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import type { EditorHost } from '@blocksuite/std';
import { ShadowlessElement } from '@blocksuite/std';
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
import type { BlockModel } from '@blocksuite/store';
import { html } from 'lit';
import { property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { styleMap } from 'lit-html/directives/style-map.js';
import { toast } from '../toast';
import { embedCardModalStyles } from './styles.js';
export class EmbedCardCreateModal extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
static override styles = embedCardModalStyles;
private readonly _onCancel = () => {
this.remove();
};
private readonly _onConfirm = () => {
const url = this.input.value;
if (!isValidUrl(url)) {
toast(this.host, 'Invalid link');
return;
}
const embedOptions = this.host.std
.get(EmbedOptionProvider)
.getEmbedBlockOptions(url);
const { mode } = this.createOptions;
if (mode === 'page') {
const { parentModel, index } = this.createOptions;
let flavour = 'affine:bookmark';
if (embedOptions) {
flavour = embedOptions.flavour;
}
this.host.store.addBlock(
flavour as never,
{
url,
},
parentModel,
index
);
} else if (mode === 'edgeless') {
const gfx = this.host.std.get(GfxControllerIdentifier);
const surfaceModel = gfx.surface;
if (!surfaceModel) {
return;
}
this.createOptions.onSave(url);
}
this.onConfirm({ mode });
this.remove();
};
private readonly _onDocumentKeydown = (e: KeyboardEvent) => {
e.stopPropagation();
if (e.key === 'Enter' && !e.isComposing) {
this._onConfirm();
}
if (e.key === 'Escape') {
this.remove();
}
};
private _handleInput(e: InputEvent) {
const target = e.target as HTMLInputElement;
this._linkInputValue = target.value;
}
override connectedCallback() {
super.connectedCallback();
this.updateComplete
.then(() => {
requestAnimationFrame(() => {
this.input.focus();
});
})
.catch(console.error);
this.disposables.addFromEvent(this, 'keydown', this._onDocumentKeydown);
this.disposables.addFromEvent(this, 'cut', stopPropagation);
this.disposables.addFromEvent(this, 'copy', stopPropagation);
this.disposables.addFromEvent(this, 'paste', stopPropagation);
}
override render() {
const keyboard = this.host.std.getOptional(VirtualKeyboardProvider);
const style = styleMap({
height: keyboard?.visible$.value
? `calc(100% - ${keyboard.height$.value}px)`
: undefined,
});
return html`<div class="embed-card-modal" style=${style}>
<div class="embed-card-modal-mask" @click=${this._onCancel}></div>
<div class="embed-card-modal-wrapper">
<div class="embed-card-modal-row">
<div class="embed-card-modal-title">${this.titleText}</div>
</div>
<div class="embed-card-modal-row">
<div class="embed-card-modal-description">
${this.descriptionText}
</div>
</div>
<div class="embed-card-modal-row">
<input
class="embed-card-modal-input link"
id="card-description"
type="text"
placeholder="Input in https://..."
value=${this._linkInputValue}
@input=${this._handleInput}
/>
</div>
<div class="embed-card-modal-row">
<button
class=${classMap({
'embed-card-modal-button': true,
save: true,
})}
?disabled=${!isValidUrl(this._linkInputValue)}
@click=${this._onConfirm}
>
Confirm
</button>
</div>
</div>
</div>`;
}
@state()
private accessor _linkInputValue = '';
@property({ attribute: false })
accessor createOptions!:
| {
mode: 'page';
parentModel: BlockModel | string;
index?: number;
}
| {
mode: 'edgeless';
onSave: (url: string) => void;
};
@property({ attribute: false })
accessor descriptionText!: string;
@property({ attribute: false })
accessor host!: EditorHost;
@query('input')
accessor input!: HTMLInputElement;
@property({ attribute: false })
accessor onConfirm!: (options: { mode: 'edgeless' | 'page' }) => void;
@property({ attribute: false })
accessor titleText!: string;
}
export async function toggleEmbedCardCreateModal(
host: EditorHost,
titleText: string,
descriptionText: string,
createOptions:
| {
mode: 'page';
parentModel: BlockModel | string;
index?: number;
}
| {
mode: 'edgeless';
onSave: (url: string) => void;
},
onConfirm: (options: { mode: 'page' | 'edgeless' }) => void
): Promise<void> {
host.selection.clear();
const embedCardCreateModal = new EmbedCardCreateModal();
embedCardCreateModal.host = host;
embedCardCreateModal.titleText = titleText;
embedCardCreateModal.descriptionText = descriptionText;
embedCardCreateModal.createOptions = createOptions;
document.body.append(embedCardCreateModal);
return new Promise(resolve => {
embedCardCreateModal.onConfirm = options => {
onConfirm(options);
resolve();
};
});
}
declare global {
interface HTMLElementTagNameMap {
'embed-card-create-modal': EmbedCardCreateModal;
}
}

View File

@@ -0,0 +1,468 @@
import type { AliasInfo, LinkableEmbedModel } from '@blocksuite/affine-model';
import {
EmbedLinkedDocModel,
EmbedSyncedDocModel,
isInternalEmbedModel,
} from '@blocksuite/affine-model';
import {
type LinkEventType,
type TelemetryEvent,
TelemetryProvider,
} from '@blocksuite/affine-shared/services';
import { fontSMStyle, fontXSStyle } from '@blocksuite/affine-shared/styles';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import {
listenClickAway,
stopPropagation,
} from '@blocksuite/affine-shared/utils';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { nextTick } from '@blocksuite/global/utils';
import {
type BlockComponent,
type BlockStdScope,
type EditorHost,
EditorLifeCycleExtension,
} from '@blocksuite/std';
import { autoUpdate, computePosition, flip, offset } from '@floating-ui/dom';
import { computed, signal } from '@preact/signals-core';
import { css, html, LitElement } from 'lit';
import { property, query } from 'lit/decorators.js';
import { choose } from 'lit/directives/choose.js';
import { classMap } from 'lit/directives/class-map.js';
import { live } from 'lit/directives/live.js';
import { toast } from '../toast';
export class EmbedCardEditModal extends SignalWatcher(
WithDisposable(LitElement)
) {
static override styles = css`
:host {
position: absolute;
top: 0;
left: 0;
z-index: var(--affine-z-index-popover);
animation: affine-popover-fade-in 0.2s ease;
}
@keyframes affine-popover-fade-in {
from {
opacity: 0;
transform: translateY(-3px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.embed-card-modal-wrapper {
display: flex;
padding: 12px;
flex-direction: column;
justify-content: flex-end;
align-items: flex-start;
gap: 12px;
width: 421px;
color: var(--affine-icon-color);
box-shadow: var(--affine-overlay-shadow);
background: ${unsafeCSSVarV2('layer/background/overlayPanel')};
border-radius: 4px;
border: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
}
.row {
width: 100%;
display: flex;
align-items: center;
gap: 12px;
}
.row .input {
display: flex;
padding: 4px 10px;
width: 100%;
min-width: 100%;
box-sizing: border-box;
border-radius: 4px;
user-select: none;
background: transparent;
border: 1px solid ${unsafeCSSVarV2('input/border/default')};
color: var(--affine-text-primary-color);
}
${fontSMStyle('.row .input')}
.input::placeholder {
color: var(--affine-placeholder-color);
}
.input:focus {
border-color: ${unsafeCSSVarV2('input/border/active')};
outline: none;
}
textarea.input {
min-height: 80px;
resize: none;
}
.row.actions {
justify-content: flex-end;
}
.row.actions .button {
display: flex;
padding: 4px 12px;
align-items: center;
gap: 4px;
border-radius: 4px;
border: 1px solid ${unsafeCSSVarV2('button/innerBlackBorder')};
background: ${unsafeCSSVarV2('button/secondary')};
color: ${unsafeCSSVarV2('text/primary')};
}
${fontXSStyle('.row.actions .button')}
.row.actions .button[disabled],
.row.actions .button:disabled {
pointer-events: none;
color: ${unsafeCSSVarV2('text/disable')};
}
.row.actions .button.save {
color: ${unsafeCSSVarV2('button/pureWhiteText')};
background: ${unsafeCSSVarV2('button/primary')};
}
.row.actions .button[disabled].save,
.row.actions .button:disabled.save {
opacity: 0.5;
}
`;
private _blockComponent: BlockComponent | null = null;
private readonly _hide = () => {
this.remove();
this.abortController?.abort();
};
private readonly _onKeydown = (e: KeyboardEvent) => {
e.stopPropagation();
if (e.key === 'Enter' && !(e.isComposing || e.shiftKey)) {
this._onSave();
}
if (e.key === 'Escape') {
e.preventDefault();
this._hide();
}
};
private readonly _onReset = () => {
const blockComponent = this._blockComponent;
if (!blockComponent) {
this._hide();
return;
}
const std = blockComponent.std;
this.model.store.updateBlock(this.model, {
title: null,
description: null,
});
this.onReset?.(std, blockComponent);
blockComponent.requestUpdate();
track(std, this.model, this.viewType, 'ResetedAlias', { control: 'reset' });
this._hide();
};
private readonly _onSave = () => {
const blockComponent = this._blockComponent;
if (!blockComponent) {
this._hide();
return;
}
const title = this.title$.value.trim();
if (title.length === 0) {
toast(this.host, 'Title can not be empty');
return;
}
const std = blockComponent.std;
const description = this.description$.value.trim();
const props: AliasInfo = { title, description };
this.onSave?.(std, blockComponent, props);
track(std, this.model, this.viewType, 'SavedAlias', { control: 'save' });
this._hide();
};
private readonly _updateDescription = (e: InputEvent) => {
const target = e.target as HTMLTextAreaElement;
this.description$.value = target.value;
};
private readonly _updateTitle = (e: InputEvent) => {
const target = e.target as HTMLInputElement;
this.title$.value = target.value;
};
get isEmbedLinkedDocModel() {
return this.model instanceof EmbedLinkedDocModel;
}
get isEmbedSyncedDocModel() {
return this.model instanceof EmbedSyncedDocModel;
}
get isInternalEmbedModel() {
return isInternalEmbedModel(this.model);
}
get modelType(): 'linked' | 'synced' | null {
if (this.isEmbedLinkedDocModel) return 'linked';
if (this.isEmbedSyncedDocModel) return 'synced';
return null;
}
get placeholders() {
if (this.isInternalEmbedModel) {
return {
title: 'Add title alias',
description:
'Add description alias (empty to inherit document content)',
};
}
return {
title: 'Write a title',
description: 'Write a description...',
};
}
private _updateInfo() {
const title = this.model.props.title || this.originalDocInfo?.title || '';
const description =
this.model.props.description || this.originalDocInfo?.description || '';
this.title$.value = title;
this.description$.value = description;
}
override connectedCallback() {
super.connectedCallback();
this.disposables.add(
this.host.std
.get(EditorLifeCycleExtension)
.slots.unmounted.subscribe(this._hide)
);
this._updateInfo();
}
override firstUpdated() {
const blockComponent = this.host.std.view.getBlock(this.model.id);
if (!blockComponent) return;
this._blockComponent = blockComponent;
this.disposables.add(
autoUpdate(blockComponent, this, () => {
computePosition(blockComponent, this, {
placement: 'top-start',
middleware: [flip(), offset(8)],
})
.then(({ x, y }) => {
this.style.left = `${x}px`;
this.style.top = `${y}px`;
})
.catch(console.error);
})
);
// Resolves the click event is triggered after the first rendering.
nextTick()
.then(() => this.disposables.add(listenClickAway(this, this._hide)))
.catch(console.error);
this.disposables.addFromEvent(this, 'keydown', this._onKeydown);
this.disposables.addFromEvent(this, 'pointerdown', stopPropagation);
this.disposables.addFromEvent(this, 'cut', stopPropagation);
this.disposables.addFromEvent(this, 'copy', stopPropagation);
this.disposables.addFromEvent(this, 'paste', stopPropagation);
this.titleInput.focus();
this.titleInput.select();
}
override render() {
return html`
<div class="embed-card-modal-wrapper">
<div class="row">
<input
class="input title"
type="text"
placeholder=${this.placeholders.title}
.value=${live(this.title$.value)}
@input=${this._updateTitle}
/>
</div>
<div class="row">
<textarea
class="input description"
maxlength="500"
placeholder=${this.placeholders.description}
.value=${live(this.description$.value)}
@input=${this._updateDescription}
></textarea>
</div>
<div class="row actions">
${choose(this.modelType, [
[
'linked',
() => html`
<button
class=${classMap({
button: true,
reset: true,
})}
.disabled=${this.resetButtonDisabled$.value}
@click=${this._onReset}
>
Reset
</button>
`,
],
[
'synced',
() => html`
<button
class=${classMap({
button: true,
cancel: true,
})}
@click=${this._hide}
>
Cancel
</button>
`,
],
])}
<button
class=${classMap({
button: true,
save: true,
})}
.disabled=${this.saveButtonDisabled$.value}
@click=${this._onSave}
>
Save
</button>
</div>
</div>
`;
}
accessor description$ = signal<string>('');
@property({ attribute: false })
accessor host!: EditorHost;
@property({ attribute: false })
accessor model!: LinkableEmbedModel;
@property({ attribute: false })
accessor originalDocInfo: AliasInfo | undefined = undefined;
@property({ attribute: false })
accessor onReset:
| ((std: BlockStdScope, component: BlockComponent) => void)
| undefined = undefined;
@property({ attribute: false })
accessor onSave:
| ((
std: BlockStdScope,
component: BlockComponent,
props: AliasInfo
) => void)
| undefined = undefined;
accessor resetButtonDisabled$ = computed<boolean>(
() =>
!(
Boolean(this.model.props.title?.length) ||
Boolean(this.model.props.description?.length)
)
);
accessor saveButtonDisabled$ = computed<boolean>(
() => this.title$.value.trim().length === 0
);
accessor title$ = signal<string>('');
@query('.input.title')
accessor titleInput!: HTMLInputElement;
@property({ attribute: false })
accessor viewType!: string;
@property({ attribute: false })
accessor abortController: AbortController | undefined = undefined;
}
export function toggleEmbedCardEditModal(
host: EditorHost,
embedCardModel: LinkableEmbedModel,
viewType: string,
originalDocInfo?: AliasInfo,
onReset?: (std: BlockStdScope, component: BlockComponent) => void,
onSave?: (
std: BlockStdScope,
component: BlockComponent,
props: AliasInfo
) => void,
abortController?: AbortController
) {
document.body.querySelector('embed-card-edit-modal')?.remove();
const embedCardEditModal = new EmbedCardEditModal();
embedCardEditModal.model = embedCardModel;
embedCardEditModal.host = host;
embedCardEditModal.viewType = viewType;
embedCardEditModal.originalDocInfo = originalDocInfo;
embedCardEditModal.onReset = onReset;
embedCardEditModal.onSave = onSave;
embedCardEditModal.abortController = abortController;
document.body.append(embedCardEditModal);
}
declare global {
interface HTMLElementTagNameMap {
'embed-card-edit-modal': EmbedCardEditModal;
}
}
function track(
std: BlockStdScope,
model: LinkableEmbedModel,
viewType: string,
event: LinkEventType,
props: Partial<TelemetryEvent>
) {
std.getOptional(TelemetryProvider)?.track(event, {
segment: 'toolbar',
page: 'doc editor',
module: 'embed card edit popup',
type: `${viewType} view`,
category: isInternalEmbedModel(model) ? 'linked doc' : 'link',
...props,
});
}

View File

@@ -0,0 +1,10 @@
import { EmbedCardCreateModal } from './embed-card-create-modal';
import { EmbedCardEditModal } from './embed-card-edit-modal';
export * from './embed-card-create-modal';
export * from './embed-card-edit-modal';
export function effects() {
customElements.define('embed-card-create-modal', EmbedCardCreateModal);
customElements.define('embed-card-edit-modal', EmbedCardEditModal);
}

View File

@@ -0,0 +1,131 @@
import { fontXSStyle, panelBaseStyle } from '@blocksuite/affine-shared/styles';
import { css } from 'lit';
export const embedCardModalStyles = css`
.embed-card-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
height: 100%;
transition: height 0.3s ease-in-out;
}
.embed-card-modal-mask {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
margin: auto;
z-index: 1;
}
${panelBaseStyle('.embed-card-modal-wrapper')}
.embed-card-modal-wrapper {
flex-direction: column;
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
margin: auto;
z-index: 2;
width: 305px;
height: max-content;
padding: 12px;
gap: 12px;
border-radius: 8px;
font-size: var(--affine-font-xs);
line-height: 20px;
}
.embed-card-modal-row {
display: flex;
flex-direction: column;
align-self: stretch;
}
.embed-card-modal-row label {
padding: 0px 2px;
color: var(--affine-text-secondary-color);
font-weight: 600;
}
.embed-card-modal-input {
display: flex;
padding-left: 10px;
padding-right: 10px;
border-radius: 8px;
border: 1px solid var(--affine-border-color);
background: var(--affine-white-10);
color: var(--affine-text-primary-color);
}
${fontXSStyle('.embed-card-modal-input')}
input.embed-card-modal-input {
padding-top: 4px;
padding-bottom: 4px;
}
textarea.embed-card-modal-input {
padding-top: 6px;
padding-bottom: 6px;
min-width: 100%;
max-width: 100%;
}
.embed-card-modal-input:focus {
border-color: var(--affine-blue-700);
box-shadow: var(--affine-active-shadow);
outline: none;
}
.embed-card-modal-input::placeholder {
color: var(--affine-placeholder-color);
}
.embed-card-modal-row:has(.embed-card-modal-button) {
flex-direction: row;
gap: 4px;
justify-content: flex-end;
}
.embed-card-modal-row:has(.embed-card-modal-button.reset) {
justify-content: space-between;
}
.embed-card-modal-button {
padding: 4px 18px;
border-radius: 8px;
box-sizing: border-box;
}
.embed-card-modal-button.save {
border: 1px solid var(--affine-black-10);
background: var(--affine-primary-color);
color: var(--affine-pure-white);
}
.embed-card-modal-button[disabled] {
pointer-events: none;
cursor: not-allowed;
color: var(--affine-text-disable-color);
background: transparent;
}
.embed-card-modal-button.reset {
padding: 4px 0;
border: none;
background: transparent;
text-decoration: underline;
color: var(--affine-secondary-color);
user-select: none;
}
.embed-card-modal-title {
font-size: 18px;
font-weight: 600;
line-height: 26px;
user-select: none;
}
.embed-card-modal-description {
font-size: 15px;
font-weight: 500;
line-height: 24px;
user-select: none;
}
`;

View File

@@ -0,0 +1,259 @@
import { PAGE_HEADER_HEIGHT } from '@blocksuite/affine-shared/consts';
import { WithDisposable } from '@blocksuite/global/lit';
import { DoneIcon, SearchIcon } from '@blocksuite/icons/lit';
import { autoPlacement, offset, type Placement, size } from '@floating-ui/dom';
import { html, LitElement, nothing } from 'lit';
import { property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { type AdvancedPortalOptions, createLitPortal } from '../portal';
import { filterableListStyles } from './styles.js';
import type { FilterableListItem, FilterableListOptions } from './types.js';
export * from './types.js';
export class FilterableListComponent<Props = unknown> extends WithDisposable(
LitElement
) {
static override styles = filterableListStyles;
private _buildContent(items: FilterableListItem<Props>[]) {
return items.map((item, idx) => {
const focussed = this._curFocusIndex === idx;
return html`
<icon-button
class=${classMap({
'filterable-item': true,
focussed,
})}
@mouseover=${() => (this._curFocusIndex = idx)}
@click=${() => this._select(item)}
hover=${focussed}
width="100%"
height="32px"
>
${item.icon ?? nothing} ${item.label ?? item.name}
<div slot="suffix">
${this.options.active?.(item) ? DoneIcon() : nothing}
</div>
</icon-button>
`;
});
}
private _filterItems() {
const searchFilter = !this._filterText
? this.options.items
: this.options.items.filter(
item =>
item.name.startsWith(this._filterText.toLowerCase()) ||
item.aliases?.some(alias =>
alias.startsWith(this._filterText.toLowerCase())
)
);
return searchFilter.sort((a, b) => {
const isActiveA = this.options.active?.(a);
const isActiveB = this.options.active?.(b);
if (isActiveA && !isActiveB) return -1;
if (!isActiveA && isActiveB) return 1;
return this.listFilter?.(a, b) ?? 0;
});
}
private _scrollFocusedItemIntoView() {
this.updateComplete
.then(() => {
this._focussedItem?.scrollIntoView({
block: 'nearest',
inline: 'start',
});
})
.catch(console.error);
}
private _select(item: FilterableListItem) {
this.abortController?.abort();
this.options.onSelect(item);
}
override connectedCallback() {
super.connectedCallback();
requestAnimationFrame(() => {
this._filterInput.focus();
});
}
override render() {
const filteredItems = this._filterItems();
const content = this._buildContent(filteredItems);
const isFlip = !!this.placement?.startsWith('top');
const _handleInputKeydown = (ev: KeyboardEvent) => {
ev.stopPropagation();
switch (ev.key) {
case 'ArrowUp': {
ev.preventDefault();
this._curFocusIndex =
(this._curFocusIndex + content.length - 1) % content.length;
this._scrollFocusedItemIntoView();
break;
}
case 'ArrowDown': {
ev.preventDefault();
this._curFocusIndex = (this._curFocusIndex + 1) % content.length;
this._scrollFocusedItemIntoView();
break;
}
case 'Enter': {
if (ev.isComposing) break;
ev.preventDefault();
const item = filteredItems[this._curFocusIndex];
if (!item) return;
this._select(item);
break;
}
case 'Escape': {
ev.preventDefault();
this.abortController?.abort();
break;
}
}
};
return html`
<div
class=${classMap({ 'affine-filterable-list': true, flipped: isFlip })}
>
<div class="input-wrapper">
${SearchIcon()}
<input
id="filter-input"
type="text"
placeholder=${this.options?.placeholder ?? 'Search'}
@input="${() => {
this._filterText = this._filterInput?.value;
this._curFocusIndex = 0;
}}"
@keydown="${_handleInputKeydown}"
/>
</div>
<editor-toolbar-separator
data-orientation="horizontal"
></editor-toolbar-separator>
<div class="items-container">${content}</div>
</div>
`;
}
@state()
private accessor _curFocusIndex = 0;
@query('#filter-input')
private accessor _filterInput!: HTMLInputElement;
@state()
private accessor _filterText = '';
@query('.filterable-item.focussed')
private accessor _focussedItem!: HTMLElement | null;
@property({ attribute: false })
accessor abortController: AbortController | null = null;
@property({ attribute: false })
accessor listFilter:
| ((a: FilterableListItem<Props>, b: FilterableListItem<Props>) => number)
| undefined = undefined;
@property({ attribute: false })
accessor options!: FilterableListOptions<Props>;
@property({ attribute: false })
accessor placement: Placement | undefined = undefined;
}
export function showPopFilterableList({
options,
filter,
abortController = new AbortController(),
referenceElement,
container,
maxHeight = 440,
portalStyles,
}: {
options: FilterableListComponent['options'];
referenceElement: Element;
container?: Element;
abortController?: AbortController;
filter?: FilterableListComponent['listFilter'];
maxHeight?: number;
portalStyles?: AdvancedPortalOptions['portalStyles'];
}) {
const portalPadding = {
top: PAGE_HEADER_HEIGHT + 12,
bottom: 12,
} as const;
const list = new FilterableListComponent();
list.options = options;
list.listFilter = filter;
list.abortController = abortController;
createLitPortal({
closeOnClickAway: true,
template: ({ positionSlot }) => {
positionSlot.subscribe(({ placement }) => {
list.placement = placement;
});
return list;
},
container,
portalStyles,
computePosition: {
referenceElement,
placement: 'bottom-start',
middleware: [
offset(4),
autoPlacement({
allowedPlacements: ['top-start', 'bottom-start'],
padding: portalPadding,
}),
size({
padding: portalPadding,
apply({ availableHeight, elements, placement }) {
Object.assign(elements.floating.style, {
height: '100%',
maxHeight: `${Math.min(maxHeight, availableHeight)}px`,
pointerEvents: 'none',
...(placement.startsWith('top')
? {
display: 'flex',
alignItems: 'flex-end',
}
: {
display: null,
alignItems: null,
}),
});
},
}),
],
autoUpdate: {
// fix the lang list position incorrectly when scrolling
animationFrame: true,
},
},
abortController,
});
}
declare global {
interface HTMLElementTagNameMap {
'affine-filterable-list': FilterableListComponent;
}
}

View File

@@ -0,0 +1,109 @@
import {
panelBaseStyle,
scrollbarStyle,
} from '@blocksuite/affine-shared/styles';
import { css } from 'lit';
export const filterableListStyles = css`
${panelBaseStyle(':host')}
:host {
flex-direction: column;
padding: 0;
max-height: 100%;
pointer-events: auto;
overflow: hidden;
z-index: var(--affine-z-index-popover);
}
.affine-filterable-list {
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: center;
width: 230px;
padding: 8px;
box-sizing: border-box;
overflow: hidden;
}
.affine-filterable-list.flipped {
flex-direction: column-reverse;
}
.items-container {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
overflow-y: scroll;
padding-top: 5px;
padding-left: 4px;
padding-right: 4px;
}
editor-toolbar-separator {
margin: 8px 0;
}
.input-wrapper {
display: flex;
align-items: center;
border-radius: 4px;
padding: 4px 10px;
gap: 4px;
border-width: 1px;
border-style: solid;
border-color: transparent;
}
.input-wrapper:focus-within {
border-color: var(--affine-blue-700);
box-shadow: var(--affine-active-shadow);
}
${scrollbarStyle('.items-container')}
.filterable-item {
display: flex;
justify-content: space-between;
gap: 4px;
padding: 12px;
}
.filterable-item > div[slot='suffix'] {
display: flex;
align-items: center;
}
.filterable-item svg {
width: 20px;
height: 20px;
}
.filterable-item.focussed {
color: var(--affine-blue-700);
background: var(--affine-hover-color-filled);
}
#filter-input {
flex: 1;
align-items: center;
height: 20px;
width: 140px;
border-radius: 8px;
padding-top: 2px;
border: transparent;
background: transparent;
color: inherit;
}
#filter-input:focus {
outline: none;
}
#filter-input::placeholder {
color: var(--affine-placeholder-color);
font-size: var(--affine-font-sm);
}
`;

View File

@@ -0,0 +1,16 @@
import type { TemplateResult } from 'lit';
export interface FilterableListItem<Props = unknown> {
name: string;
label?: string;
icon?: TemplateResult;
aliases?: string[];
props?: Props;
}
export interface FilterableListOptions<Props = unknown> {
placeholder?: string;
items: FilterableListItem<Props>[];
active?: (item: FilterableListItem) => boolean;
onSelect: (item: FilterableListItem) => void;
}

View File

@@ -0,0 +1,123 @@
import type { AffineTextStyleAttributes } from '@blocksuite/affine-shared/types';
import { PropTypes, requiredProperties } from '@blocksuite/std';
import { LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import { html } from 'lit-html';
import { repeat } from 'lit-html/directives/repeat.js';
import { EditorChevronDown } from '../toolbar';
const colors = [
'default',
'red',
'orange',
'yellow',
'green',
'teal',
'blue',
'purple',
'grey',
] as const;
export type HighlightType = Pick<
AffineTextStyleAttributes,
'color' | 'background'
>;
// TODO(@fundon): these recent settings should be added to the dropdown menu
// tests/blocksutie/e2e/format-bar.spec.ts#253
//
// let latestHighlightColor: string | null = null;
// let latestHighlightType: HighlightType = 'background';
@requiredProperties({
updateHighlight: PropTypes.instanceOf(Function),
})
export class HighlightDropdownMenu extends LitElement {
@property({ attribute: false })
accessor updateHighlight!: (styles: HighlightType) => void;
private readonly _update = (style: HighlightType) => {
// latestHighlightColor = value;
// latestHighlightType = type;
this.updateHighlight(style);
};
override render() {
const prefix = '--affine-text-highlight';
return html`
<editor-menu-button
.contentPadding="${'8px'}"
.button=${html`
<editor-icon-button aria-label="highlight" .tooltip="${'Highlight'}">
<affine-highlight-duotone-icon
style=${styleMap({
'--color':
// latestHighlightColor ?? 'var(--affine-text-primary-color)',
'var(--affine-text-primary-color)',
})}
></affine-highlight-duotone-icon>
${EditorChevronDown}
</editor-icon-button>
`}
>
<div data-size="large" data-orientation="vertical">
<div class="highlight-heading">Color</div>
${repeat(colors, color => {
const isDefault = color === 'default';
const value = isDefault
? null
: `var(${prefix}-foreground-${color})`;
return html`
<editor-menu-action
data-testid="foreground-${color}"
@click=${() => this._update({ color: value })}
>
<affine-text-duotone-icon
style=${styleMap({
'--color': value ?? 'var(--affine-text-primary-color)',
})}
></affine-text-duotone-icon>
<span class="label capitalize"
>${isDefault ? `${color} color` : color}</span
>
</editor-menu-action>
`;
})}
<div class="highlight-heading">Background</div>
${repeat(colors, color => {
const isDefault = color === 'default';
const value = isDefault ? null : `var(${prefix}-${color})`;
return html`
<editor-menu-action
data-testid="background-${color}"
@click=${() => this._update({ background: value })}
>
<affine-text-duotone-icon
style=${styleMap({
'--color': 'var(--affine-text-primary-color)',
'--background': value ?? 'transparent',
})}
></affine-text-duotone-icon>
<span class="label capitalize"
>${isDefault ? `${color} background` : color}</span
>
</editor-menu-action>
`;
})}
</div>
</editor-menu-button>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'affine-highlight-dropdown-menu': HighlightDropdownMenu;
}
}

View File

@@ -0,0 +1,23 @@
import { HighLightDuotoneIcon } from '@blocksuite/icons/lit';
import { css, LitElement } from 'lit';
export class HighlightDuotoneIcon extends LitElement {
static override styles = css`
svg {
display: flex;
font-size: 20px;
}
svg > path:nth-child(1) {
fill: var(--color, unset);
}
`;
override render() {
return HighLightDuotoneIcon();
}
}
declare global {
interface HTMLElementTagNameMap {
'affine-highlight-duotone-icon': HighlightDuotoneIcon;
}
}

View File

@@ -0,0 +1,16 @@
import { HighlightDropdownMenu } from './dropdown-menu';
import { HighlightDuotoneIcon } from './highlight-duotone-icon';
import { TextDuotoneIcon } from './text-duotone-icon';
export * from './dropdown-menu';
export * from './highlight-duotone-icon';
export * from './text-duotone-icon';
export function effects() {
customElements.define(
'affine-highlight-dropdown-menu',
HighlightDropdownMenu
);
customElements.define('affine-highlight-duotone-icon', HighlightDuotoneIcon);
customElements.define('affine-text-duotone-icon', TextDuotoneIcon);
}

View File

@@ -0,0 +1,26 @@
import { TextBackgroundDuotoneIcon } from '@blocksuite/icons/lit';
import { css, LitElement } from 'lit';
export class TextDuotoneIcon extends LitElement {
static override styles = css`
svg {
display: flex;
font-size: 20px;
}
svg > path:nth-child(1) {
fill: var(--background, unset);
}
svg > path:nth-child(3) {
fill: var(--color, unset);
}
`;
override render() {
return TextBackgroundDuotoneIcon();
}
}
declare global {
interface HTMLElementTagNameMap {
'affine-text-duotone-icon': TextDuotoneIcon;
}
}

View File

@@ -0,0 +1,201 @@
import { DisposableGroup } from '@blocksuite/global/disposable';
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,
}).portal;
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();
}
}

View File

@@ -0,0 +1,3 @@
export { HoverController } from './controller.js';
export type * from './types.js';
export { whenHover } from './when-hover.js';

View File

@@ -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 === 'mouseenter') {
// 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 !== 'mouseenter') 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;
};
};

View File

@@ -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;
};
};

View File

@@ -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;

View File

@@ -0,0 +1,144 @@
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 === 'mouseenter' ? 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('mouseenter'));
}
element.addEventListener('mouseenter', 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('mouseenter', onHoverChange, {
capture: true,
});
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 };

View File

@@ -0,0 +1,242 @@
import { baseTheme } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import {
css,
html,
LitElement,
nothing,
type TemplateResult,
unsafeCSS,
} from 'lit';
import { property, query } from 'lit/decorators.js';
/**
* Default size is 32px, you can override it by setting `size` property.
* For example, `<icon-button size="32px"></icon-button>`.
*
* You can also set `width` or `height` property to override the size.
*
* Set `text` property to show a text label.
*
* @example
* ```ts
* html`<icon-button @click=${this.onUnlink}>
* ${UnlinkIcon}
* </icon-button>`
*
* html`<icon-button size="32px" text="HTML" @click=${this._importHtml}>
* ${ExportToHTMLIcon}
* </icon-button>`
* ```
*/
export class IconButton extends LitElement {
static override styles = css`
:host {
box-sizing: border-box;
display: flex;
justify-content: center;
align-items: center;
border: none;
width: var(--button-width);
height: var(--button-height);
border-radius: 4px;
background: transparent;
cursor: pointer;
user-select: none;
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
color: var(--affine-text-primary-color);
pointer-events: auto;
padding: 4px;
}
// This media query can detect if the device has a hover capability
@media (hover: hover) {
:host(:hover) {
background: var(--affine-hover-color);
}
}
:host(:active) {
background: transparent;
}
:host([disabled]),
:host(:disabled) {
background: transparent;
color: var(--affine-text-disable-color);
cursor: not-allowed;
}
/* You can add a 'hover' attribute to the button to show the hover style */
:host([hover='true']) {
background: var(--affine-hover-color);
}
:host([hover='false']) {
background: transparent;
}
:host(:active[active]) {
background: transparent;
}
/* not supported "until-found" yet */
:host([hidden]) {
display: none;
}
:host > .text-container {
display: flex;
flex-direction: column;
overflow: hidden;
}
:host .text {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: var(--affine-font-sm);
line-height: var(--affine-line-height);
}
:host .sub-text {
font-size: var(--affine-font-xs);
color: var(
--light-textColor-textSecondaryColor,
var(--textColor-textSecondaryColor, #8e8d91)
);
line-height: var(--affine-line-height);
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
margin-top: -2px;
}
::slotted(svg) {
flex-shrink: 0;
color: var(--svg-icon-color);
}
::slotted([slot='suffix']) {
margin-left: auto;
}
`;
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 === true) {
event.preventDefault();
event.stopPropagation();
}
},
{ capture: true }
);
}
override connectedCallback() {
super.connectedCallback();
this.tabIndex = 0;
this.role = 'button';
const DEFAULT_SIZE = '28px';
if (this.size && (this.width || this.height)) {
return;
}
let width = this.width ?? DEFAULT_SIZE;
let height = this.height ?? DEFAULT_SIZE;
if (this.size) {
width = this.size;
height = this.size;
}
this.style.setProperty(
'--button-width',
typeof width === 'string' ? width : `${width}px`
);
this.style.setProperty(
'--button-height',
typeof height === 'string' ? height : `${height}px`
);
}
override render() {
if (this.hidden) return nothing;
if (this.disabled) {
const disabledColor = cssVarV2('icon/disable');
this.style.setProperty('--svg-icon-color', disabledColor);
this.dataset.testDisabled = 'true';
} else {
this.dataset.testDisabled = 'false';
const iconColor = this.active
? cssVarV2('icon/activated')
: cssVarV2('icon/primary');
this.style.setProperty('--svg-icon-color', iconColor);
}
const text = this.text
? // wrap a span around the text so we can ellipsis it automatically
html`<div class="text">${this.text}</div>`
: nothing;
const subText = this.subText
? html`<div class="sub-text">${this.subText}</div>`
: nothing;
const textContainer =
this.text || this.subText
? html`<div class="text-container">${text}${subText}</div>`
: nothing;
return html`<slot></slot>
${textContainer}
<slot name="suffix"></slot>`;
}
@property({ attribute: true, type: Boolean })
accessor active: boolean = false;
// Do not add `{ attribute: false }` option here, otherwise the `disabled` styles will not work
@property({ attribute: true, type: Boolean })
accessor disabled: boolean | undefined = undefined;
@property()
accessor height: string | number | null = null;
@property({ attribute: true, type: String })
accessor hover: 'true' | 'false' | undefined = undefined;
@property()
accessor size: string | number | null = null;
@property()
accessor subText: string | TemplateResult<1> | null = null;
@property()
accessor text: string | TemplateResult<1> | null = null;
@query('.text-container .text')
accessor textElement: HTMLDivElement | null = null;
@property()
accessor width: string | number | null = null;
}
declare global {
interface HTMLElementTagNameMap {
'icon-button': IconButton;
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,173 @@
import {
FileIconAepIcon,
FileIconAiIcon,
FileIconAviIcon,
FileIconCssIcon,
FileIconCsvIcon,
FileIconDmgIcon,
FileIconDocIcon,
FileIconDocxIcon,
FileIconEpsIcon,
FileIconExeIcon,
FileIconFigIcon,
FileIconGifIcon,
FileIconHtmlIcon,
FileIconInddIcon,
FileIconJavaIcon,
FileIconJpegIcon,
FileIconJsIcon,
FileIconJsonIcon,
FileIconMkvIcon,
FileIconMp3Icon,
FileIconMp4Icon,
FileIconMpegIcon,
FileIconNoneIcon,
FileIconPdfIcon,
FileIconPngIcon,
FileIconPptIcon,
FileIconPptxIcon,
FileIconPsdIcon,
FileIconRarIcon,
FileIconRssIcon,
FileIconSqlIcon,
FileIconSvgIcon,
FileIconTiffIcon,
FileIconTxtIcon,
FileIconWavIcon,
FileIconWebpIcon,
FileIconXlsIcon,
FileIconXlsxIcon,
FileIconXmlIcon,
FileIconZipIcon,
ImageIcon,
} from '@blocksuite/icons/rc';
export function getAttachmentFileIconRC(filetype: string) {
switch (filetype) {
case 'img':
return ImageIcon;
case 'image/jpeg':
case 'jpg':
case 'jpeg':
return FileIconJpegIcon;
case 'image/png':
case 'png':
return FileIconPngIcon;
case 'image/webp':
case 'webp':
return FileIconWebpIcon;
case 'image/tiff':
case 'tiff':
return FileIconTiffIcon;
case 'image/gif':
case 'gif':
return FileIconGifIcon;
case 'image/svg':
case 'svg':
return FileIconSvgIcon;
case 'image/eps':
case 'eps':
return FileIconEpsIcon;
case 'application/pdf':
case 'pdf':
return FileIconPdfIcon;
case 'application/msword':
case 'application/x-iwork-pages-sffpages':
case 'doc':
return FileIconDocIcon;
case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
case 'docx':
return FileIconDocxIcon;
case 'text/plain':
case 'txt':
return FileIconTxtIcon;
case 'csv':
return FileIconCsvIcon;
case 'application/vnd.ms-excel':
case 'xls':
return FileIconXlsIcon;
case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':
case 'application/x-iwork-numbers-sffnumbers':
case 'xlsx':
return FileIconXlsxIcon;
case 'application/vnd.ms-powerpoint':
case 'application/x-iwork-keynote-sffkeynote':
case 'ppt':
return FileIconPptIcon;
case 'application/vnd.openxmlformats-officedocument.presentationml.presentation':
case 'pptx':
return FileIconPptxIcon;
case 'application/illustrator':
case 'fig':
return FileIconFigIcon;
case 'application/postscript':
case 'ai':
return FileIconAiIcon;
case 'application/vnd.adobe.photoshop':
case 'psd':
return FileIconPsdIcon;
case 'application/vnd.adobe.indesign':
case 'indd':
return FileIconInddIcon;
case 'application/vnd.adobe.afterfx':
case 'aep':
return FileIconAepIcon;
case 'audio/mpeg':
case 'audio/mp3':
case 'mp3':
return FileIconMp3Icon;
case 'audio/wav':
case 'wav':
return FileIconWavIcon;
case 'video/mpeg':
case 'mpeg':
return FileIconMpegIcon;
case 'video/mp4':
case 'mp4':
return FileIconMp4Icon;
case 'video/avi':
case 'avi':
return FileIconAviIcon;
case 'video/mkv':
case 'mkv':
return FileIconMkvIcon;
case 'text/html':
case 'html':
return FileIconHtmlIcon;
case 'text/css':
case 'css':
return FileIconCssIcon;
case 'application/rss+xml':
case 'rss':
return FileIconRssIcon;
case 'application/sql':
case 'sql':
return FileIconSqlIcon;
case 'application/javascript':
case 'js':
return FileIconJsIcon;
case 'application/json':
case 'json':
return FileIconJsonIcon;
case 'application/java':
case 'java':
return FileIconJavaIcon;
case 'application/xml':
case 'xml':
return FileIconXmlIcon;
case 'application/x-msdos-program':
case 'exe':
return FileIconExeIcon;
case 'application/x-apple-diskimage':
case 'dmg':
return FileIconDmgIcon;
case 'application/zip':
case 'zip':
return FileIconZipIcon;
case 'application/x-rar-compressed':
case 'rar':
return FileIconRarIcon;
default:
return FileIconNoneIcon;
}
}

View File

@@ -0,0 +1,105 @@
import {
FileIconAepIcon,
FileIconAiIcon,
FileIconAviIcon,
FileIconCssIcon,
FileIconCsvIcon,
FileIconDmgIcon,
FileIconDocIcon,
FileIconDocxIcon,
FileIconEpsIcon,
FileIconExeIcon,
FileIconFigIcon,
FileIconGifIcon,
FileIconHtmlIcon,
FileIconImgIcon,
FileIconInddIcon,
FileIconJavaIcon,
FileIconJpegIcon,
FileIconJpgIcon,
FileIconJsIcon,
FileIconJsonIcon,
FileIconMdIcon,
FileIconMkvIcon,
FileIconMp3Icon,
FileIconMp4Icon,
FileIconMpegIcon,
FileIconNoneIcon,
FileIconPdfIcon,
FileIconPngIcon,
FileIconPptIcon,
FileIconPptxIcon,
FileIconPsdIcon,
FileIconRarIcon,
FileIconRssIcon,
FileIconSqlIcon,
FileIconSvgIcon,
FileIconTiffIcon,
FileIconTxtIcon,
FileIconWavIcon,
FileIconWebpIcon,
FileIconXlsIcon,
FileIconXlsxIcon,
FileIconXmlIcon,
FileIconZipIcon,
} from '@blocksuite/icons/lit';
// TODO(@fundon): should be an extension, users can customize.
const iconMap = new Map([
['img', FileIconImgIcon],
['jpg', FileIconJpgIcon],
['jpeg', FileIconJpegIcon],
['png', FileIconPngIcon],
['webp', FileIconWebpIcon],
['tiff', FileIconTiffIcon],
['gif', FileIconGifIcon],
['svg', FileIconSvgIcon],
['eps', FileIconEpsIcon],
['pdf', FileIconPdfIcon],
['doc', FileIconDocIcon],
['docx', FileIconDocxIcon],
['txt', FileIconTxtIcon],
['csv', FileIconCsvIcon],
['xls', FileIconXlsIcon],
['xlsx', FileIconXlsxIcon],
['ppt', FileIconPptIcon],
['pptx', FileIconPptxIcon],
['fig', FileIconFigIcon],
['ai', FileIconAiIcon],
['psd', FileIconPsdIcon],
['indd', FileIconInddIcon],
['aep', FileIconAepIcon],
['mp3', FileIconMp3Icon],
['wav', FileIconWavIcon],
['mp4', FileIconMp4Icon],
['mpeg', FileIconMpegIcon],
['avi', FileIconAviIcon],
['mkv', FileIconMkvIcon],
['html', FileIconHtmlIcon],
['css', FileIconCssIcon],
['rss', FileIconRssIcon],
['md', FileIconMdIcon],
['sql', FileIconSqlIcon],
['js', FileIconJsIcon],
['json', FileIconJsonIcon],
['java', FileIconJavaIcon],
['xml', FileIconXmlIcon],
['exe', FileIconExeIcon],
['dmg', FileIconDmgIcon],
['zip', FileIconZipIcon],
['rar', FileIconRarIcon],
['none', FileIconNoneIcon],
]);
// It's `40 x 40` by default.
export function getAttachmentFileIcon(
filetype: string,
options: Parameters<typeof FileIconNoneIcon>[0] = {
width: '2.5em',
height: '2.5em',
style: 'font-size: 16px',
}
) {
const Icon = iconMap.get(filetype) ?? FileIconNoneIcon;
return Icon(options);
}

View File

@@ -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>
`;

View File

@@ -0,0 +1,10 @@
export * from './ai.js';
export * from './file-icons.js';
export * from './file-icons-rc';
export * from './import-export.js';
export * from './list.js';
export * from './loading.js';
export * from './misc.js';
export * from './tags.js';
export * from './text.js';
export * from './utils.js';

View File

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

View File

@@ -0,0 +1,47 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { html } from 'lit';
export const LoadingIcon = ({
size = '1em',
progress = 0.2,
ringColor = cssVarV2('loading/background'),
strokeColor = cssVarV2('loading/foreground'),
}: {
size?: string;
progress?: number;
ringColor?: string;
strokeColor?: string;
} = {}) =>
html`<svg
width="${size}"
height="${size}"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
fill="none"
style="fill: none;"
>
<style>
.spinner {
transform-origin: center;
animation: spinner_animate 0.75s infinite linear;
}
@keyframes spinner_animate {
100% {
transform: rotate(360deg);
}
}
</style>
<circle cx="12" cy="12" r="8" stroke="${ringColor}" stroke-width="4" />
<circle
class="spinner"
cx="12"
cy="12"
r="8"
stroke="${strokeColor}"
stroke-width="4"
stroke-linecap="round"
stroke-dasharray="${2 * Math.PI * 8 * progress} ${
2 * Math.PI * 8 * (1 - progress)
}"
/>
</svg>`;

View File

@@ -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>`;

View File

@@ -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);

View File

@@ -0,0 +1,866 @@
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',
});
export const CollapseCodeIcon = icons.CollapseIcon({
width: '20',
height: '20',
});
export const ExpandCodeIcon = icons.ToggleRightIcon({
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 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',
});
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 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',
});

View File

@@ -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>`;
}

View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1,277 @@
import type {
LinkPreviewProvider,
LinkPreviewResult,
} from '@blocksuite/affine-shared/services';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { ToggleDownIcon, ToggleRightIcon } from '@blocksuite/icons/lit';
import { flip, offset, shift } from '@floating-ui/dom';
import { css, html, LitElement, nothing, type PropertyValues } from 'lit';
import { property, state } from 'lit/decorators.js';
import { createLitPortal } from '../portal';
export class LinkPreviewDetails extends LitElement {
static override styles = css`
:host {
display: block;
color: ${unsafeCSSVarV2('text/primary')};
font: 13px/20px var(--affine-font-family);
}
:host([floating]) {
position: absolute;
right: 8px;
bottom: 8px;
}
:host([floating]) button {
background: ${unsafeCSSVarV2('layer/background/primary')};
box-shadow: 0 0 0 1px ${unsafeCSSVarV2('layer/background/tertiary')};
}
.content.floating {
margin: 0;
width: min(360px, calc(100vw - 32px));
box-sizing: border-box;
box-shadow: var(--affine-menu-shadow);
}
button {
display: flex;
align-items: center;
gap: 4px;
border: 0;
border-radius: 4px;
padding: 4px 8px;
background: transparent;
color: ${unsafeCSSVarV2('text/secondary')};
font: inherit;
cursor: pointer;
}
button:hover {
background: var(--affine-hover-color);
}
button:focus-visible {
outline: 2px solid var(--affine-primary-color);
}
.content {
color: inherit;
margin-top: 4px;
padding: 12px;
border: 1px solid ${unsafeCSSVarV2('layer/background/tertiary')};
border-radius: 8px;
background: ${unsafeCSSVarV2('layer/background/primary')};
max-height: 320px;
overflow: auto;
user-select: text;
overflow-wrap: anywhere;
}
.meta,
.status,
time {
color: ${unsafeCSSVarV2('text/secondary')};
}
.segment {
margin-top: 8px;
}
.segment-text,
.description {
white-space: pre-wrap;
}
h4 {
margin: 12px 0 4px;
font: inherit;
font-weight: 600;
}
time {
margin-right: 8px;
font-variant-numeric: tabular-nums;
}
`;
@property({ attribute: false }) accessor url = '';
@property({ type: Boolean, reflect: true }) accessor floating = false;
@property({ attribute: false }) accessor provider!: LinkPreviewProvider;
@state() private accessor open = false;
@state() private accessor loading = false;
@state() private accessor value: LinkPreviewResult | undefined;
private controller?: AbortController;
private popupController?: AbortController;
private updatePopup?: () => void;
protected override willUpdate(changed: PropertyValues) {
if (changed.has('url') || changed.has('provider')) {
this.controller?.abort();
this.open = false;
this.loading = false;
this.value = undefined;
}
}
override disconnectedCallback() {
super.disconnectedCallback();
this.popupController?.abort();
this.controller?.abort();
this.open = false;
this.loading = false;
}
protected override updated() {
if (!this.floating || !this.open) {
this.popupController?.abort();
return;
}
if (this.popupController) {
this.updatePopup?.();
return;
}
const button = this.shadowRoot?.querySelector('button');
if (!button) return;
const controller = new AbortController();
this.popupController = controller;
controller.signal.addEventListener('abort', () => {
this.popupController = undefined;
this.updatePopup = undefined;
this.open = false;
});
document.addEventListener(
'keydown',
event => {
if (event.key !== 'Escape') return;
event.stopPropagation();
controller.abort();
button.focus();
},
{ signal: controller.signal }
);
const { portal } = createLitPortal({
template: ({ updatePortal }) => {
this.updatePopup = updatePortal;
return html`<style>
${LinkPreviewDetails.styles.cssText}</style
>${this.renderContent()}`;
},
computePosition: {
referenceElement: button,
placement: 'bottom-end',
middleware: [offset(8), flip(), shift({ padding: 16 })],
autoUpdate: { animationFrame: true },
},
abortController: controller,
positionStrategy: 'fixed',
closeOnClickAway: true,
});
portal.shadowRoot?.querySelector<HTMLElement>('.content')?.focus();
}
private async toggle() {
this.open = !this.open;
if (!this.open || this.loading || this.value) return;
const controller = new AbortController();
this.controller = controller;
this.loading = true;
try {
const value = await this.provider.query(this.url, controller.signal, [
'transcript',
]);
if (!controller.signal.aborted) this.value = value;
} catch {
if (!controller.signal.aborted) this.value = {};
} finally {
if (this.controller === controller) this.loading = false;
}
}
override render() {
return html`<div
contenteditable="false"
@click=${(event: Event) => event.stopPropagation()}
@dblclick=${(event: Event) => event.stopPropagation()}
@pointerdown=${(event: Event) => event.stopPropagation()}
>
<button
type="button"
aria-expanded=${this.open}
aria-controls=${this.floating ? nothing : 'details'}
aria-haspopup=${this.floating ? 'dialog' : nothing}
@click=${this.toggle}
>
${(this.open ? ToggleDownIcon : ToggleRightIcon)({ width: '16px', height: '16px' })}
Details
</button>
${this.open && !this.floating ? this.renderContent() : nothing}
</div>`;
}
private renderContent() {
const value = this.value;
const transcript = value?.transcript;
const metadata = [
value?.author?.name,
value?.publishedAt?.split('T')[0],
value?.durationSeconds === undefined
? undefined
: timestamp(value.durationSeconds),
].filter(Boolean);
let chapterIndex = 0;
const chapters = transcript?.chapters ?? [];
return html`<div
class="content ${this.floating ? 'floating' : ''}"
id="details"
contenteditable="false"
aria-busy=${this.loading}
role=${this.floating ? 'dialog' : 'region'}
aria-label="Link details"
tabindex="-1"
@click=${(event: Event) => event.stopPropagation()}
@dblclick=${(event: Event) => event.stopPropagation()}
@pointerdown=${(event: Event) => event.stopPropagation()}
@wheel=${(event: Event) => event.stopPropagation()}
>
${
this.loading
? html`<span class="status" role="status">Loading details…</span>`
: html`
${metadata.length ? html`<div class="meta">${metadata.join(' · ')}</div>` : nothing}
${value?.description ? html`<p class="description">${value.description}</p>` : nothing}
${
transcript?.segments.length
? html`
<h4>Transcript</h4>
${transcript.segments.map(segment => {
const headings = [];
while (
chapterIndex < chapters.length &&
chapters[chapterIndex].startSeconds <=
(segment.startSeconds ?? 0)
) {
headings.push(
html`<h4>${chapters[chapterIndex++].title}</h4>`
);
}
return html`${headings}
<div class="segment">
${segment.startSeconds === undefined ? nothing : html`<time>${timestamp(segment.startSeconds)}</time>`}${segment.speaker ? html`<strong>${segment.speaker}: </strong>` : nothing}<span
class="segment-text"
>${segment.text}</span
>
</div>`;
})}
${transcript.truncated ? html`<p class="status">Transcript truncated by the preview service.</p>` : nothing}
`
: html`<div class="status">No transcript available.</div>`
}
`
}
</div>`;
}
}
function timestamp(seconds: number) {
const minutes = Math.floor(seconds / 60);
return `${minutes}:${Math.floor(seconds % 60)
.toString()
.padStart(2, '0')}`;
}
declare global {
interface HTMLElementTagNameMap {
'affine-link-preview-details': LinkPreviewDetails;
}
}

View File

@@ -0,0 +1,10 @@
import { LinkPreviewDetails } from './details';
import { LinkPreview } from './link';
export * from './details';
export * from './link';
export function effects() {
customElements.define('affine-link-preview', LinkPreview);
customElements.define('affine-link-preview-details', LinkPreviewDetails);
}

View File

@@ -0,0 +1,81 @@
import {
getHostName,
isValidUrl,
normalizeUrl,
} from '@blocksuite/affine-shared/utils';
import { PropTypes, requiredProperties } from '@blocksuite/std';
import { css, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import { html } from 'lit-html';
@requiredProperties({
url: PropTypes.string,
})
export class LinkPreview extends LitElement {
static override styles = css`
.affine-link-preview {
display: flex;
justify-content: flex-start;
min-width: 60px;
max-width: 140px;
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;
}
`;
@property({ attribute: false })
accessor url!: string;
override render() {
const { url } = this;
const normalizedUrl = normalizeUrl(url);
const safeUrl =
normalizedUrl && isValidUrl(normalizedUrl) ? normalizedUrl : null;
const hostName = getHostName(safeUrl ?? url);
if (!safeUrl) {
return html`
<span class="affine-link-preview">
<span>${hostName}</span>
</span>
`;
}
return html`
<a
class="affine-link-preview"
rel="noopener noreferrer"
target="_blank"
href=${safeUrl}
>
<span>${hostName}</span>
</a>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'affine-link-preview': LinkPreview;
}
}

View File

@@ -0,0 +1,61 @@
import { PropTypes, requiredProperties } from '@blocksuite/std';
import { css, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import { html } from 'lit-html';
@requiredProperties({
title: PropTypes.string,
open: PropTypes.instanceOf(Function),
})
export class DocTitle extends LitElement {
static override styles = css`
editor-icon-button .label {
min-width: 60px;
max-width: 140px;
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;
}
`;
@property({ attribute: false })
override accessor title!: string;
@property({ attribute: false })
accessor open!: (event: MouseEvent) => void;
override render() {
const { title, open } = this;
return html`
<editor-icon-button
aria-label="Doc title"
.hover=${false}
.labelHeight="${'20px'}"
.tooltip=${title}
@click=${(event: MouseEvent) => open(event)}
>
<span class="label">${title}</span>
</editor-icon-button>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'affine-linked-doc-title': DocTitle;
}
}

View File

@@ -0,0 +1,7 @@
import { DocTitle } from './doc-title';
export * from './doc-title';
export function effects() {
customElements.define('affine-linked-doc-title', DocTitle);
}

View File

@@ -0,0 +1 @@
export * from './linked-doc.js';

View File

@@ -0,0 +1,45 @@
import { NotificationProvider } from '@blocksuite/affine-shared/services';
import { type BlockStdScope } from '@blocksuite/std';
import { toast } from '../toast/toast.js';
function notify(std: BlockStdScope, title: string, message: string) {
const notification = std.getOptional(NotificationProvider);
const { host } = std;
if (!notification) {
toast(host, title);
return;
}
notification.notifyWithUndoAction({
title,
message,
accent: 'info',
duration: 10 * 1000,
});
}
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.`
);
}

View File

@@ -0,0 +1,131 @@
import {
type OpenDocMode,
type ToolbarAction,
ToolbarContext,
} from '@blocksuite/affine-shared/services';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { PropTypes, requiredProperties } from '@blocksuite/std';
import { computed, type ReadonlySignal } from '@preact/signals-core';
import { css, html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import { ifDefined } from 'lit-html/directives/if-defined.js';
import { repeat } from 'lit-html/directives/repeat.js';
import { EditorChevronDown } from '../toolbar';
@requiredProperties({
actions: PropTypes.array,
context: PropTypes.instanceOf(ToolbarContext),
openDocModeSignal: PropTypes.object,
updateOpenDocMode: PropTypes.instanceOf(Function),
})
export class OpenDocDropdownMenu extends SignalWatcher(
WithDisposable(LitElement)
) {
static override styles = css`
:host {
display: flex;
gap: unset !important;
}
div[data-orientation] {
width: 264px;
gap: 4px;
min-width: unset;
overflow: unset;
}
editor-menu-action {
.label {
display: flex;
flex: 1;
justify-content: space-between;
}
.shortcut {
color: ${unsafeCSSVarV2('text/secondary')};
}
}
`;
@property({ attribute: false })
accessor actions!: (ToolbarAction & {
mode: OpenDocMode;
shortcut?: string;
})[];
@property({ attribute: false })
accessor context!: ToolbarContext;
@property({ attribute: false })
accessor openDocModeSignal!: ReadonlySignal<OpenDocMode>;
@property({ attribute: false })
accessor updateOpenDocMode!: (mode: OpenDocMode) => void;
currentAction$ = computed(() => {
const currentOpenDocMode = this.openDocModeSignal.value;
return (
this.actions.find(a => a.mode === currentOpenDocMode) ?? this.actions[0]
);
});
override render() {
const {
actions,
context,
updateOpenDocMode,
currentAction$: { value: currentAction },
} = this;
return html`
<editor-icon-button
aria-label="${currentAction.label}"
.tooltip="${currentAction.label}"
@click=${() => currentAction.run?.(context)}
>
${currentAction.icon}
<span class="label">Open</span>
</editor-icon-button>
<editor-menu-button
aria-label="Open doc menu"
.contentPadding="${'8px'}"
.button=${html`
<editor-icon-button aria-label="Open doc with">
${EditorChevronDown}
</editor-icon-button>
`}
>
<div data-orientation="vertical">
${repeat(
actions,
action => action.id,
({ label, icon, run, disabled, mode, shortcut }) => html`
<editor-menu-action
aria-label=${ifDefined(label)}
?disabled=${ifDefined(disabled)}
@click=${() => {
run?.(context);
updateOpenDocMode(mode);
}}
>
${icon}
<div class="label">
${label}
<span class="shortcut">${shortcut}</span>
</div>
</editor-menu-action>
`
)}
</div>
</editor-menu-button>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'affine-open-doc-dropdown-menu': OpenDocDropdownMenu;
}
}

View File

@@ -0,0 +1,7 @@
import { OpenDocDropdownMenu } from './dropdown-menu';
export * from './dropdown-menu';
export function effects() {
customElements.define('affine-open-doc-dropdown-menu', OpenDocDropdownMenu);
}

View File

@@ -0,0 +1,47 @@
import {
getBlockSelectionsCommand,
getSelectedBlocksCommand,
getTextSelectionCommand,
} from '@blocksuite/affine-shared/commands';
import type { BlockComponent, Command, InitCommandCtx } from '@blocksuite/std';
import { isPeekable, peek } from './peekable.js';
const getSelectedPeekableBlocks = (cmd: InitCommandCtx) => {
const [result, ctx] = cmd.std.command
.chain()
.tryAll(chain => [
chain.pipe(getTextSelectionCommand),
chain.pipe(getBlockSelectionsCommand),
])
.pipe(getSelectedBlocksCommand, { types: ['text', 'block'] })
.run();
return ((result ? ctx.selectedBlocks : []) || []).filter(isPeekable);
};
export const getSelectedPeekableBlocksCommand: Command<
{
selectedBlocks: BlockComponent[];
},
{
selectedPeekableBlocks: BlockComponent[];
}
> = (ctx, next) => {
const selectedPeekableBlocks = getSelectedPeekableBlocks(ctx);
if (selectedPeekableBlocks.length > 0) {
next({ selectedPeekableBlocks });
}
};
export const peekSelectedBlockCommand: Command<{
selectedBlocks: BlockComponent[];
}> = (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();
}
};

View File

@@ -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 readonly _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 readonly target: T,
private readonly enable?: (e: T) => boolean
) {}
}

View File

@@ -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';

View File

@@ -0,0 +1,137 @@
import { NoteBlockModel } from '@blocksuite/affine-model';
import { DocModeProvider } from '@blocksuite/affine-shared/services';
import {
isInsideEdgelessEditor,
matchModels,
} from '@blocksuite/affine-shared/utils';
import type { Constructor } from '@blocksuite/global/utils';
import {
GfxBlockElementModel,
GfxControllerIdentifier,
} from '@blocksuite/std/gfx';
import type { BlockModel } from '@blocksuite/store';
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);
/**
* In edgeless mode, we need to check if the click target is not covered by
* other elements. If it is, we should not show the peek view.
*/
private _peekableInEdgeless(e: MouseEvent) {
const docModeService = this.std.getOptional(DocModeProvider);
if (
!('model' in this) ||
!docModeService ||
docModeService.getEditorMode() !== 'edgeless'
) {
return true;
}
const model = this['model'] as BlockModel;
const gfx = this.std.get(GfxControllerIdentifier);
const hitTarget = gfx.getElementByPoint(
...gfx.viewport.toModelCoordFromClientCoord([e.clientX, e.clientY])
);
if (hitTarget && hitTarget !== model) {
// Check if hitTarget is a GfxBlockElementModel (which extends BlockModel)
// and if it's a NoteBlockModel, then check if current model is inside it
if (
hitTarget instanceof GfxBlockElementModel &&
matchModels(hitTarget, [NoteBlockModel])
) {
let curModel: BlockModel | null = model;
while (curModel) {
if (curModel === hitTarget) {
return true; // Model is inside the NoteBlockModel, allow peek
}
curModel = curModel.parent;
}
}
return false;
}
return true;
}
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 && this._peekableInEdgeless(e)) {
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 &&
this._peekableInEdgeless(e)
) {
e.stopPropagation();
e.stopImmediatePropagation();
this[symbol].peek().catch(console.error);
}
});
}
}
};
return derivedClass as unknown as C;
};

View File

@@ -0,0 +1,16 @@
import { createIdentifier } from '@blocksuite/global/di';
import type { ExtensionType } from '@blocksuite/store';
import type { PeekViewService } from './type.js';
export const PeekViewProvider = createIdentifier<PeekViewService>(
'AffinePeekViewProvider'
);
export function PeekViewExtension(service: PeekViewService): ExtensionType {
return {
setup: di => {
di.override(PeekViewProvider, () => service);
},
};
}

View File

@@ -0,0 +1,70 @@
import type { DisposableClass } from '@blocksuite/global/lit';
import type { BlockComponent, BlockStdScope } from '@blocksuite/std';
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(
// oxlint-disable-next-line typescript/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;
};

View File

@@ -0,0 +1,225 @@
import { BlockSuiteError } from '@blocksuite/global/exceptions';
import {
autoUpdate,
computePosition,
type ComputePositionReturn,
} from '@floating-ui/dom';
import { cssVar } from '@toeverything/theme';
import { render } from 'lit';
import { Subject } from 'rxjs';
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;
if (!root) {
throw new BlockSuiteError(
BlockSuiteError.ErrorCode.ValueNotExists,
'Failed to create portal 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;
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 Subject<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 Subject<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 { portal: portalRoot, update: () => {} };
}
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;
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.next(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 { portal: portalRoot, update };
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,82 @@
import type {
AutoUpdateOptions,
ComputePositionConfig,
ComputePositionReturn,
ReferenceElement,
} from '@floating-ui/dom';
import type { RenderOptions, TemplateResult } from 'lit';
import type { Subject } from 'rxjs';
/**
* 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: Subject<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;
};

View File

@@ -0,0 +1,8 @@
import { ResourceStatus } from './status';
export * from './resource';
export * from './status';
export function effects() {
customElements.define('affine-resource-status', ResourceStatus);
}

View File

@@ -0,0 +1,230 @@
import type { Disposable } from '@blocksuite/global/disposable';
import type { BlobEngine, BlobState } from '@blocksuite/sync';
import {
computed,
effect,
type ReadonlySignal,
signal,
} from '@preact/signals-core';
import type { TemplateResult } from 'lit-html';
export type ResourceKind = 'Blob' | 'File' | 'Image';
export type StateKind =
| 'loading'
| 'uploading'
| 'error'
| 'error:oversize'
| 'none';
export type StateInfo = {
icon: TemplateResult;
title?: string;
description?: string | null;
};
export type ResolvedStateInfoPart = {
loading: boolean;
error: boolean;
state: StateKind;
url: string | null;
needUpload: boolean;
};
export type ResolvedStateInfo = StateInfo & ResolvedStateInfoPart;
export class ResourceController implements Disposable {
readonly blobUrl$ = signal<string | null>(null);
// TODO(@fundon): default `loading` status.
readonly state$ = signal<Partial<BlobState>>({});
readonly resolvedState$ = computed<ResolvedStateInfoPart>(() => {
const url = this.blobUrl$.value;
const {
needUpload = false,
uploading = false,
downloading = false,
overSize = false,
errorMessage,
} = this.state$.value;
const hasExceeded = overSize;
const hasError = hasExceeded || Boolean(errorMessage);
const state = this.determineState(
hasExceeded,
hasError,
uploading,
downloading
);
const loading = state === 'uploading' || state === 'loading';
return {
error: hasError,
needUpload,
loading,
state,
url,
};
});
private engine?: BlobEngine;
constructor(
readonly blobId$: ReadonlySignal<string | undefined>,
readonly kind: ResourceKind = 'File'
) {}
// This is a tradeoff, initializing `Blob Sync Engine`.
setEngine(engine: BlobEngine) {
this.engine = engine;
return this;
}
determineState(
hasExceeded: boolean,
hasError: boolean,
uploading: boolean,
downloading: boolean
): StateKind {
if (hasExceeded) return 'error:oversize';
if (hasError) return 'error';
if (uploading) return 'uploading';
if (downloading) return 'loading';
return 'none';
}
resolveStateWith(
info: {
loadingIcon: TemplateResult;
errorIcon?: TemplateResult;
} & StateInfo
): ResolvedStateInfo {
const { error, loading, state, url, needUpload } =
this.resolvedState$.value;
const { icon, title, description, loadingIcon, errorIcon } = info;
const result = {
error,
loading,
state,
icon,
title,
description,
url,
needUpload,
};
if (loading) {
result.icon = loadingIcon ?? icon;
} else if (error) {
result.icon = errorIcon ?? icon;
result.description = this.state$.value.errorMessage ?? description;
}
return result;
}
updateState(state: Partial<BlobState>) {
this.state$.value = { ...this.state$.value, ...state };
}
subscribe() {
return effect(() => {
const blobId = this.blobId$.value;
if (!blobId) return;
const blobState$ = this.engine?.blobState$(blobId);
if (!blobState$) return;
const subscription = blobState$.subscribe(state => {
let { uploading, downloading, errorMessage } = state;
if (state.overSize) {
uploading = false;
downloading = false;
} else if ((uploading || downloading) && errorMessage) {
errorMessage = null;
}
this.updateState({ ...state, uploading, downloading, errorMessage });
});
return () => subscription.unsubscribe();
});
}
async blob() {
const blobId = this.blobId$.peek();
if (!blobId) return null;
let blob: Blob | null = null;
let errorMessage: string | null = null;
try {
if (!this.engine) {
throw new Error('Blob engine is not initialized');
}
blob = (await this.engine.get(blobId)) ?? null;
if (!blob) errorMessage = `${this.kind} not found`;
} catch (err) {
console.error(err);
errorMessage = `Failed to retrieve ${this.kind}`;
}
if (errorMessage) this.updateState({ errorMessage });
return blob;
}
async createUrlWith(type?: string) {
let blob = await this.blob();
if (!blob) return null;
if (type) blob = new Blob([blob], { type });
return URL.createObjectURL(blob);
}
async refreshUrlWith(type?: string) {
// Resets the state.
this.state$.value = {};
const url = await this.createUrlWith(type);
if (!url) return;
const prevUrl = this.blobUrl$.peek();
this.blobUrl$.value = url;
if (!prevUrl) return;
// Releases the previous url.
URL.revokeObjectURL(prevUrl);
}
// Re-upload to the server.
async upload() {
const blobId = this.blobId$.peek();
if (!blobId) return;
const state = this.state$.peek();
if (!state.needUpload) return;
if (state.uploading) return;
// Resets the state.
this.state$.value = {};
return await this.engine?.upload(blobId);
}
dispose() {
const url = this.blobUrl$.peek();
if (!url) return;
// Releases the current url.
URL.revokeObjectURL(url);
}
}

View File

@@ -0,0 +1,165 @@
import {
fontBaseStyle,
panelBaseColorsStyle,
} from '@blocksuite/affine-shared/styles';
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import {
createButtonPopper,
stopPropagation,
} from '@blocksuite/affine-shared/utils';
import { WithDisposable } from '@blocksuite/global/lit';
import { InformationIcon } from '@blocksuite/icons/lit';
import { PropTypes, requiredProperties } from '@blocksuite/std';
import { css, html, LitElement } from 'lit';
import { property, query } from 'lit/decorators.js';
@requiredProperties({
message: PropTypes.string,
needUpload: PropTypes.boolean,
action: PropTypes.instanceOf(Function),
})
export class ResourceStatus extends WithDisposable(LitElement) {
static override styles = css`
button.status {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
font-size: 18px;
outline: none;
border: none;
cursor: pointer;
color: ${unsafeCSSVarV2('button/pureWhiteText')};
background: ${unsafeCSSVarV2('status/error')};
box-shadow: ${unsafeCSSVar('overlayShadow')};
}
${panelBaseColorsStyle('.popper')}
${fontBaseStyle('.popper')}
.popper {
display: none;
outline: none;
padding: 8px;
border-radius: 8px;
width: 260px;
font-style: normal;
font-weight: 400;
line-height: 22px;
font-size: ${unsafeCSSVar('fontSm')};
&[data-show] {
display: flex;
flex-direction: column;
gap: 4px;
}
}
.header {
font-weight: 500;
}
.content {
font-feature-settings:
'liga' off,
'clig' off;
color: ${unsafeCSSVarV2('text/primary')};
}
.footer {
display: flex;
justify-content: flex-end;
margin-top: 4px;
}
button.action {
display: flex;
align-items: center;
padding: 2px 12px;
border-radius: 8px;
border: none;
background: none;
cursor: pointer;
outline: none;
color: ${unsafeCSSVarV2('button/primary')};
}
`;
private _popper: ReturnType<typeof createButtonPopper> | null = null;
private _updatePopper() {
this._popper?.dispose();
this._popper = createButtonPopper({
reference: this._trigger,
popperElement: this._content,
mainAxis: 8,
allowedPlacements: ['top-start', 'bottom-start'],
});
}
override firstUpdated() {
this._updatePopper();
this.disposables.addFromEvent(this, 'click', stopPropagation);
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();
});
this.disposables.addFromEvent(
this._actionButton,
'click',
(_: MouseEvent) => {
this._popper?.hide();
this.action();
}
);
this.disposables.add(() => this._popper?.dispose());
}
override render() {
const { message, needUpload } = this;
const { type, label } = needUpload
? {
type: 'Upload',
label: 'Retry',
}
: {
type: 'Download',
label: 'Reload',
};
return html`
<button class="status">${InformationIcon()}</button>
<div class="popper">
<div class="header">${type} failed</div>
<div class="content">${message}</div>
<div class="footer">
<button class="action">${label}</button>
</div>
</div>
`;
}
@query('div.popper')
private accessor _content!: HTMLDivElement;
@query('button.status')
private accessor _trigger!: HTMLButtonElement;
@query('button.action')
private accessor _actionButton!: HTMLButtonElement;
@property({ attribute: false })
accessor message!: string;
@property({ attribute: false })
accessor needUpload!: boolean;
@property({ attribute: false })
accessor action!: () => void;
}

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