refactor(editor): extract root block (#10356)

Closes: [BS-2207](https://linear.app/affine-design/issue/BS-2207/move-root-block-to-affineblock-root)
This commit is contained in:
Saul-Mirone
2025-02-21 12:38:26 +00:00
parent 4a66ec7400
commit 55651503df
336 changed files with 626 additions and 423 deletions
@@ -0,0 +1,549 @@
import type { AIError } from '@blocksuite/affine-components/ai-item';
import {
DocModeProvider,
NotificationProvider,
ThemeProvider,
} from '@blocksuite/affine-shared/services';
import {
getPageRootByElement,
stopPropagation,
} from '@blocksuite/affine-shared/utils';
import { WidgetComponent } from '@blocksuite/block-std';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import { assertExists } from '@blocksuite/global/utils';
import type { BaseSelection } from '@blocksuite/store';
import {
autoPlacement,
autoUpdate,
computePosition,
type ComputePositionConfig,
flip,
offset,
type Rect,
shift,
} from '@floating-ui/dom';
import { css, html, nothing, type PropertyValues } from 'lit';
import { property, query } from 'lit/decorators.js';
import { choose } from 'lit/directives/choose.js';
import { AFFINE_FORMAT_BAR_WIDGET } from '../format-bar/format-bar.js';
import {
AFFINE_VIEWPORT_OVERLAY_WIDGET,
type AffineViewportOverlayWidget,
} from '../viewport-overlay/viewport-overlay.js';
import type { AIPanelGenerating } from './components/index.js';
import type { AffineAIPanelState, AffineAIPanelWidgetConfig } from './type.js';
export const AFFINE_AI_PANEL_WIDGET = 'affine-ai-panel-widget';
export class AffineAIPanelWidget extends WidgetComponent {
static override styles = css`
:host {
display: flex;
outline: none;
border-radius: var(--8, 8px);
border: 1px solid var(--affine-border-color);
background: var(--affine-background-overlay-panel-color);
box-shadow: var(--affine-overlay-shadow);
position: absolute;
width: max-content;
height: auto;
top: 0;
left: 0;
overflow-y: auto;
scrollbar-width: none !important;
z-index: var(--affine-z-index-popover);
--affine-font-family: var(--affine-font-sans-family);
}
.ai-panel-container {
display: flex;
flex-direction: column;
box-sizing: border-box;
width: 100%;
height: fit-content;
padding: 10px 0;
}
.ai-panel-container:not(:has(ai-panel-generating)) {
gap: 8px;
}
.ai-panel-container:has(ai-panel-answer),
.ai-panel-container:has(ai-panel-error),
.ai-panel-container:has(ai-panel-generating:has(generating-placeholder)) {
padding: 12px 0;
}
:host([data-state='hidden']) {
display: none;
}
`;
private _abortController = new AbortController();
private _answer: string | null = null;
private readonly _clearDiscardModal = () => {
if (this._discardModalAbort) {
this._discardModalAbort.abort();
this._discardModalAbort = null;
}
};
private readonly _clickOutside = () => {
this._discardWithConfirmation();
};
private _discardModalAbort: AbortController | null = null;
private readonly _inputFinish = (text: string) => {
this._inputText = text;
this.generate();
};
private _inputText: string | null = null;
private readonly _onDocumentClick = (e: MouseEvent) => {
if (
this.state !== 'hidden' &&
e.target !== this &&
!this.contains(e.target as Node)
) {
this._clickOutside();
return true;
}
return false;
};
private readonly _onKeyDown = (event: KeyboardEvent) => {
event.stopPropagation();
const { state } = this;
if (state !== 'generating' && state !== 'input') {
return;
}
const { key } = event;
if (key === 'Escape') {
if (state === 'generating') {
this.stopGenerating();
} else {
this.hide();
}
return;
}
};
private readonly _resetAbortController = () => {
if (this.state === 'generating') {
this._abortController.abort();
}
this._abortController = new AbortController();
};
private _selection?: BaseSelection[];
private _stopAutoUpdate?: undefined | (() => void);
ctx: unknown = null;
private readonly _discardWithConfirmation = () => {
if (this.state === 'hidden') {
return;
}
if (this.state === 'input' || !this.answer) {
this.hide();
return;
}
this.showDiscardModal()
.then(discard => {
discard && this.discard();
})
.catch(console.error);
};
discard = () => {
this.hide();
this.restoreSelection();
this.config?.discardCallback?.();
};
/**
* You can evaluate this method multiple times to regenerate the answer.
*/
generate = () => {
this.restoreSelection();
assertExists(this.config);
const text = this._inputText;
assertExists(text);
assertExists(this.config.generateAnswer);
this._resetAbortController();
// reset answer
this._answer = null;
const update = (answer: string) => {
this._answer = answer;
this.requestUpdate();
};
const finish = (type: 'success' | 'error' | 'aborted', err?: AIError) => {
if (type === 'aborted') return;
assertExists(this.config);
if (type === 'error') {
this.state = 'error';
this.config.errorStateConfig.error = err;
} else {
this.state = 'finished';
this.config.errorStateConfig.error = undefined;
}
this._resetAbortController();
};
this.scrollTop = 0; // reset scroll top
this.state = 'generating';
this.config.generateAnswer({
input: text,
update,
finish,
signal: this._abortController.signal,
});
};
hide = (shouldTriggerCallback: boolean = true) => {
this._resetAbortController();
this.state = 'hidden';
this._stopAutoUpdate?.();
this._inputText = null;
this._answer = null;
this._stopAutoUpdate = undefined;
this.viewportOverlayWidget?.unlock();
if (shouldTriggerCallback) {
this.config?.hideCallback?.();
}
};
onInput = (text: string) => {
this._inputText = text;
this.config?.inputCallback?.(text);
};
restoreSelection = () => {
if (this._selection) {
this.host.selection.set([...this._selection]);
if (this.state === 'hidden') {
this._selection = undefined;
}
}
};
setState = (state: AffineAIPanelState, reference: Element) => {
this.state = state;
this._autoUpdatePosition(reference);
};
showDiscardModal = () => {
const notification = this.host.std.getOptional(NotificationProvider);
if (!notification) {
return Promise.resolve(true);
}
this._clearDiscardModal();
this._discardModalAbort = new AbortController();
return notification
.confirm({
title: 'Discard the AI result',
message: 'Do you want to discard the results the AI just generated?',
cancelText: 'Cancel',
confirmText: 'Discard',
abort: this._abortController.signal,
})
.finally(() => (this._discardModalAbort = null));
};
stopGenerating = () => {
this._abortController.abort();
this.state = 'finished';
if (!this.answer) {
this.hide();
}
};
toggle = (
reference: Element,
input?: string,
shouldTriggerCallback?: boolean
) => {
if (input) {
this._inputText = input;
this.generate();
} else {
// reset state
this.hide(shouldTriggerCallback);
this.state = 'input';
}
this._autoUpdatePosition(reference);
};
get answer() {
return this._answer;
}
get inputText() {
return this._inputText;
}
get viewportOverlayWidget() {
const rootId = this.host.doc.root?.id;
return rootId
? (this.host.view.getWidget(
AFFINE_VIEWPORT_OVERLAY_WIDGET,
rootId
) as AffineViewportOverlayWidget)
: null;
}
private _autoUpdatePosition(reference: Element) {
// workaround for the case that the reference contains children block elements, like:
// paragraph
// child paragraph
{
const childrenContainer = reference.querySelector(
'.affine-block-children-container'
);
if (childrenContainer && childrenContainer.previousElementSibling) {
reference = childrenContainer.previousElementSibling;
}
}
this._stopAutoUpdate?.();
this._stopAutoUpdate = autoUpdate(reference, this, () => {
computePosition(reference, this, this._calcPositionOptions(reference))
.then(({ x, y }) => {
this.style.left = `${x}px`;
this.style.top = `${y}px`;
setTimeout(() => {
const input = this.shadowRoot?.querySelector('ai-panel-input');
input?.textarea?.focus();
}, 0);
})
.catch(console.error);
});
}
private _calcPositionOptions(
reference: Element
): Partial<ComputePositionConfig> {
let rootBoundary: Rect | undefined;
{
const docModeProvider = this.host.std.get(DocModeProvider);
if (docModeProvider.getEditorMode() === 'page') {
rootBoundary = undefined;
} else {
const gfx = this.host.std.get(GfxControllerIdentifier);
// TODO circular dependency: instanceof EdgelessRootService
const viewport = gfx.viewport;
rootBoundary = {
x: viewport.left,
y: viewport.top,
width: viewport.width,
height: viewport.height - 100, // 100 for edgeless toolbar
};
}
}
const overflowOptions = {
padding: 20,
rootBoundary: rootBoundary,
};
// block element in page editor
if (getPageRootByElement(reference)) {
return {
placement: 'bottom-start',
middleware: [offset(8), shift(overflowOptions)],
};
}
// block element in doc in edgeless editor
else if (reference.closest('edgeless-block-portal-note')) {
return {
middleware: [
offset(8),
shift(overflowOptions),
autoPlacement({
...overflowOptions,
allowedPlacements: ['top-start', 'bottom-start'],
}),
],
};
}
// edgeless element
else {
return {
placement: 'right-start',
middleware: [
offset({ mainAxis: 16 }),
flip({
mainAxis: true,
crossAxis: true,
flipAlignment: true,
...overflowOptions,
}),
shift({
crossAxis: true,
...overflowOptions,
}),
],
};
}
}
override connectedCallback() {
super.connectedCallback();
this.tabIndex = -1;
this.disposables.addFromEvent(
document,
'pointerdown',
this._onDocumentClick
);
this.disposables.add(
this.block.host.event.add('pointerDown', evtState =>
this._onDocumentClick(
evtState.get('pointerState').event as PointerEvent
)
)
);
this.disposables.add(
this.block.host.event.add('click', () => {
return this.state !== 'hidden' ? true : false;
})
);
this.disposables.addFromEvent(this, 'wheel', stopPropagation);
this.disposables.addFromEvent(this, 'pointerdown', stopPropagation);
this.disposables.addFromEvent(this, 'pointerup', stopPropagation);
this.disposables.addFromEvent(this, 'keydown', this._onKeyDown);
}
override disconnectedCallback() {
super.disconnectedCallback();
this._clearDiscardModal();
this._stopAutoUpdate?.();
}
override render() {
if (this.state === 'hidden') {
return nothing;
}
if (!this.config) return nothing;
const config = this.config;
const theme = this.std.get(ThemeProvider).theme;
const mainTemplate = choose(this.state, [
[
'input',
() =>
html`<ai-panel-input
.onBlur=${this._discardWithConfirmation}
.onFinish=${this._inputFinish}
.onInput=${this.onInput}
.networkSearchConfig=${config.networkSearchConfig}
></ai-panel-input>`,
],
[
'generating',
() => html`
${this.answer
? html`
<ai-panel-answer
.finish=${false}
.config=${config.finishStateConfig}
.host=${this.host}
>
${this.answer &&
config.answerRenderer(this.answer, this.state)}
</ai-panel-answer>
`
: nothing}
<ai-panel-generating
.config=${config.generatingStateConfig}
.theme=${theme}
.stopGenerating=${this.stopGenerating}
.withAnswer=${!!this.answer}
></ai-panel-generating>
`,
],
[
'finished',
() => html`
<ai-panel-answer
.config=${config.finishStateConfig}
.copy=${config.copy}
.host=${this.host}
>
${this.answer && config.answerRenderer(this.answer, this.state)}
</ai-panel-answer>
`,
],
[
'error',
() => html`
<ai-panel-error
.config=${config.errorStateConfig}
.copy=${config.copy}
.withAnswer=${!!this.answer}
.host=${this.host}
>
${this.answer && config.answerRenderer(this.answer, this.state)}
</ai-panel-error>
`,
],
]);
return html`<div class="ai-panel-container">${mainTemplate}</div>`;
}
protected override willUpdate(changed: PropertyValues): void {
const prevState = changed.get('state');
if (prevState) {
if (prevState === 'hidden') {
this._selection = this.host.selection.value;
} else {
this.restoreSelection();
}
// tell format bar to show or hide
const rootBlockId = this.host.doc.root?.id;
const formatBar = rootBlockId
? this.host.view.getWidget(AFFINE_FORMAT_BAR_WIDGET, rootBlockId)
: null;
if (formatBar) {
formatBar.requestUpdate();
}
}
if (this.state !== 'hidden') {
this.viewportOverlayWidget?.lock();
} else {
this.viewportOverlayWidget?.unlock();
}
this.dataset.state = this.state;
}
@property({ attribute: false })
accessor config: AffineAIPanelWidgetConfig | null = null;
@query('ai-panel-generating')
accessor generatingElement: AIPanelGenerating | null = null;
@property()
accessor state: AffineAIPanelState = 'hidden';
}
@@ -0,0 +1,29 @@
import { WithDisposable } from '@blocksuite/global/utils';
import { css, html, LitElement } from 'lit';
export class AIPanelDivider extends WithDisposable(LitElement) {
static override styles = css`
:host {
display: flex;
flex-direction: column;
align-items: flex-start;
align-self: stretch;
width: 100%;
}
.divider {
height: 0.5px;
background: var(--affine-border-color);
width: 100%;
}
`;
override render() {
return html`<div class="divider"></div>`;
}
}
declare global {
interface HTMLElementTagNameMap {
'ai-panel-divider': AIPanelDivider;
}
}
@@ -0,0 +1,111 @@
import {
AIDoneIcon,
CopyIcon,
WarningIcon,
} from '@blocksuite/affine-components/icons';
import { NotificationProvider } from '@blocksuite/affine-shared/services';
import type { EditorHost } from '@blocksuite/block-std';
import { WithDisposable } from '@blocksuite/global/utils';
import { baseTheme } from '@toeverything/theme';
import { css, html, LitElement, nothing, unsafeCSS } from 'lit';
import { property, state } from 'lit/decorators.js';
import type { CopyConfig } from '../type.js';
export class AIFinishTip extends WithDisposable(LitElement) {
static override styles = css`
:host {
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
}
.finish-tip {
display: flex;
box-sizing: border-box;
width: 100%;
height: 22px;
align-items: center;
justify-content: space-between;
padding: 0 12px;
gap: 4px;
color: var(--affine-text-secondary-color);
.text {
display: flex;
align-items: flex-start;
flex: 1 0 0;
/* light/xs */
font-size: var(--affine-font-xs);
font-style: normal;
font-weight: 400;
line-height: 20px; /* 166.667% */
}
.right {
display: flex;
align-items: center;
.copy,
.copied {
display: flex;
width: 20px;
height: 20px;
justify-content: center;
align-items: center;
border-radius: 8px;
user-select: none;
}
.copy:hover {
color: var(--affine-icon-color);
background: var(--affine-hover-color);
cursor: pointer;
}
.copied {
color: var(--affine-brand-color);
}
}
}
`;
override render() {
return html`<div class="finish-tip">
${WarningIcon}
<div class="text">AI outputs can be misleading or wrong</div>
${this.copy?.allowed
? html`<div class="right">
${this.copied
? html`<div class="copied">${AIDoneIcon}</div>`
: html`<div
class="copy"
@click=${async () => {
this.copied = !!(await this.copy?.onCopy());
if (this.copied) {
this.host.std
.getOptional(NotificationProvider)
?.toast('Copied to clipboard');
}
}}
>
${CopyIcon}
<affine-tooltip>Copy</affine-tooltip>
</div>`}
</div>`
: nothing}
</div>`;
}
@state()
accessor copied = false;
@property({ attribute: false })
accessor copy: CopyConfig | undefined = undefined;
@property({ attribute: false })
accessor host!: EditorHost;
}
declare global {
interface HTMLElementTagNameMap {
'ai-finish-tip': AIFinishTip;
}
}
@@ -0,0 +1,147 @@
import {
DarkLoadingIcon,
LightLoadingIcon,
} from '@blocksuite/affine-components/icons';
import { ColorScheme } from '@blocksuite/affine-model';
import { unsafeCSSVar } from '@blocksuite/affine-shared/theme';
import { WithDisposable } from '@blocksuite/global/utils';
import { baseTheme } from '@toeverything/theme';
import {
css,
html,
LitElement,
nothing,
type PropertyValues,
unsafeCSS,
} from 'lit';
import { property } from 'lit/decorators.js';
export class GeneratingPlaceholder extends WithDisposable(LitElement) {
static override styles = css`
:host {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
margin-bottom: 8px;
}
.generating-header {
width: 100%;
font-size: ${unsafeCSSVar('fontXs')};
font-style: normal;
font-weight: 500;
line-height: 20px;
height: 20px;
}
.generating-header,
.loading-progress {
color: ${unsafeCSSVar('textSecondaryColor')};
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
}
.generating-body {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: 100%;
border-radius: 4px;
border: 2px solid ${unsafeCSSVar('primaryColor')};
background: ${unsafeCSSVar('blue50')};
color: ${unsafeCSSVar('brandColor')};
gap: 4px;
}
.generating-icon {
display: flex;
justify-content: center;
align-items: center;
height: 24px;
}
.generating-icon svg {
scale: 1.5;
}
.loading-progress {
display: flex;
flex-direction: column;
font-style: normal;
font-weight: 400;
text-align: center;
gap: 4px;
}
.loading-text {
font-size: ${unsafeCSSVar('fontBase')};
height: 24px;
line-height: 24px;
}
.loading-stage {
font-size: ${unsafeCSSVar('fontXs')};
height: 20px;
line-height: 20px;
}
`;
protected override render() {
const loadingText = this.stages[this.loadingProgress - 1] || '';
return html`<style>
.generating-body {
height: ${this.height}px;
}
</style>
${this.showHeader
? html`<div class="generating-header">Answer</div>`
: nothing}
<div class="generating-body">
<div class="generating-icon">
${this.theme === ColorScheme.Light
? DarkLoadingIcon
: LightLoadingIcon}
</div>
<div class="loading-progress">
<div class="loading-text">${loadingText}</div>
<div class="loading-stage">
${this.loadingProgress} / ${this.stages.length}
</div>
</div>
</div>`;
}
override willUpdate(changed: PropertyValues) {
if (changed.has('loadingProgress')) {
this.loadingProgress = Math.max(
1,
Math.min(this.loadingProgress, this.stages.length)
);
}
}
@property({ attribute: false })
accessor height: number = 300;
@property({ attribute: false })
accessor loadingProgress!: number;
@property({ attribute: false })
accessor showHeader!: boolean;
@property({ attribute: false })
accessor stages!: string[];
@property({ attribute: false })
accessor theme!: ColorScheme;
}
declare global {
interface HTMLElementTagNameMap {
'generating-placeholder': GeneratingPlaceholder;
}
}
@@ -0,0 +1,2 @@
export * from './divider.js';
export * from './state/index.js';
@@ -0,0 +1,152 @@
import type { EditorHost } from '@blocksuite/block-std';
import { WithDisposable } from '@blocksuite/global/utils';
import { baseTheme } from '@toeverything/theme';
import { css, html, LitElement, nothing, unsafeCSS } from 'lit';
import { property } from 'lit/decorators.js';
import type { AIPanelAnswerConfig, CopyConfig } from '../../type.js';
import { filterAIItemGroup } from '../../utils.js';
export class AIPanelAnswer extends WithDisposable(LitElement) {
static override styles = css`
:host {
width: 100%;
display: flex;
box-sizing: border-box;
flex-direction: column;
gap: 8px;
padding: 0;
}
.answer {
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
gap: 4px;
align-self: stretch;
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
padding: 0 12px;
}
.answer-head {
align-self: stretch;
color: var(--affine-text-secondary-color);
/* light/xsMedium */
font-size: var(--affine-font-xs);
font-style: normal;
font-weight: 500;
line-height: 20px; /* 166.667% */
height: 20px;
}
.answer-body {
align-self: stretch;
color: var(--affine-text-primary-color);
font-feature-settings:
'clig' off,
'liga' off;
/* light/sm */
font-size: var(--affine-font-xs);
font-style: normal;
font-weight: 400;
line-height: 22px; /* 157.143% */
}
.response-list-container {
display: flex;
flex-direction: column;
gap: 4px;
}
.response-list-container,
.action-list-container {
padding: 0 8px;
user-select: none;
}
/* set item style outside ai-item */
.response-list-container ai-item-list,
.action-list-container ai-item-list {
--item-padding: 4px;
}
.response-list-container ai-item-list {
--item-icon-color: var(--affine-icon-secondary);
--item-icon-hover-color: var(--affine-icon-color);
}
`;
override render() {
const responseGroup = filterAIItemGroup(this.host, this.config.responses);
return html`
<div class="answer">
<div class="answer-head">Answer</div>
<div class="answer-body">
<slot></slot>
</div>
</div>
${this.finish
? html`
<ai-finish-tip
.copy=${this.copy}
.host=${this.host}
></ai-finish-tip>
${responseGroup.length > 0
? html`
<ai-panel-divider></ai-panel-divider>
${responseGroup.map(
(group, index) => html`
${index !== 0
? html`<ai-panel-divider></ai-panel-divider>`
: nothing}
<div class="response-list-container">
<ai-item-list
.host=${this.host}
.groups=${[group]}
></ai-item-list>
</div>
`
)}
`
: nothing}
${responseGroup.length > 0 && this.config.actions.length > 0
? html`<ai-panel-divider></ai-panel-divider>`
: nothing}
${this.config.actions.length > 0
? html`
<div class="action-list-container">
<ai-item-list
.host=${this.host}
.groups=${this.config.actions}
></ai-item-list>
</div>
`
: nothing}
`
: nothing}
`;
}
@property({ attribute: false })
accessor config!: AIPanelAnswerConfig;
@property({ attribute: false })
accessor copy: CopyConfig | undefined = undefined;
@property({ attribute: false })
accessor finish = true;
@property({ attribute: false })
accessor host!: EditorHost;
}
declare global {
interface HTMLElementTagNameMap {
'ai-panel-answer': AIPanelAnswer;
}
}
@@ -0,0 +1,263 @@
import {
AIErrorType,
type AIItemGroupConfig,
} from '@blocksuite/affine-components/ai-item';
import type { EditorHost } from '@blocksuite/block-std';
import { WithDisposable } from '@blocksuite/global/utils';
import { baseTheme } from '@toeverything/theme';
import { css, html, LitElement, nothing, unsafeCSS } from 'lit';
import { property } from 'lit/decorators.js';
import { choose } from 'lit/directives/choose.js';
import type { AIPanelErrorConfig, CopyConfig } from '../../type.js';
import { filterAIItemGroup } from '../../utils.js';
export class AIPanelError extends WithDisposable(LitElement) {
static override styles = css`
:host {
width: 100%;
display: flex;
flex-direction: column;
gap: 8px;
padding: 0;
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
}
.error {
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
align-self: stretch;
padding: 0px 12px;
gap: 4px;
.answer-tip {
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
gap: 4px;
align-self: stretch;
.answer-label {
align-self: stretch;
color: var(--affine-text-secondary-color);
/* light/xsMedium */
font-size: var(--affine-font-xs);
font-style: normal;
font-weight: 500;
line-height: 20px; /* 166.667% */
}
}
.error-info {
align-self: stretch;
color: var(--affine-error-color, #eb4335);
font-feature-settings:
'clig' off,
'liga' off;
/* light/sm */
font-size: var(--affine-font-sm);
font-style: normal;
font-weight: 400;
line-height: 22px; /* 157.143% */
a {
color: inherit;
}
}
.action-button-group {
display: flex;
width: 100%;
gap: 16px;
align-items: center;
justify-content: end;
margin-top: 4px;
}
.action-button {
display: flex;
box-sizing: border-box;
padding: 4px 12px;
justify-content: center;
align-items: center;
gap: 4px;
border-radius: 8px;
border: 1px solid var(--affine-border-color);
background: var(--affine-white);
color: var(--affine-text-primary-color);
/* light/xsMedium */
font-size: var(--affine-font-xs);
font-style: normal;
font-weight: 500;
line-height: 20px; /* 166.667% */
}
.action-button:hover {
cursor: pointer;
}
.action-button.primary {
border: 1px solid var(--affine-black-10);
background: var(--affine-primary-color);
color: var(--affine-pure-white);
}
.action-button > span {
display: flex;
align-items: center;
justify-content: center;
padding: 0 4px;
}
.action-button:not(.primary):hover {
background: var(--affine-hover-color);
}
}
ai-panel-divider {
margin-top: 4px;
}
.response-list-container {
display: flex;
flex-direction: column;
gap: 4px;
padding: 0 8px;
user-select: none;
}
.response-list-container ai-item-list {
--item-padding: 4px;
--item-icon-color: var(--affine-icon-secondary);
--item-icon-hover-color: var(--affine-icon-color);
}
`;
private readonly _getResponseGroup = () => {
let responseGroup: AIItemGroupConfig[] = [];
const errorType = this.config.error?.type;
if (errorType && errorType !== AIErrorType.GeneralNetworkError) {
return responseGroup;
}
responseGroup = filterAIItemGroup(this.host, this.config.responses);
return responseGroup;
};
override render() {
const responseGroup = this._getResponseGroup();
const errorTemplate = choose(
this.config.error?.type,
[
[
AIErrorType.Unauthorized,
() =>
html` <div class="error-info">
You need to login to AFFiNE Cloud to continue using AFFiNE AI.
</div>
<div class="action-button-group">
<div @click=${this.config.cancel} class="action-button">
<span>Cancel</span>
</div>
<div @click=${this.config.login} class="action-button primary">
<span>Login</span>
</div>
</div>`,
],
[
AIErrorType.PaymentRequired,
() =>
html` <div class="error-info">
You've reached the current usage cap for AFFiNE AI. You can
subscribe to AFFiNE AI to continue the AI experience!
</div>
<div class="action-button-group">
<div @click=${this.config.cancel} class="action-button">
<span>Cancel</span>
</div>
<div
@click=${this.config.upgrade}
class="action-button primary"
>
<span>Upgrade</span>
</div>
</div>`,
],
],
// default error handler
() => {
const tip = this.config.error?.message;
const error = tip
? html`<span class="error-tip"
>An error occurred<affine-tooltip
tip-position="bottom-start"
.arrow=${false}
>${tip}</affine-tooltip
></span
>`
: 'An error occurred';
return html`
<style>
.error-tip {
text-decoration: underline;
}
</style>
<div class="error-info">
${error}. Please try again later. If this issue persists, please let
us know at
<a href="mailto:support@toeverything.info">
support@toeverything.info
</a>
</div>
`;
}
);
return html`
<div class="error">
<div class="answer-tip">
<div class="answer-label">Answer</div>
<slot></slot>
</div>
${errorTemplate}
</div>
${this.withAnswer
? html`<ai-finish-tip
.copy=${this.copy}
.host=${this.host}
></ai-finish-tip>`
: nothing}
${responseGroup.length > 0
? html`
<ai-panel-divider></ai-panel-divider>
${responseGroup.map(
(group, index) => html`
${index !== 0
? html`<ai-panel-divider></ai-panel-divider>`
: nothing}
<div class="response-list-container">
<ai-item-list
.host=${this.host}
.groups=${[group]}
></ai-item-list>
</div>
`
)}
`
: nothing}
`;
}
@property({ attribute: false })
accessor config!: AIPanelErrorConfig;
@property({ attribute: false })
accessor copy: CopyConfig | undefined = undefined;
@property({ attribute: false })
accessor host!: EditorHost;
@property({ attribute: false })
accessor withAnswer = false;
}
declare global {
interface HTMLElementTagNameMap {
'ai-panel-error': AIPanelError;
}
}
@@ -0,0 +1,123 @@
import {
AIStarIconWithAnimation,
AIStopIcon,
} from '@blocksuite/affine-components/icons';
import type { ColorScheme } from '@blocksuite/affine-model';
import { WithDisposable } from '@blocksuite/global/utils';
import { baseTheme } from '@toeverything/theme';
import { css, html, LitElement, nothing, unsafeCSS } from 'lit';
import { property } from 'lit/decorators.js';
import type { AIPanelGeneratingConfig } from '../../type.js';
export class AIPanelGenerating extends WithDisposable(LitElement) {
static override styles = css`
:host {
width: 100%;
padding: 0 12px;
box-sizing: border-box;
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
}
.generating-tip {
display: flex;
width: 100%;
height: 22px;
align-items: center;
gap: 8px;
color: var(--affine-brand-color);
.text {
display: flex;
align-items: flex-start;
gap: 10px;
flex: 1 0 0;
/* light/smMedium */
font-size: var(--affine-font-sm);
font-style: normal;
font-weight: 500;
line-height: 22px; /* 157.143% */
}
.left,
.right {
display: flex;
height: 20px;
justify-content: center;
align-items: center;
}
.left {
width: 20px;
}
.right {
gap: 6px;
}
.right:hover {
cursor: pointer;
}
.stop-icon {
height: 20px;
width: 20px;
}
.esc-label {
font-size: var(--affine-font-xs);
font-weight: 500;
line-height: 20px;
}
}
`;
override render() {
const {
generatingIcon = AIStarIconWithAnimation,
stages,
height = 300,
} = this.config;
return html`
${stages && stages.length > 0
? html`<generating-placeholder
.height=${height}
.theme=${this.theme}
.loadingProgress=${this.loadingProgress}
.stages=${stages}
.showHeader=${!this.withAnswer}
></generating-placeholder>`
: nothing}
<div class="generating-tip">
<div class="left">${generatingIcon}</div>
<div class="text">AI is generating...</div>
<div @click=${this.stopGenerating} class="right">
<span class="stop-icon">${AIStopIcon}</span>
<span class="esc-label">ESC</span>
</div>
</div>
`;
}
updateLoadingProgress(progress: number) {
this.loadingProgress = progress;
}
@property({ attribute: false })
accessor config!: AIPanelGeneratingConfig;
@property({ attribute: false })
accessor loadingProgress: number = 1;
@property({ attribute: false })
accessor stopGenerating!: () => void;
@property({ attribute: false })
accessor theme!: ColorScheme;
@property({ attribute: false })
accessor withAnswer!: boolean;
}
declare global {
interface HTMLElementTagNameMap {
'ai-panel-generating': AIPanelGenerating;
}
}
@@ -0,0 +1,4 @@
export * from './answer.js';
export * from './error.js';
export * from './generating.js';
export * from './input.js';
@@ -0,0 +1,220 @@
import { AIStarIcon } from '@blocksuite/affine-components/icons';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { stopPropagation } from '@blocksuite/affine-shared/utils';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import { PublishIcon, SendIcon } from '@blocksuite/icons/lit';
import { css, html, LitElement, nothing } from 'lit';
import { property, query, state } from 'lit/decorators.js';
import type { AINetworkSearchConfig } from '../../type';
export class AIPanelInput extends SignalWatcher(WithDisposable(LitElement)) {
static override styles = css`
:host {
width: 100%;
padding: 0 12px;
box-sizing: border-box;
}
.root {
display: flex;
align-items: flex-start;
gap: 8px;
background: var(--affine-background-overlay-panel-color);
}
.star {
display: flex;
padding: 2px;
align-items: center;
}
.textarea-container {
display: flex;
align-items: flex-end;
gap: 8px;
flex: 1 0 0;
textarea {
flex: 1 0 0;
border: none;
outline: none;
-webkit-box-shadow: none;
-moz-box-shadow: none;
box-shadow: none;
background-color: transparent;
resize: none;
overflow: hidden;
padding: 0px;
color: var(--affine-text-primary-color);
/* light/sm */
font-family: var(--affine-font-family);
font-size: var(--affine-font-sm);
font-style: normal;
font-weight: 400;
line-height: 22px; /* 157.143% */
}
textarea::placeholder {
color: var(--affine-placeholder-color);
}
textarea::-moz-placeholder {
color: var(--affine-placeholder-color);
}
}
.arrow {
display: flex;
align-items: center;
padding: 2px;
gap: 4px;
border-radius: 4px;
background: ${unsafeCSSVarV2('icon/disable')};
svg {
width: 20px;
height: 20px;
color: ${unsafeCSSVarV2('button/pureWhiteText')};
}
}
.arrow[data-active] {
background: ${unsafeCSSVarV2('icon/activated')};
}
.arrow[data-active]:hover {
cursor: pointer;
}
.network {
display: flex;
align-items: center;
padding: 2px;
gap: 4px;
cursor: pointer;
svg {
width: 20px;
height: 20px;
color: ${unsafeCSSVarV2('icon/primary')};
}
}
.network[data-active='true'] svg {
color: ${unsafeCSSVarV2('icon/activated')};
}
`;
private readonly _onInput = () => {
this.textarea.style.height = 'auto';
this.textarea.style.height = this.textarea.scrollHeight + 'px';
this.onInput?.(this.textarea.value);
const value = this.textarea.value.trim();
if (value.length > 0) {
this._arrow.dataset.active = '';
this._hasContent = true;
} else {
delete this._arrow.dataset.active;
this._hasContent = false;
}
};
private readonly _onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
this._sendToAI(e);
}
};
private readonly _sendToAI = (e: MouseEvent | KeyboardEvent) => {
e.preventDefault();
e.stopPropagation();
const value = this.textarea.value.trim();
if (value.length === 0) return;
this.onFinish?.(value);
this.remove();
};
private readonly _toggleNetworkSearch = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const enable = this.networkSearchConfig.enabled.value;
this.networkSearchConfig.setEnabled(!enable);
};
override render() {
return html`<div class="root">
<div class="star">${AIStarIcon}</div>
<div class="textarea-container">
<textarea
placeholder="What are your thoughts?"
rows="1"
@keydown=${this._onKeyDown}
@input=${this._onInput}
@pointerdown=${stopPropagation}
@click=${stopPropagation}
@dblclick=${stopPropagation}
@cut=${stopPropagation}
@copy=${stopPropagation}
@paste=${stopPropagation}
@keyup=${stopPropagation}
></textarea>
${this.networkSearchConfig.visible.value
? html`
<div
class="network"
data-active=${!!this.networkSearchConfig.enabled.value}
@click=${this._toggleNetworkSearch}
@pointerdown=${stopPropagation}
>
${PublishIcon()}
<affine-tooltip .offset=${12}
>Toggle Network Search</affine-tooltip
>
</div>
`
: nothing}
<div
class="arrow"
@click=${this._sendToAI}
@pointerdown=${stopPropagation}
>
${SendIcon()}
${this._hasContent
? html`<affine-tooltip .offset=${12}>Send to AI</affine-tooltip>`
: nothing}
</div>
</div>
</div>`;
}
override updated(_changedProperties: Map<PropertyKey, unknown>): void {
const result = super.updated(_changedProperties);
this.textarea.style.height = this.textarea.scrollHeight + 'px';
return result;
}
@query('.arrow')
private accessor _arrow!: HTMLDivElement;
@state()
private accessor _hasContent = false;
@property({ attribute: false })
accessor networkSearchConfig!: AINetworkSearchConfig;
@property({ attribute: false })
accessor onFinish: ((input: string) => void) | undefined = undefined;
@property({ attribute: false })
accessor onInput: ((input: string) => void) | undefined = undefined;
@query('textarea')
accessor textarea!: HTMLTextAreaElement;
}
declare global {
interface HTMLElementTagNameMap {
'ai-panel-input': AIPanelInput;
}
}
@@ -0,0 +1,66 @@
import type {
AIError,
AIItemGroupConfig,
} from '@blocksuite/affine-components/ai-item';
import type { Signal } from '@preact/signals-core';
import type { nothing, TemplateResult } from 'lit';
export interface CopyConfig {
allowed: boolean;
onCopy: () => boolean | Promise<boolean>;
}
export interface AIPanelAnswerConfig {
responses: AIItemGroupConfig[];
actions: AIItemGroupConfig[];
}
export interface AIPanelErrorConfig {
login: () => void;
upgrade: () => void;
cancel: () => void;
responses: AIItemGroupConfig[];
error?: AIError;
}
export interface AIPanelGeneratingConfig {
generatingIcon: TemplateResult<1>;
height?: number;
stages?: string[];
}
export interface AINetworkSearchConfig {
visible: Signal<boolean | undefined>;
enabled: Signal<boolean | undefined>;
setEnabled: (state: boolean) => void;
}
export interface AffineAIPanelWidgetConfig {
answerRenderer: (
answer: string,
state?: AffineAIPanelState
) => TemplateResult<1> | typeof nothing;
generateAnswer?: (props: {
input: string;
update: (answer: string) => void;
finish: (type: 'success' | 'error' | 'aborted', err?: AIError) => void;
// Used to allow users to stop actively when generating
signal: AbortSignal;
}) => void;
finishStateConfig: AIPanelAnswerConfig;
generatingStateConfig: AIPanelGeneratingConfig;
errorStateConfig: AIPanelErrorConfig;
networkSearchConfig: AINetworkSearchConfig;
hideCallback?: () => void;
discardCallback?: () => void;
inputCallback?: (input: string) => void;
copy?: CopyConfig;
}
export type AffineAIPanelState =
| 'hidden'
| 'input'
| 'generating'
| 'finished'
| 'error';
@@ -0,0 +1,20 @@
import type { AIItemGroupConfig } from '@blocksuite/affine-components/ai-item';
import { isInsidePageEditor } from '@blocksuite/affine-shared/utils';
import type { EditorHost } from '@blocksuite/block-std';
export function filterAIItemGroup(
host: EditorHost,
configs: AIItemGroupConfig[]
): AIItemGroupConfig[] {
const editorMode = isInsidePageEditor(host) ? 'page' : 'edgeless';
return configs
.map(group => ({
...group,
items: group.items.filter(item =>
item.showWhen
? item.showWhen(host.command.chain(), editorMode, host)
: true
),
}))
.filter(group => group.items.length > 0);
}
@@ -0,0 +1,97 @@
import type { AIItemGroupConfig } from '@blocksuite/affine-components/ai-item';
import { scrollbarStyle } from '@blocksuite/affine-shared/styles';
import { on, stopPropagation } from '@blocksuite/affine-shared/utils';
import type { EditorHost } from '@blocksuite/block-std';
import { WithDisposable } from '@blocksuite/global/utils';
import { css, html, LitElement, nothing } from 'lit';
import { property } from 'lit/decorators.js';
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
export class EdgelessCopilotPanel extends WithDisposable(LitElement) {
static override styles = css`
:host {
display: flex;
position: absolute;
}
.edgeless-copilot-panel {
box-sizing: border-box;
padding: 8px 4px 8px 8px;
min-width: 330px;
max-height: 374px;
overflow-y: auto;
background: var(--affine-background-overlay-panel-color);
box-shadow: var(--affine-shadow-2);
border-radius: 8px;
z-index: var(--affine-z-index-popover);
}
${scrollbarStyle('.edgeless-copilot-panel')}
.edgeless-copilot-panel:hover::-webkit-scrollbar-thumb {
background-color: var(--affine-black-30);
}
`;
private _getChain() {
return this.edgeless.service.std.command.chain();
}
override connectedCallback(): void {
super.connectedCallback();
this._disposables.add(on(this, 'wheel', stopPropagation));
this._disposables.add(on(this, 'pointerdown', stopPropagation));
}
hide() {
this.remove();
}
override render() {
const chain = this._getChain();
const groups = this.groups.reduce((pre, group) => {
const filtered = group.items.filter(item =>
item.showWhen?.(chain, 'edgeless', this.host)
);
if (filtered.length > 0) pre.push({ ...group, items: filtered });
return pre;
}, [] as AIItemGroupConfig[]);
if (groups.every(group => group.items.length === 0)) return nothing;
return html`
<div class="edgeless-copilot-panel">
<ai-item-list
.onClick=${() => {
this.onClick?.();
}}
.host=${this.host}
.groups=${groups}
></ai-item-list>
</div>
`;
}
@property({ attribute: false })
accessor edgeless!: EdgelessRootBlockComponent;
@property({ attribute: false })
accessor entry: 'toolbar' | 'selection' | undefined = undefined;
@property({ attribute: false })
accessor groups!: AIItemGroupConfig[];
@property({ attribute: false })
accessor host!: EditorHost;
@property({ attribute: false })
accessor onClick: (() => void) | undefined = undefined;
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-copilot-panel': EdgelessCopilotPanel;
}
}
@@ -0,0 +1,75 @@
import type { AIItemGroupConfig } from '@blocksuite/affine-components/ai-item';
import { AIStarIcon } from '@blocksuite/affine-components/icons';
import type { EditorHost } from '@blocksuite/block-std';
import { isGfxGroupCompatibleModel } from '@blocksuite/block-std/gfx';
import { WithDisposable } from '@blocksuite/global/utils';
import { css, html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
import type { CopilotTool } from '../../edgeless/gfx-tool/copilot-tool.js';
import { sortEdgelessElements } from '../../edgeless/utils/clone-utils.js';
export class EdgelessCopilotToolbarEntry extends WithDisposable(LitElement) {
static override styles = css`
.copilot-icon-button {
line-height: 20px;
.label.medium {
color: var(--affine-brand-color);
}
}
`;
private readonly _onClick = () => {
this.onClick?.();
this._showCopilotPanel();
};
private _showCopilotPanel() {
const selectedElements = sortEdgelessElements(
this.edgeless.service.selection.selectedElements
);
const toBeSelected = new Set(selectedElements);
selectedElements.forEach(element => {
// its descendants are already selected
if (toBeSelected.has(element)) return;
toBeSelected.add(element);
if (isGfxGroupCompatibleModel(element)) {
element.descendantElements.forEach(descendant => {
toBeSelected.add(descendant);
});
}
});
this.edgeless.gfx.tool.setTool('copilot');
(
this.edgeless.gfx.tool.currentTool$.peek() as CopilotTool
).updateSelectionWith(Array.from(toBeSelected), 10);
}
override render() {
return html`<edgeless-tool-icon-button
aria-label="Ask AI"
class="copilot-icon-button"
@click=${this._onClick}
>
${AIStarIcon} <span class="label medium">Ask AI</span>
</edgeless-tool-icon-button>`;
}
@property({ attribute: false })
accessor edgeless!: EdgelessRootBlockComponent;
@property({ attribute: false })
accessor groups!: AIItemGroupConfig[];
@property({ attribute: false })
accessor host!: EditorHost;
@property({ attribute: false })
accessor onClick: (() => void) | undefined = undefined;
}
@@ -0,0 +1,299 @@
import type { AIItemGroupConfig } from '@blocksuite/affine-components/ai-item';
import type { RootBlockModel } from '@blocksuite/affine-model';
import {
MOUSE_BUTTON,
requestConnectedFrame,
} from '@blocksuite/affine-shared/utils';
import { WidgetComponent } from '@blocksuite/block-std';
import { Bound, getCommonBoundWithRotation } from '@blocksuite/global/utils';
import {
autoUpdate,
computePosition,
flip,
offset,
shift,
size,
} from '@floating-ui/dom';
import { effect } from '@preact/signals-core';
import { css, html, nothing } from 'lit';
import { query, state } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
import {
AFFINE_AI_PANEL_WIDGET,
AffineAIPanelWidget,
} from '../ai-panel/ai-panel.js';
import { EdgelessCopilotPanel } from '../edgeless-copilot-panel/index.js';
export const AFFINE_EDGELESS_COPILOT_WIDGET = 'affine-edgeless-copilot-widget';
export class EdgelessCopilotWidget extends WidgetComponent<
RootBlockModel,
EdgelessRootBlockComponent
> {
static override styles = css`
.copilot-selection-rect {
position: absolute;
box-sizing: border-box;
border-radius: 4px;
border: 2px dashed var(--affine-brand-color, #1e96eb);
}
`;
private _clickOutsideOff: (() => void) | null = null;
private _copilotPanel!: EdgelessCopilotPanel | null;
private _listenClickOutsideId: number | null = null;
private _selectionModelRect!: DOMRect;
groups: AIItemGroupConfig[] = [];
get edgeless() {
return this.block;
}
get selectionModelRect() {
return this._selectionModelRect;
}
get selectionRect() {
return this._selectionRect;
}
get visible() {
return !!(
this._visible &&
this._selectionRect.width &&
this._selectionRect.height
);
}
set visible(visible: boolean) {
this._visible = visible;
}
private _showCopilotPanel() {
requestConnectedFrame(() => {
if (!this._copilotPanel) {
const panel = new EdgelessCopilotPanel();
panel.host = this.host;
panel.groups = this.groups;
panel.edgeless = this.edgeless;
this.renderRoot.append(panel);
this._copilotPanel = panel;
}
const referenceElement = this.selectionElem;
const panel = this._copilotPanel;
// @TODO: optimize
const viewport = this.edgeless.service.viewport;
if (!referenceElement || !referenceElement.isConnected) return;
// show ai input
const rootBlockId = this.host.doc.root?.id;
if (rootBlockId) {
const aiPanel = this.host.view.getWidget(
AFFINE_AI_PANEL_WIDGET,
rootBlockId
);
if (aiPanel instanceof AffineAIPanelWidget && aiPanel.config) {
aiPanel.setState('input', referenceElement);
}
}
autoUpdate(referenceElement, panel, () => {
computePosition(referenceElement, panel, {
placement: 'right-start',
middleware: [
offset({
mainAxis: 16,
crossAxis: 45,
}),
flip({
mainAxis: true,
crossAxis: true,
flipAlignment: true,
}),
shift(() => {
const { left, top, width, height } = viewport;
return {
padding: 20,
crossAxis: true,
rootBoundary: {
x: left,
y: top,
width,
height: height - 100,
},
};
}),
size({
apply: ({ elements }) => {
const { height } = viewport;
elements.floating.style.maxHeight = `${height - 140}px`;
},
}),
],
})
.then(({ x, y }) => {
panel.style.left = `${x}px`;
panel.style.top = `${y}px`;
})
.catch(e => {
console.warn("Can't compute EdgelessCopilotPanel position", e);
});
});
}, this);
}
private _updateSelection(rect: DOMRect) {
this._selectionModelRect = rect;
const zoom = this.edgeless.service.viewport.zoom;
const [x, y] = this.edgeless.service.viewport.toViewCoord(
rect.left,
rect.top
);
const [width, height] = [rect.width * zoom, rect.height * zoom];
this._selectionRect = { x, y, width, height };
}
private _watchClickOutside() {
this._clickOutsideOff?.();
const { width, height } = this._selectionRect;
if (width && height) {
this._listenClickOutsideId &&
cancelAnimationFrame(this._listenClickOutsideId);
this._listenClickOutsideId = requestConnectedFrame(() => {
if (!this.isConnected) {
return;
}
const off = this.block.dispatcher.add('pointerDown', ctx => {
const e = ctx.get('pointerState').raw;
if (
e.button === MOUSE_BUTTON.MAIN &&
!this.contains(e.target as HTMLElement)
) {
off();
this._visible = false;
this.hideCopilotPanel();
}
});
this._listenClickOutsideId = null;
this._clickOutsideOff = off;
}, this);
}
}
override connectedCallback(): void {
super.connectedCallback();
const CopilotSelectionTool = this.edgeless.gfx.tool.get('copilot');
this._disposables.add(
CopilotSelectionTool.draggingAreaUpdated.on(shouldShowPanel => {
this._visible = true;
this._updateSelection(CopilotSelectionTool.area);
if (shouldShowPanel) {
this._showCopilotPanel();
this._watchClickOutside();
} else {
this.hideCopilotPanel();
}
})
);
this._disposables.add(
this.edgeless.service.viewport.viewportUpdated.on(() => {
if (!this._visible) return;
this._updateSelection(CopilotSelectionTool.area);
})
);
this._disposables.add(
effect(() => {
const currentTool = this.edgeless.gfx.tool.currentToolName$.value;
if (!this._visible || currentTool === 'copilot') return;
this._visible = false;
this._clickOutsideOff = null;
this._copilotPanel?.remove();
this._copilotPanel = null;
})
);
}
determineInsertionBounds(width = 800, height = 95) {
const elements = this.edgeless.service.selection.selectedElements;
const offsetY = 20 / this.edgeless.service.viewport.zoom;
const bounds = new Bound(0, 0, width, height);
if (elements.length) {
const { x, y, h } = getCommonBoundWithRotation(elements);
bounds.x = x;
bounds.y = y + h + offsetY;
} else {
const { x, y, height: h } = this.selectionModelRect;
bounds.x = x;
bounds.y = y + h + offsetY;
}
return bounds;
}
hideCopilotPanel() {
this._copilotPanel?.hide();
this._copilotPanel = null;
this._clickOutsideOff = null;
}
lockToolbar(disabled: boolean) {
this.edgeless.slots.toolbarLocked.emit(disabled);
}
override render() {
if (!this._visible) return nothing;
const rect = this._selectionRect;
return html`<div class="affine-edgeless-ai">
<div
class="copilot-selection-rect"
style=${styleMap({
left: `${rect.x}px`,
top: `${rect.y}px`,
width: `${rect.width}px`,
height: `${rect.height}px`,
})}
></div>
</div>`;
}
@state()
private accessor _selectionRect: {
x: number;
y: number;
width: number;
height: number;
} = { x: 0, y: 0, width: 0, height: 0 };
@state()
private accessor _visible = false;
@query('.copilot-selection-rect')
accessor selectionElem!: HTMLDivElement;
}
declare global {
interface HTMLElementTagNameMap {
[AFFINE_EDGELESS_COPILOT_WIDGET]: EdgelessCopilotWidget;
}
}
@@ -0,0 +1,95 @@
import { EdgelessLegacySlotIdentifier } from '@blocksuite/affine-block-surface';
import type { RootBlockModel } from '@blocksuite/affine-model';
import { WidgetComponent } from '@blocksuite/block-std';
import { effect } from '@preact/signals-core';
import { css, html, nothing } from 'lit';
import { state } from 'lit/decorators.js';
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
export const AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET =
'affine-edgeless-zoom-toolbar-widget';
export class AffineEdgelessZoomToolbarWidget extends WidgetComponent<
RootBlockModel,
EdgelessRootBlockComponent
> {
static override styles = css`
:host {
position: absolute;
bottom: 20px;
left: 12px;
z-index: var(--affine-z-index-popover);
display: flex;
justify-content: center;
-webkit-user-select: none;
user-select: none;
}
@container viewport (width <= 1200px) {
edgeless-zoom-toolbar {
display: none;
}
}
@container viewport (width > 1200px) {
zoom-bar-toggle-button {
display: none;
}
}
`;
get edgeless() {
return this.block;
}
override connectedCallback() {
super.connectedCallback();
this.disposables.add(
effect(() => {
const currentTool = this.edgeless.gfx.tool.currentToolName$.value;
if (currentTool !== 'frameNavigator') {
this._hide = false;
}
this.requestUpdate();
})
);
}
override firstUpdated() {
const { disposables, std } = this;
const slots = std.get(EdgelessLegacySlotIdentifier);
disposables.add(
slots.navigatorSettingUpdated.on(({ hideToolbar }) => {
if (hideToolbar !== undefined) {
this._hide = hideToolbar;
}
})
);
}
override render() {
if (this._hide || !this.edgeless) {
return nothing;
}
return html`
<edgeless-zoom-toolbar .edgeless=${this.edgeless}></edgeless-zoom-toolbar>
<zoom-bar-toggle-button
.edgeless=${this.edgeless}
></zoom-bar-toggle-button>
`;
}
@state()
private accessor _hide = false;
}
declare global {
interface HTMLElementTagNameMap {
[AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET]: AffineEdgelessZoomToolbarWidget;
}
}
@@ -0,0 +1,115 @@
import { createLitPortal } from '@blocksuite/affine-components/portal';
import { stopPropagation } from '@blocksuite/affine-shared/utils';
import { WithDisposable } from '@blocksuite/global/utils';
import { MoreHorizontalIcon } from '@blocksuite/icons/lit';
import { offset } from '@floating-ui/dom';
import { css, html, LitElement, nothing } from 'lit';
import { property, query, state } from 'lit/decorators.js';
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
export class ZoomBarToggleButton extends WithDisposable(LitElement) {
static override styles = css`
:host {
display: flex;
}
.toggle-button {
display: flex;
position: relative;
}
edgeless-zoom-toolbar {
position: absolute;
bottom: initial;
}
`;
private _abortController: AbortController | null = null;
private _closeZoomMenu() {
if (this._abortController && !this._abortController.signal.aborted) {
this._abortController.abort();
this._abortController = null;
this._showPopper = false;
}
}
private _toggleZoomMenu() {
if (this._abortController && !this._abortController.signal.aborted) {
this._closeZoomMenu();
return;
}
this._abortController = new AbortController();
this._abortController.signal.addEventListener('abort', () => {
this._showPopper = false;
});
createLitPortal({
template: html`<edgeless-zoom-toolbar
.edgeless=${this.edgeless}
.layout=${'vertical'}
></edgeless-zoom-toolbar>`,
container: this._toggleButton,
computePosition: {
referenceElement: this._toggleButton,
placement: 'top',
middleware: [offset(4)],
autoUpdate: true,
},
abortController: this._abortController,
closeOnClickAway: true,
});
this._showPopper = true;
}
override disconnectedCallback() {
super.disconnectedCallback();
this._closeZoomMenu();
}
override firstUpdated() {
const { disposables } = this;
disposables.add(
this.edgeless.slots.readonlyUpdated.on(() => {
this.requestUpdate();
})
);
}
override render() {
if (this.edgeless.doc.readonly) {
return nothing;
}
return html`
<div class="toggle-button" @pointerdown=${stopPropagation}>
<edgeless-tool-icon-button
.tooltip=${'Toggle Zoom Tool Bar'}
.tipPosition=${'right'}
.active=${this._showPopper}
.arrow=${false}
.activeMode=${'background'}
.iconContainerPadding=${6}
.iconSize=${'24px'}
@click=${() => this._toggleZoomMenu()}
>
${MoreHorizontalIcon()}
</edgeless-tool-icon-button>
</div>
`;
}
@state()
private accessor _showPopper = false;
@query('.toggle-button')
private accessor _toggleButton!: HTMLElement;
@property({ attribute: false })
accessor edgeless!: EdgelessRootBlockComponent;
}
declare global {
interface HTMLElementTagNameMap {
'zoom-bar-toggle-button': ZoomBarToggleButton;
}
}
@@ -0,0 +1,216 @@
import { stopPropagation } from '@blocksuite/affine-shared/utils';
import { ZOOM_STEP } from '@blocksuite/block-std/gfx';
import { WithDisposable } from '@blocksuite/global/utils';
import { MinusIcon, PlusIcon, ViewBarIcon } from '@blocksuite/icons/lit';
import { effect } from '@preact/signals-core';
import { baseTheme } from '@toeverything/theme';
import { css, html, LitElement, nothing, unsafeCSS } from 'lit';
import { property } from 'lit/decorators.js';
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
export class EdgelessZoomToolbar extends WithDisposable(LitElement) {
static override styles = css`
:host {
display: flex;
}
.edgeless-zoom-toolbar-container {
display: flex;
align-items: center;
background: transparent;
border-radius: 8px;
fill: currentcolor;
padding: 4px;
}
.edgeless-zoom-toolbar-container.horizantal {
flex-direction: row;
}
.edgeless-zoom-toolbar-container.vertical {
flex-direction: column;
width: 40px;
background-color: var(--affine-background-overlay-panel-color);
box-shadow: var(--affine-shadow-2);
border: 1px solid var(--affine-border-color);
border-radius: 8px;
}
.edgeless-zoom-toolbar-container[level='second'] {
position: absolute;
bottom: 8px;
transform: translateY(-100%);
}
.edgeless-zoom-toolbar-container[hidden] {
display: none;
}
.zoom-percent {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 32px;
border: none;
box-sizing: border-box;
padding: 4px;
color: var(--affine-icon-color);
background-color: transparent;
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
font-size: 12px;
font-weight: 500;
text-align: center;
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
}
.zoom-percent:hover {
color: var(--affine-primary-color);
background-color: var(--affine-hover-color);
}
.zoom-percent[disabled] {
pointer-events: none;
cursor: not-allowed;
color: var(--affine-text-disable-color);
}
`;
get edgelessService() {
return this.edgeless.service;
}
get gfx() {
return this.edgeless.gfx;
}
get edgelessTool() {
return this.edgeless.gfx.tool.currentToolOption$.peek();
}
get locked() {
return this.edgelessService.locked;
}
get viewport() {
return this.edgelessService.viewport;
}
get zoom() {
if (!this.viewport) {
console.error('Something went wrong, viewport is not available');
return 1;
}
return this.viewport.zoom;
}
constructor(edgeless: EdgelessRootBlockComponent) {
super();
this.edgeless = edgeless;
}
private _isVerticalBar() {
return this.layout === 'vertical';
}
override connectedCallback() {
super.connectedCallback();
this.disposables.add(
effect(() => {
this.edgeless.gfx.tool.currentToolName$.value;
this.requestUpdate();
})
);
}
override firstUpdated() {
const { disposables } = this;
disposables.add(
this.edgeless.service.viewport.viewportUpdated.on(() =>
this.requestUpdate()
)
);
disposables.add(
this.edgeless.slots.readonlyUpdated.on(() => {
this.requestUpdate();
})
);
}
override render() {
if (this.edgeless.doc.readonly) {
return nothing;
}
const formattedZoom = `${Math.round(this.zoom * 100)}%`;
const classes = `edgeless-zoom-toolbar-container ${this.layout}`;
const locked = this.locked;
return html`
<div
class=${classes}
@dblclick=${stopPropagation}
@mousedown=${stopPropagation}
@mouseup=${stopPropagation}
@pointerdown=${stopPropagation}
>
<edgeless-tool-icon-button
.tooltip=${'Fit to screen'}
.tipPosition=${this._isVerticalBar() ? 'right' : 'top-end'}
.arrow=${!this._isVerticalBar()}
@click=${() => this.gfx.fitToScreen()}
.iconContainerPadding=${4}
.iconSize=${'24px'}
.disabled=${locked}
>
${ViewBarIcon()}
</edgeless-tool-icon-button>
<edgeless-tool-icon-button
.tooltip=${'Zoom out'}
.tipPosition=${this._isVerticalBar() ? 'right' : 'top'}
.arrow=${!this._isVerticalBar()}
@click=${() => this.edgelessService.setZoomByStep(-ZOOM_STEP)}
.iconContainerPadding=${4}
.iconSize=${'24px'}
.disabled=${locked}
>
${MinusIcon()}
</edgeless-tool-icon-button>
<button
class="zoom-percent"
@click=${() => this.viewport.smoothZoom(1)}
.disabled=${locked}
>
${formattedZoom}
</button>
<edgeless-tool-icon-button
.tooltip=${'Zoom in'}
.tipPosition=${this._isVerticalBar() ? 'right' : 'top'}
.arrow=${!this._isVerticalBar()}
@click=${() => this.edgelessService.setZoomByStep(ZOOM_STEP)}
.iconContainerPadding=${4}
.iconSize=${'24px'}
.disabled=${locked}
>
${PlusIcon()}
</edgeless-tool-icon-button>
</div>
`;
}
@property({ attribute: false })
accessor edgeless: EdgelessRootBlockComponent;
@property({ attribute: false })
accessor layout: 'horizontal' | 'vertical' = 'horizontal';
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-zoom-toolbar': EdgelessZoomToolbar;
}
}
@@ -0,0 +1,64 @@
import { MindmapElementModel } from '@blocksuite/affine-model';
import { TelemetryProvider } from '@blocksuite/affine-shared/services';
import type { GfxModel } from '@blocksuite/block-std/gfx';
import { Bound, WithDisposable } from '@blocksuite/global/utils';
import { FrameIcon } from '@blocksuite/icons/lit';
import { css, html, LitElement, nothing } from 'lit';
import { property } from 'lit/decorators.js';
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
export class EdgelessAddFrameButton extends WithDisposable(LitElement) {
static override styles = css`
.label {
padding-left: 4px;
}
`;
private readonly _createFrame = () => {
const frame = this.edgeless.service.frame.createFrameOnSelected();
if (!frame) return;
this.edgeless.std
.getOptional(TelemetryProvider)
?.track('CanvasElementAdded', {
control: 'context-menu',
page: 'whiteboard editor',
module: 'toolbar',
segment: 'toolbar',
type: 'frame',
});
this.edgeless.surface.fitToViewport(Bound.deserialize(frame.xywh));
};
protected override render() {
return html`
<editor-icon-button
aria-label="Frame"
.tooltip=${'Frame'}
.labelHeight=${'20px'}
.iconSize=${'20px'}
@click=${this._createFrame}
>
${FrameIcon()}<span class="label medium">Frame</span>
</editor-icon-button>
`;
}
@property({ attribute: false })
accessor edgeless!: EdgelessRootBlockComponent;
}
export function renderAddFrameButton(
edgeless: EdgelessRootBlockComponent,
elements: GfxModel[]
) {
if (elements.length < 2) return nothing;
if (elements.some(e => e.group instanceof MindmapElementModel))
return nothing;
return html`
<edgeless-add-frame-button
.edgeless=${edgeless}
></edgeless-add-frame-button>
`;
}
@@ -0,0 +1,56 @@
import {
GroupElementModel,
MindmapElementModel,
} from '@blocksuite/affine-model';
import type { GfxModel } from '@blocksuite/block-std/gfx';
import { WithDisposable } from '@blocksuite/global/utils';
import { GroupingIcon } from '@blocksuite/icons/lit';
import { css, html, LitElement, nothing } from 'lit';
import { property } from 'lit/decorators.js';
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
export class EdgelessAddGroupButton extends WithDisposable(LitElement) {
static override styles = css`
.label {
padding-left: 4px;
}
`;
private readonly _createGroup = () => {
this.edgeless.service.createGroupFromSelected();
};
protected override render() {
return html`
<editor-icon-button
aria-label="Group"
.tooltip=${'Group'}
.labelHeight=${'20px'}
.iconSize=${'20px'}
@click=${this._createGroup}
>
${GroupingIcon()}<span class="label medium">Group</span>
</editor-icon-button>
`;
}
@property({ attribute: false })
accessor edgeless!: EdgelessRootBlockComponent;
}
export function renderAddGroupButton(
edgeless: EdgelessRootBlockComponent,
elements: GfxModel[]
) {
if (elements.length < 2) return nothing;
if (elements[0] instanceof GroupElementModel) return nothing;
if (elements.some(e => e.group instanceof MindmapElementModel))
return nothing;
return html`
<edgeless-add-group-button
.edgeless=${edgeless}
></edgeless-add-group-button>
`;
}
@@ -0,0 +1,349 @@
import {
autoArrangeElementsCommand,
autoResizeElementsCommand,
EdgelessCRUDIdentifier,
updateXYWH,
} from '@blocksuite/affine-block-surface';
import { MindmapElementModel } from '@blocksuite/affine-model';
import type { GfxModel } from '@blocksuite/block-std/gfx';
import { Bound, WithDisposable } from '@blocksuite/global/utils';
import {
AlignBottomIcon,
AlignHorizontalCenterIcon,
AlignLeftIcon,
AlignRightIcon,
AlignTopIcon,
AlignVerticalCenterIcon,
AutoTidyUpIcon,
DistributeHorizontalIcon,
DistributeVerticalIcon,
ResizeTidyUpIcon,
} from '@blocksuite/icons/lit';
import { css, html, LitElement, nothing, type TemplateResult } from 'lit';
import { property } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
import { SmallArrowDownIcon } from './icons.js';
const enum Alignment {
AutoArrange = 'Auto arrange',
AutoResize = 'Resize & Align',
Bottom = 'Align bottom',
DistributeHorizontally = 'Distribute horizontally',
DistributeVertically = 'Distribute vertically',
Horizontally = 'Align horizontally',
Left = 'Align left',
Right = 'Align right',
Top = 'Align top',
Vertically = 'Align vertically',
}
interface AlignmentIcon {
name: Alignment;
content: TemplateResult<1>;
}
const iconSize = { width: '20px', height: '20px' };
const HORIZONTAL_ALIGNMENT: AlignmentIcon[] = [
{
name: Alignment.Left,
content: AlignLeftIcon(iconSize),
},
{
name: Alignment.Horizontally,
content: AlignHorizontalCenterIcon(iconSize),
},
{
name: Alignment.Right,
content: AlignRightIcon(iconSize),
},
{
name: Alignment.DistributeHorizontally,
content: DistributeHorizontalIcon(iconSize),
},
];
const VERTICAL_ALIGNMENT: AlignmentIcon[] = [
{
name: Alignment.Top,
content: AlignTopIcon(iconSize),
},
{
name: Alignment.Vertically,
content: AlignVerticalCenterIcon(iconSize),
},
{
name: Alignment.Bottom,
content: AlignBottomIcon(iconSize),
},
{
name: Alignment.DistributeVertically,
content: DistributeVerticalIcon(iconSize),
},
];
const AUTO_ALIGNMENT: AlignmentIcon[] = [
{
name: Alignment.AutoArrange,
content: AutoTidyUpIcon({ width: '20px', height: '20px' }),
},
{
name: Alignment.AutoResize,
content: ResizeTidyUpIcon({ width: '20px', height: '20px' }),
},
];
export class EdgelessAlignButton extends WithDisposable(LitElement) {
static override styles = css`
.align-menu-content {
max-width: 120px;
flex-wrap: wrap;
padding: 8px 2px;
}
.align-menu-separator {
width: 120px;
height: 1px;
background-color: var(--affine-background-tertiary-color);
}
`;
private get elements() {
return this.edgeless.service.selection.selectedElements;
}
private _align(type: Alignment) {
switch (type) {
case Alignment.Left:
this._alignLeft();
break;
case Alignment.Horizontally:
this._alignHorizontally();
break;
case Alignment.Right:
this._alignRight();
break;
case Alignment.DistributeHorizontally:
this._alignDistributeHorizontally();
break;
case Alignment.Top:
this._alignTop();
break;
case Alignment.Vertically:
this._alignVertically();
break;
case Alignment.Bottom:
this._alignBottom();
break;
case Alignment.DistributeVertically:
this._alignDistributeVertically();
break;
case Alignment.AutoArrange:
this.edgeless.std.command.exec(autoArrangeElementsCommand);
break;
case Alignment.AutoResize:
this.edgeless.std.command.exec(autoResizeElementsCommand);
break;
}
}
private _alignBottom() {
const { elements } = this;
const bounds = elements.map(a => a.elementBound);
const bottom = Math.max(...bounds.map(b => b.maxY));
elements.forEach((ele, index) => {
const elementBound = bounds[index];
const bound = Bound.deserialize(ele.xywh);
const offset = bound.maxY - elementBound.maxY;
bound.y = bottom - bound.h + offset;
this._updateXYWH(ele, bound);
});
}
private _alignDistributeHorizontally() {
const { elements } = this;
elements.sort((a, b) => a.elementBound.minX - b.elementBound.minX);
const bounds = elements.map(a => a.elementBound);
const left = bounds[0].minX;
const right = bounds[bounds.length - 1].maxX;
const totalWidth = right - left;
const totalGap =
totalWidth - elements.reduce((prev, ele) => prev + ele.elementBound.w, 0);
const gap = totalGap / (elements.length - 1);
let next = bounds[0].maxX + gap;
for (let i = 1; i < elements.length - 1; i++) {
const bound = Bound.deserialize(elements[i].xywh);
bound.x = next + bounds[i].w / 2 - bound.w / 2;
next += gap + bounds[i].w;
this._updateXYWH(elements[i], bound);
}
}
private _alignDistributeVertically() {
const { elements } = this;
elements.sort((a, b) => a.elementBound.minY - b.elementBound.minY);
const bounds = elements.map(a => a.elementBound);
const top = bounds[0].minY;
const bottom = bounds[bounds.length - 1].maxY;
const totalHeight = bottom - top;
const totalGap =
totalHeight -
elements.reduce((prev, ele) => prev + ele.elementBound.h, 0);
const gap = totalGap / (elements.length - 1);
let next = bounds[0].maxY + gap;
for (let i = 1; i < elements.length - 1; i++) {
const bound = Bound.deserialize(elements[i].xywh);
bound.y = next + bounds[i].h / 2 - bound.h / 2;
next += gap + bounds[i].h;
this._updateXYWH(elements[i], bound);
}
}
private _alignHorizontally() {
const { elements } = this;
const bounds = elements.map(a => a.elementBound);
const left = Math.min(...bounds.map(b => b.minX));
const right = Math.max(...bounds.map(b => b.maxX));
const centerX = (left + right) / 2;
elements.forEach(ele => {
const bound = Bound.deserialize(ele.xywh);
bound.x = centerX - bound.w / 2;
this._updateXYWH(ele, bound);
});
}
private _alignLeft() {
const { elements } = this;
const bounds = elements.map(a => a.elementBound);
const left = Math.min(...bounds.map(b => b.minX));
elements.forEach((ele, index) => {
const elementBound = bounds[index];
const bound = Bound.deserialize(ele.xywh);
const offset = bound.minX - elementBound.minX;
bound.x = left + offset;
this._updateXYWH(ele, bound);
});
}
private _alignRight() {
const { elements } = this;
const bounds = elements.map(a => a.elementBound);
const right = Math.max(...bounds.map(b => b.maxX));
elements.forEach((ele, index) => {
const elementBound = bounds[index];
const bound = Bound.deserialize(ele.xywh);
const offset = bound.maxX - elementBound.maxX;
bound.x = right - bound.w + offset;
this._updateXYWH(ele, bound);
});
}
private _alignTop() {
const { elements } = this;
const bounds = elements.map(a => a.elementBound);
const top = Math.min(...bounds.map(b => b.minY));
elements.forEach((ele, index) => {
const elementBound = bounds[index];
const bound = Bound.deserialize(ele.xywh);
const offset = bound.minY - elementBound.minY;
bound.y = top + offset;
this._updateXYWH(ele, bound);
});
}
private _alignVertically() {
const { elements } = this;
const bounds = elements.map(a => a.elementBound);
const top = Math.min(...bounds.map(b => b.minY));
const bottom = Math.max(...bounds.map(b => b.maxY));
const centerY = (top + bottom) / 2;
elements.forEach(ele => {
const bound = Bound.deserialize(ele.xywh);
bound.y = centerY - bound.h / 2;
this._updateXYWH(ele, bound);
});
}
private _updateXYWH(ele: GfxModel, bound: Bound) {
const { updateElement } = this.edgeless.std.get(EdgelessCRUDIdentifier);
const { updateBlock } = this.edgeless.doc;
updateXYWH(ele, bound, updateElement, updateBlock);
}
private renderIcons(icons: AlignmentIcon[]) {
return html`
${repeat(
icons,
(item, index) => item.name + index,
({ name, content }) => {
return html`
<editor-icon-button
aria-label=${name}
.tooltip=${name}
.iconSize=${'20px'}
@click=${() => this._align(name)}
>
${content}
</editor-icon-button>
`;
}
)}
`;
}
override firstUpdated() {
this._disposables.add(
this.edgeless.service.selection.slots.updated.on(() =>
this.requestUpdate()
)
);
}
override render() {
return html`
<editor-menu-button
.button=${html`
<editor-icon-button
aria-label="Align objects"
.tooltip=${'Align objects'}
>
${AlignLeftIcon(iconSize)}${SmallArrowDownIcon}
</editor-icon-button>
`}
>
<div class="align-menu-content">
${this.renderIcons(HORIZONTAL_ALIGNMENT)}
${this.renderIcons(VERTICAL_ALIGNMENT)}
<div class="align-menu-separator"></div>
${this.renderIcons(AUTO_ALIGNMENT)}
</div>
</editor-menu-button>
`;
}
@property({ attribute: false })
accessor edgeless!: EdgelessRootBlockComponent;
}
export function renderAlignButton(
edgeless: EdgelessRootBlockComponent,
elements: GfxModel[]
) {
if (elements.length < 2) return nothing;
if (elements.some(e => e.group instanceof MindmapElementModel))
return nothing;
return html`
<edgeless-align-button .edgeless=${edgeless}></edgeless-align-button>
`;
}
@@ -0,0 +1,163 @@
import {
type AttachmentBlockComponent,
attachmentViewToggleMenu,
} from '@blocksuite/affine-block-attachment';
import { getEmbedCardIcons } from '@blocksuite/affine-block-embed';
import {
CaptionIcon,
DownloadIcon,
PaletteIcon,
} from '@blocksuite/affine-components/icons';
import { renderToolbarSeparator } from '@blocksuite/affine-components/toolbar';
import type {
AttachmentBlockModel,
EmbedCardStyle,
} from '@blocksuite/affine-model';
import {
EMBED_CARD_HEIGHT,
EMBED_CARD_WIDTH,
} from '@blocksuite/affine-shared/consts';
import { ThemeProvider } from '@blocksuite/affine-shared/services';
import { Bound, WithDisposable } from '@blocksuite/global/utils';
import type { TemplateResult } from 'lit';
import { html, LitElement, nothing } from 'lit';
import { property } from 'lit/decorators.js';
import { join } from 'lit/directives/join.js';
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
export class EdgelessChangeAttachmentButton extends WithDisposable(LitElement) {
private readonly _download = () => {
this._block?.download();
};
private readonly _setCardStyle = (style: EmbedCardStyle) => {
const bounds = Bound.deserialize(this.model.xywh);
bounds.w = EMBED_CARD_WIDTH[style];
bounds.h = EMBED_CARD_HEIGHT[style];
const xywh = bounds.serialize();
this.model.doc.updateBlock(this.model, { style, xywh });
};
private readonly _showCaption = () => {
this._block?.captionEditor?.show();
};
private get _block() {
const block = this.std.view.getBlock(this.model.id);
if (!block) return null;
return block as AttachmentBlockComponent;
}
private get _doc() {
return this.model.doc;
}
private get _getCardStyleOptions(): {
style: EmbedCardStyle;
Icon: TemplateResult<1>;
tooltip: string;
}[] {
const theme = this.std.get(ThemeProvider).theme;
const { EmbedCardListIcon, EmbedCardCubeIcon } = getEmbedCardIcons(theme);
return [
{
style: 'horizontalThin',
Icon: EmbedCardListIcon,
tooltip: 'Horizontal style',
},
{
style: 'cubeThick',
Icon: EmbedCardCubeIcon,
tooltip: 'Vertical style',
},
];
}
get std() {
return this.edgeless.std;
}
get viewToggleMenu() {
const block = this._block;
const model = this.model;
if (!block || !model) return nothing;
return attachmentViewToggleMenu({
block,
callback: () => this.requestUpdate(),
});
}
override render() {
return join(
[
this.model.style === 'pdf'
? null
: html`
<editor-menu-button
.contentPadding=${'8px'}
.button=${html`
<editor-icon-button
aria-label="Card style"
.tooltip=${'Card style'}
>
${PaletteIcon}
</editor-icon-button>
`}
>
<card-style-panel
.value=${this.model.style}
.options=${this._getCardStyleOptions}
.onSelect=${this._setCardStyle}
>
</card-style-panel>
</editor-menu-button>
`,
this.viewToggleMenu,
html`
<editor-icon-button
aria-label="Download"
.tooltip=${'Download'}
?disabled=${this._doc.readonly}
@click=${this._download}
>
${DownloadIcon}
</editor-icon-button>
`,
html`
<editor-icon-button
aria-label="Add caption"
.tooltip=${'Add caption'}
class="change-attachment-button caption"
?disabled=${this._doc.readonly}
@click=${this._showCaption}
>
${CaptionIcon}
</editor-icon-button>
`,
].filter(button => button !== nothing && button),
renderToolbarSeparator
);
}
@property({ attribute: false })
accessor edgeless!: EdgelessRootBlockComponent;
@property({ attribute: false })
accessor model!: AttachmentBlockModel;
}
export function renderAttachmentButton(
edgeless: EdgelessRootBlockComponent,
attachments?: AttachmentBlockModel[]
) {
if (attachments?.length !== 1) return nothing;
return html`
<edgeless-change-attachment-button
.model=${attachments[0]}
.edgeless=${edgeless}
></edgeless-change-attachment-button>
`;
}
@@ -0,0 +1,192 @@
import { EdgelessCRUDIdentifier } from '@blocksuite/affine-block-surface';
import type {
EdgelessColorPickerButton,
PickColorEvent,
} from '@blocksuite/affine-components/color-picker';
import {
packColor,
packColorsWithColorScheme,
} from '@blocksuite/affine-components/color-picker';
import type {
BrushElementModel,
BrushProps,
ColorScheme,
} from '@blocksuite/affine-model';
import {
DefaultTheme,
LineWidth,
resolveColor,
} from '@blocksuite/affine-model';
import { FeatureFlagService } from '@blocksuite/affine-shared/services';
import type { ColorEvent } from '@blocksuite/affine-shared/utils';
import { countBy, maxBy, WithDisposable } from '@blocksuite/global/utils';
import { html, LitElement, nothing } from 'lit';
import { property, query } from 'lit/decorators.js';
import { when } from 'lit/directives/when.js';
import type { LineWidthEvent } from '../../edgeless/components/panel/line-width-panel.js';
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
function getMostCommonColor(
elements: BrushElementModel[],
colorScheme: ColorScheme
): string {
const colors = countBy(elements, (ele: BrushElementModel) =>
resolveColor(ele.color, colorScheme)
);
const max = maxBy(Object.entries(colors), ([_k, count]) => count);
return max
? (max[0] as string)
: resolveColor(DefaultTheme.black, colorScheme);
}
function getMostCommonSize(elements: BrushElementModel[]): LineWidth {
const sizes = countBy(elements, ele => ele.lineWidth);
const max = maxBy(Object.entries(sizes), ([_k, count]) => count);
return max ? (Number(max[0]) as LineWidth) : LineWidth.Four;
}
function notEqual<K extends keyof BrushProps>(key: K, value: BrushProps[K]) {
return (element: BrushElementModel) => element[key] !== value;
}
export class EdgelessChangeBrushButton extends WithDisposable(LitElement) {
private readonly _setBrushColor = ({ detail }: ColorEvent) => {
const color = detail.value;
this._setBrushProp('color', color);
};
private readonly _setLineWidth = ({ detail: lineWidth }: LineWidthEvent) => {
this._setBrushProp('lineWidth', lineWidth);
};
pickColor = (e: PickColorEvent) => {
const field = 'color';
if (e.type === 'pick') {
const color = e.detail.value;
this.elements.forEach(ele => {
const props = packColor(field, color);
this.crud.updateElement(ele.id, props);
});
return;
}
this.elements.forEach(ele =>
ele[e.type === 'start' ? 'stash' : 'pop'](field)
);
};
get doc() {
return this.edgeless.doc;
}
get service() {
return this.edgeless.service;
}
get surface() {
return this.edgeless.surface;
}
get crud() {
return this.edgeless.std.get(EdgelessCRUDIdentifier);
}
private _setBrushProp<K extends keyof BrushProps>(
key: K,
value: BrushProps[K]
) {
this.doc.captureSync();
this.elements
.filter(notEqual(key, value))
.forEach(element =>
this.crud.updateElement(element.id, { [key]: value })
);
}
override render() {
const colorScheme = this.edgeless.surface.renderer.getColorScheme();
const elements = this.elements;
const selectedColor = getMostCommonColor(elements, colorScheme);
const selectedSize = getMostCommonSize(elements);
return html`
<edgeless-line-width-panel
.selectedSize=${selectedSize}
@select=${this._setLineWidth}
>
</edgeless-line-width-panel>
<editor-toolbar-separator></editor-toolbar-separator>
${when(
this.edgeless.doc
.get(FeatureFlagService)
.getFlag('enable_color_picker'),
() => {
const { type, colors } = packColorsWithColorScheme(
colorScheme,
selectedColor,
elements[0].color
);
return html`
<edgeless-color-picker-button
class="color"
.label=${'Color'}
.pick=${this.pickColor}
.color=${selectedColor}
.colors=${colors}
.colorType=${type}
.theme=${colorScheme}
.palettes=${DefaultTheme.Palettes}
>
</edgeless-color-picker-button>
`;
},
() => html`
<editor-menu-button
.contentPadding=${'8px'}
.button=${html`
<editor-icon-button aria-label="Color" .tooltip=${'Color'}>
<edgeless-color-button
.color=${selectedColor}
></edgeless-color-button>
</editor-icon-button>
`}
>
<edgeless-color-panel
.value=${selectedColor}
.theme=${colorScheme}
.palettes=${DefaultTheme.Palettes}
@select=${this._setBrushColor}
>
</edgeless-color-panel>
</editor-menu-button>
`
)}
`;
}
@query('edgeless-color-picker-button.color')
accessor colorButton!: EdgelessColorPickerButton;
@property({ attribute: false })
accessor edgeless!: EdgelessRootBlockComponent;
@property({ attribute: false })
accessor elements: BrushElementModel[] = [];
}
export function renderChangeBrushButton(
edgeless: EdgelessRootBlockComponent,
elements?: BrushElementModel[]
) {
if (!elements?.length) return nothing;
return html`
<edgeless-change-brush-button .elements=${elements} .edgeless=${edgeless}>
</edgeless-change-brush-button>
`;
}
@@ -0,0 +1,649 @@
import { EdgelessCRUDIdentifier } from '@blocksuite/affine-block-surface';
import type {
EdgelessColorPickerButton,
PickColorEvent,
} from '@blocksuite/affine-components/color-picker';
import {
packColor,
packColorsWithColorScheme,
} from '@blocksuite/affine-components/color-picker';
import { renderToolbarSeparator } from '@blocksuite/affine-components/toolbar';
import {
type ColorScheme,
type ConnectorElementModel,
type ConnectorElementProps,
ConnectorEndpoint,
type ConnectorLabelProps,
ConnectorMode,
DEFAULT_FRONT_END_POINT_STYLE,
DEFAULT_REAR_END_POINT_STYLE,
DefaultTheme,
LineWidth,
PointStyle,
resolveColor,
StrokeStyle,
} from '@blocksuite/affine-model';
import { FeatureFlagService } from '@blocksuite/affine-shared/services';
import type { ColorEvent } from '@blocksuite/affine-shared/utils';
import { countBy, maxBy, WithDisposable } from '@blocksuite/global/utils';
import {
AddTextIcon,
ConnectorCIcon,
ConnectorEIcon,
ConnectorLIcon,
EndPointArrowIcon,
EndPointCircleIcon,
EndPointDiamondIcon,
EndPointTriangleIcon,
FlipDirectionIcon,
StartPointArrowIcon,
StartPointCircleIcon,
StartPointDiamondIcon,
StartPointIcon,
StartPointTriangleIcon,
StyleGeneralIcon,
StyleScribbleIcon,
} from '@blocksuite/icons/lit';
import { html, LitElement, nothing, type TemplateResult } from 'lit';
import { property, query } from 'lit/decorators.js';
import { choose } from 'lit/directives/choose.js';
import { join } from 'lit/directives/join.js';
import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js';
import { when } from 'lit/directives/when.js';
import {
type LineStyleEvent,
LineStylesPanel,
} from '../../edgeless/components/panel/line-styles-panel.js';
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
import { mountConnectorLabelEditor } from '../../edgeless/utils/text.js';
import { SmallArrowDownIcon } from './icons.js';
function getMostCommonColor(
elements: ConnectorElementModel[],
colorScheme: ColorScheme
): string {
const colors = countBy(elements, (ele: ConnectorElementModel) =>
resolveColor(ele.stroke, colorScheme)
);
const max = maxBy(Object.entries(colors), ([_k, count]) => count);
return max
? (max[0] as string)
: resolveColor(DefaultTheme.connectorColor, colorScheme);
}
function getMostCommonMode(
elements: ConnectorElementModel[]
): ConnectorMode | null {
const modes = countBy(elements, ele => ele.mode);
const max = maxBy(Object.entries(modes), ([_k, count]) => count);
return max ? (Number(max[0]) as ConnectorMode) : null;
}
function getMostCommonLineWidth(elements: ConnectorElementModel[]): LineWidth {
const sizes = countBy(elements, ele => ele.strokeWidth);
const max = maxBy(Object.entries(sizes), ([_k, count]) => count);
return max ? (Number(max[0]) as LineWidth) : LineWidth.Four;
}
export function getMostCommonLineStyle(
elements: ConnectorElementModel[]
): StrokeStyle {
const sizes = countBy(elements, ele => ele.strokeStyle);
const max = maxBy(Object.entries(sizes), ([_k, count]) => count);
return max ? (max[0] as StrokeStyle) : StrokeStyle.Solid;
}
function getMostCommonRough(elements: ConnectorElementModel[]): boolean {
const { trueCount, falseCount } = elements.reduce(
(counts, ele) => {
if (ele.rough) {
counts.trueCount++;
} else {
counts.falseCount++;
}
return counts;
},
{ trueCount: 0, falseCount: 0 }
);
return trueCount > falseCount;
}
function getMostCommonEndpointStyle(
elements: ConnectorElementModel[],
endpoint: ConnectorEndpoint,
fallback: PointStyle
): PointStyle {
const field =
endpoint === ConnectorEndpoint.Front
? 'frontEndpointStyle'
: 'rearEndpointStyle';
const modes = countBy(elements, ele => ele[field]);
const max = maxBy(Object.entries(modes), ([_k, count]) => count);
return max ? (max[0] as PointStyle) : fallback;
}
function notEqual<
K extends keyof Omit<ConnectorElementProps, keyof ConnectorLabelProps>,
>(key: K, value: ConnectorElementProps[K]) {
return (element: ConnectorElementModel) => element[key] !== value;
}
interface EndpointStyle {
value: PointStyle;
icon: TemplateResult<1>;
}
const iconSize = { width: '20px', height: '20px' };
const STYLE_LIST = [
{
name: 'General',
value: false,
icon: StyleGeneralIcon(iconSize),
},
{
name: 'Scribbled',
value: true,
icon: StyleScribbleIcon(iconSize),
},
] as const;
const STYLE_CHOOSE: [boolean, () => TemplateResult<1>][] = [
[false, () => StyleGeneralIcon(iconSize)],
[true, () => StyleScribbleIcon(iconSize)],
] as const;
const FRONT_ENDPOINT_STYLE_LIST: EndpointStyle[] = [
{
value: PointStyle.None,
icon: StartPointIcon(),
},
{
value: PointStyle.Arrow,
icon: StartPointArrowIcon(),
},
{
value: PointStyle.Triangle,
icon: StartPointTriangleIcon(),
},
{
value: PointStyle.Circle,
icon: StartPointCircleIcon(),
},
{
value: PointStyle.Diamond,
icon: StartPointDiamondIcon(),
},
] as const;
const REAR_ENDPOINT_STYLE_LIST: EndpointStyle[] = [
{
value: PointStyle.Diamond,
icon: EndPointDiamondIcon(),
},
{
value: PointStyle.Circle,
icon: EndPointCircleIcon(),
},
{
value: PointStyle.Triangle,
icon: EndPointTriangleIcon(),
},
{
value: PointStyle.Arrow,
icon: EndPointArrowIcon(),
},
{
value: PointStyle.None,
icon: StartPointIcon(),
},
] as const;
const MODE_LIST = [
{
name: 'Curve',
icon: ConnectorCIcon(),
value: ConnectorMode.Curve,
},
{
name: 'Elbowed',
icon: ConnectorEIcon(),
value: ConnectorMode.Orthogonal,
},
{
name: 'Straight',
icon: ConnectorLIcon(),
value: ConnectorMode.Straight,
},
] as const;
const MODE_CHOOSE: [ConnectorMode, () => TemplateResult<1>][] = [
[ConnectorMode.Curve, () => ConnectorCIcon()],
[ConnectorMode.Orthogonal, () => ConnectorEIcon()],
[ConnectorMode.Straight, () => ConnectorLIcon()],
] as const;
export class EdgelessChangeConnectorButton extends WithDisposable(LitElement) {
get crud() {
return this.edgeless.std.get(EdgelessCRUDIdentifier);
}
private readonly _setConnectorColor = (e: ColorEvent) => {
const stroke = e.detail.value;
this._setConnectorProp('stroke', stroke);
};
private readonly _setConnectorStroke = ({ type, value }: LineStyleEvent) => {
if (type === 'size') {
this._setConnectorStrokeWidth(value);
return;
}
this._setConnectorStrokeStyle(value);
};
pickColor = (e: PickColorEvent) => {
const field = 'stroke';
if (e.type === 'pick') {
const color = e.detail.value;
this.elements.forEach(ele => {
const props = packColor(field, color);
this.crud.updateElement(ele.id, props);
});
return;
}
this.elements.forEach(ele =>
ele[e.type === 'start' ? 'stash' : 'pop'](field)
);
};
get doc() {
return this.edgeless.doc;
}
get service() {
return this.edgeless.service;
}
private _addLabel() {
mountConnectorLabelEditor(this.elements[0], this.edgeless);
}
private _flipEndpointStyle(
frontEndpointStyle: PointStyle,
rearEndpointStyle: PointStyle
) {
if (frontEndpointStyle === rearEndpointStyle) return;
this.elements.forEach(element =>
this.crud.updateElement(element.id, {
frontEndpointStyle: rearEndpointStyle,
rearEndpointStyle: frontEndpointStyle,
})
);
}
private _getEndpointIcon(list: EndpointStyle[], style: PointStyle) {
return list.find(({ value }) => value === style)?.icon || StartPointIcon();
}
private _setConnectorMode(mode: ConnectorMode) {
this._setConnectorProp('mode', mode);
}
private _setConnectorPointStyle(end: ConnectorEndpoint, style: PointStyle) {
const props = {
[end === ConnectorEndpoint.Front
? 'frontEndpointStyle'
: 'rearEndpointStyle']: style,
};
this.elements.forEach(element =>
this.crud.updateElement(element.id, { ...props })
);
}
private _setConnectorProp<
K extends keyof Omit<ConnectorElementProps, keyof ConnectorLabelProps>,
>(key: K, value: ConnectorElementProps[K]) {
this.doc.captureSync();
this.elements
.filter(notEqual(key, value))
.forEach(element =>
this.crud.updateElement(element.id, { [key]: value })
);
}
private _setConnectorRough(rough: boolean) {
this._setConnectorProp('rough', rough);
}
private _setConnectorStrokeStyle(strokeStyle: StrokeStyle) {
this._setConnectorProp('strokeStyle', strokeStyle);
}
private _setConnectorStrokeWidth(strokeWidth: number) {
this._setConnectorProp('strokeWidth', strokeWidth);
}
private _showAddButtonOrTextMenu() {
if (this.elements.length === 1 && !this.elements[0].text) {
return 'button';
}
if (!this.elements.some(e => !e.text)) {
return 'menu';
}
return 'nothing';
}
override render() {
const colorScheme = this.edgeless.surface.renderer.getColorScheme();
const elements = this.elements;
const selectedColor = getMostCommonColor(elements, colorScheme);
const selectedMode = getMostCommonMode(elements);
const selectedLineSize = getMostCommonLineWidth(elements);
const selectedRough = getMostCommonRough(elements);
const selectedLineStyle = getMostCommonLineStyle(elements);
const selectedStartPointStyle = getMostCommonEndpointStyle(
elements,
ConnectorEndpoint.Front,
DEFAULT_FRONT_END_POINT_STYLE
);
const selectedEndPointStyle = getMostCommonEndpointStyle(
elements,
ConnectorEndpoint.Rear,
DEFAULT_REAR_END_POINT_STYLE
);
return join(
[
when(
this.edgeless.doc
.get(FeatureFlagService)
.getFlag('enable_color_picker'),
() => {
const { type, colors } = packColorsWithColorScheme(
colorScheme,
selectedColor,
elements[0].stroke
);
return html`
<edgeless-color-picker-button
class="stroke-color"
.label=${'Stroke style'}
.pick=${this.pickColor}
.color=${selectedColor}
.colors=${colors}
.colorType=${type}
.theme=${colorScheme}
.palettes=${DefaultTheme.Palettes}
.hollowCircle=${true}
>
<div
slot="other"
class="line-styles"
style=${styleMap({
display: 'flex',
flexDirection: 'row',
gap: '8px',
alignItems: 'center',
})}
>
${LineStylesPanel({
selectedLineSize: selectedLineSize,
selectedLineStyle: selectedLineStyle,
onClick: this._setConnectorStroke,
})}
</div>
<editor-toolbar-separator
slot="separator"
data-orientation="horizontal"
></editor-toolbar-separator>
</edgeless-color-picker-button>
`;
},
() => html`
<editor-menu-button
.contentPadding=${'8px'}
.button=${html`
<editor-icon-button
aria-label="Stroke style"
.tooltip=${'Stroke style'}
.iconSize=${'20px'}
>
<edgeless-color-button
.color=${selectedColor}
></edgeless-color-button>
</editor-icon-button>
`}
>
<stroke-style-panel
.theme=${colorScheme}
.strokeWidth=${selectedLineSize}
.strokeStyle=${selectedLineStyle}
.strokeColor=${selectedColor}
.setStrokeStyle=${this._setConnectorStroke}
.setStrokeColor=${this._setConnectorColor}
>
</stroke-style-panel>
</editor-menu-button>
`
),
html`
<editor-menu-button
.button=${html`
<editor-icon-button
aria-label="Style"
.tooltip=${'Style'}
.iconSize=${'20px'}
>
${choose(selectedRough, STYLE_CHOOSE)}${SmallArrowDownIcon}
</editor-icon-button>
`}
>
<div>
${repeat(
STYLE_LIST,
item => item.name,
({ name, value, icon }) => html`
<editor-icon-button
aria-label=${name}
.tooltip=${name}
.active=${selectedRough === value}
.activeMode=${'background'}
.iconSize=${'20px'}
@click=${() => this._setConnectorRough(value)}
>
${icon}
</editor-icon-button>
`
)}
</div>
</editor-menu-button>
`,
html`
<editor-menu-button
.button=${html`
<editor-icon-button
aria-label="Start point style"
.tooltip=${'Start point style'}
.iconSize=${'20px'}
>
${this._getEndpointIcon(
FRONT_ENDPOINT_STYLE_LIST,
selectedStartPointStyle
)}${SmallArrowDownIcon}
</editor-icon-button>
`}
>
<div>
${repeat(
FRONT_ENDPOINT_STYLE_LIST,
item => item.value,
({ value, icon }) => html`
<editor-icon-button
aria-label=${value}
.tooltip=${value}
.active=${selectedStartPointStyle === value}
.activeMode=${'background'}
.iconSize=${'20px'}
@click=${() =>
this._setConnectorPointStyle(
ConnectorEndpoint.Front,
value
)}
>
${icon}
</editor-icon-button>
`
)}
</div>
</editor-menu-button>
<editor-icon-button
aria-label="Flip direction"
.tooltip=${'Flip direction'}
.disabled=${false}
.iconSize=${'20px'}
@click=${() =>
this._flipEndpointStyle(
selectedStartPointStyle,
selectedEndPointStyle
)}
>
${FlipDirectionIcon()}
</editor-icon-button>
<editor-menu-button
.button=${html`
<editor-icon-button
aria-label="End point style"
.tooltip=${'End point style'}
.iconSize=${'20px'}
>
${this._getEndpointIcon(
REAR_ENDPOINT_STYLE_LIST,
selectedEndPointStyle
)}${SmallArrowDownIcon}
</editor-icon-button>
`}
>
<div>
${repeat(
REAR_ENDPOINT_STYLE_LIST,
item => item.value,
({ value, icon }) => html`
<editor-icon-button
aria-label=${value}
.tooltip=${value}
.active=${selectedEndPointStyle === value}
.activeMode=${'background'}
.iconSize=${'20px'}
@click=${() =>
this._setConnectorPointStyle(
ConnectorEndpoint.Rear,
value
)}
>
${icon}
</editor-icon-button>
`
)}
</div>
</editor-menu-button>
<editor-menu-button
.button=${html`
<editor-icon-button
aria-label="Shape"
.tooltip=${'Connector shape'}
.iconSize=${'20px'}
>
${choose(selectedMode, MODE_CHOOSE)}${SmallArrowDownIcon}
</editor-icon-button>
`}
>
<div>
${repeat(
MODE_LIST,
item => item.name,
({ name, value, icon }) => html`
<editor-icon-button
aria-label=${name}
.tooltip=${name}
.active=${selectedMode === value}
.activeMode=${'background'}
.iconSize=${'20px'}
@click=${() => this._setConnectorMode(value)}
>
${icon}
</editor-icon-button>
`
)}
</div>
</editor-menu-button>
`,
choose<string, TemplateResult<1> | typeof nothing>(
this._showAddButtonOrTextMenu(),
[
[
'button',
() => html`
<editor-icon-button
aria-label="Add text"
.tooltip=${'Add text'}
.iconSize=${'20px'}
@click=${this._addLabel}
>
${AddTextIcon()}
</editor-icon-button>
`,
],
[
'menu',
() => html`
<edgeless-change-text-menu
.elementType=${'connector'}
.elements=${this.elements}
.edgeless=${this.edgeless}
></edgeless-change-text-menu>
`,
],
['nothing', () => nothing],
]
),
].filter(button => button !== nothing),
renderToolbarSeparator
);
}
@property({ attribute: false })
accessor edgeless!: EdgelessRootBlockComponent;
@property({ attribute: false })
accessor elements: ConnectorElementModel[] = [];
@query('edgeless-color-picker-button.stroke-color')
accessor strokeColorButton!: EdgelessColorPickerButton;
}
export function renderConnectorButton(
edgeless: EdgelessRootBlockComponent,
elements?: ConnectorElementModel[]
) {
if (!elements?.length) return nothing;
return html`
<edgeless-change-connector-button
.elements=${elements}
.edgeless=${edgeless}
>
</edgeless-change-connector-button>
`;
}
@@ -0,0 +1,19 @@
import type { EdgelessTextBlockModel } from '@blocksuite/affine-model';
import { html, nothing } from 'lit';
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
export function renderChangeEdgelessTextButton(
edgeless: EdgelessRootBlockComponent,
elements?: EdgelessTextBlockModel[]
) {
if (!elements?.length) return nothing;
return html`
<edgeless-change-text-menu
.elementType=${'edgeless-text'}
.elements=${elements}
.edgeless=${edgeless}
></edgeless-change-text-menu>
`;
}
@@ -0,0 +1,920 @@
import {} from '@blocksuite/affine-block-bookmark';
import {
EmbedLinkedDocBlockComponent,
EmbedSyncedDocBlockComponent,
getDocContentWithMaxLength,
getEmbedCardIcons,
} from '@blocksuite/affine-block-embed';
import {
EdgelessCRUDIdentifier,
reassociateConnectorsCommand,
} from '@blocksuite/affine-block-surface';
import { toggleEmbedCardEditModal } from '@blocksuite/affine-components/embed-card-modal';
import {
CaptionIcon,
CopyIcon,
EditIcon,
ExpandFullSmallIcon,
OpenIcon,
PaletteIcon,
} from '@blocksuite/affine-components/icons';
import {
notifyLinkedDocClearedAliases,
notifyLinkedDocSwitchedToCard,
notifyLinkedDocSwitchedToEmbed,
} from '@blocksuite/affine-components/notification';
import { isPeekable, peek } from '@blocksuite/affine-components/peek';
import { toast } from '@blocksuite/affine-components/toast';
import {
type MenuItem,
renderToolbarSeparator,
} from '@blocksuite/affine-components/toolbar';
import {
type AliasInfo,
BookmarkStyles,
type BuiltInEmbedModel,
type EmbedCardStyle,
isInternalEmbedModel,
} from '@blocksuite/affine-model';
import {
EMBED_CARD_HEIGHT,
EMBED_CARD_WIDTH,
} from '@blocksuite/affine-shared/consts';
import {
EmbedOptionProvider,
type EmbedOptions,
FeatureFlagService,
GenerateDocUrlProvider,
type GenerateDocUrlService,
type LinkEventType,
OpenDocExtensionIdentifier,
type OpenDocMode,
type TelemetryEvent,
TelemetryProvider,
ThemeProvider,
} from '@blocksuite/affine-shared/services';
import { getHostName, referenceToNode } from '@blocksuite/affine-shared/utils';
import type { BlockStdScope } from '@blocksuite/block-std';
import { Bound, WithDisposable } from '@blocksuite/global/utils';
import { css, html, LitElement, nothing, type TemplateResult } from 'lit';
import { property, state } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { join } from 'lit/directives/join.js';
import { repeat } from 'lit/directives/repeat.js';
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
import {
isBookmarkBlock,
isEmbedGithubBlock,
isEmbedHtmlBlock,
isEmbedLinkedDocBlock,
isEmbedSyncedDocBlock,
} from '../../edgeless/utils/query.js';
import type { BuiltInEmbedBlockComponent } from '../../utils/types';
import { SmallArrowDownIcon } from './icons.js';
export class EdgelessChangeEmbedCardButton extends WithDisposable(LitElement) {
static override styles = css`
.affine-link-preview {
display: flex;
justify-content: flex-start;
width: 140px;
padding: var(--1, 0px);
border-radius: var(--1, 0px);
opacity: var(--add, 1);
user-select: none;
cursor: pointer;
color: var(--affine-link-color);
font-feature-settings:
'clig' off,
'liga' off;
font-family: var(--affine-font-family);
font-size: var(--affine-font-sm);
font-style: normal;
font-weight: 400;
text-decoration: none;
text-wrap: nowrap;
}
.affine-link-preview > span {
display: inline-block;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
text-overflow: ellipsis;
overflow: hidden;
opacity: var(--add, 1);
}
editor-icon-button.doc-title .label {
max-width: 110px;
display: inline-block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
user-select: none;
cursor: pointer;
color: var(--affine-link-color);
font-feature-settings:
'clig' off,
'liga' off;
font-family: var(--affine-font-family);
font-size: var(--affine-font-sm);
font-style: normal;
font-weight: 400;
text-decoration: none;
text-wrap: nowrap;
}
`;
get crud() {
return this.edgeless.std.get(EdgelessCRUDIdentifier);
}
private readonly _convertToCardView = () => {
if (this._isCardView) {
return;
}
const block = this._blockComponent;
if (block && 'convertToCard' in block) {
block.convertToCard();
return;
}
if (!('url' in this.model)) {
return;
}
const { id, url, xywh, style, caption } = this.model;
let targetFlavour = 'affine:bookmark',
targetStyle = style;
if (this._embedOptions && this._embedOptions.viewType === 'card') {
const { flavour, styles } = this._embedOptions;
targetFlavour = flavour;
targetStyle = styles.includes(style) ? style : styles[0];
} else {
targetStyle = BookmarkStyles.includes(style) ? style : BookmarkStyles[0];
}
const bound = Bound.deserialize(xywh);
bound.w = EMBED_CARD_WIDTH[targetStyle];
bound.h = EMBED_CARD_HEIGHT[targetStyle];
const newId = this.crud.addBlock(
targetFlavour,
{ url, xywh: bound.serialize(), style: targetStyle, caption },
this.edgeless.surface.model
);
this.std.command.exec(reassociateConnectorsCommand, {
oldId: id,
newId,
});
this.edgeless.service.selection.set({
editing: false,
elements: [newId],
});
this._doc.deleteBlock(this.model);
};
private readonly _convertToEmbedView = () => {
if (this._isEmbedView) {
return;
}
const block = this._blockComponent;
if (block && 'convertToEmbed' in block) {
const referenceInfo = block.referenceInfo$.peek();
block.convertToEmbed();
if (referenceInfo.title || referenceInfo.description)
notifyLinkedDocSwitchedToEmbed(this.std);
return;
}
if (!('url' in this.model)) {
return;
}
if (!this._embedOptions) return;
const { flavour, styles } = this._embedOptions;
const { id, url, xywh, style } = this.model;
const targetStyle = styles.includes(style) ? style : styles[0];
const bound = Bound.deserialize(xywh);
bound.w = EMBED_CARD_WIDTH[targetStyle];
bound.h = EMBED_CARD_HEIGHT[targetStyle];
const newId = this.crud.addBlock(
flavour,
{
url,
xywh: bound.serialize(),
style: targetStyle,
},
this.edgeless.surface.model
);
if (!newId) return;
this.std.command.exec(reassociateConnectorsCommand, {
oldId: id,
newId,
});
this.edgeless.service.selection.set({
editing: false,
elements: [newId],
});
this._doc.deleteBlock(this.model);
};
private readonly _copyUrl = () => {
let url!: ReturnType<GenerateDocUrlService['generateDocUrl']>;
if ('url' in this.model) {
url = this.model.url;
} else if (isInternalEmbedModel(this.model)) {
url = this.std
.getOptional(GenerateDocUrlProvider)
?.generateDocUrl(this.model.pageId, this.model.params);
}
if (!url) return;
navigator.clipboard.writeText(url).catch(console.error);
toast(this.std.host, 'Copied link to clipboard');
this.edgeless.service.selection.clear();
track(this.std, this.model, this._viewType, 'CopiedLink', {
control: 'copy link',
});
};
private _embedOptions: EmbedOptions | null = null;
private readonly _getScale = () => {
if ('scale' in this.model) {
return this.model.scale ?? 1;
} else if (isEmbedHtmlBlock(this.model)) {
return 1;
}
const bound = Bound.deserialize(this.model.xywh);
return bound.h / EMBED_CARD_HEIGHT[this.model.style];
};
private readonly _open = ({ openMode }: { openMode?: OpenDocMode } = {}) => {
this._blockComponent?.open({ openMode });
};
private readonly _openEditPopup = (e: MouseEvent) => {
e.stopPropagation();
if (isEmbedHtmlBlock(this.model)) return;
this.std.selection.clear();
const originalDocInfo = this._originalDocInfo;
toggleEmbedCardEditModal(
this.std.host,
this.model,
this._viewType,
originalDocInfo,
(std, component) => {
if (
isEmbedLinkedDocBlock(this.model) &&
component instanceof EmbedLinkedDocBlockComponent
) {
component.refreshData();
notifyLinkedDocClearedAliases(std);
}
},
(std, component, props) => {
if (
isEmbedSyncedDocBlock(this.model) &&
component instanceof EmbedSyncedDocBlockComponent
) {
component.convertToCard(props);
notifyLinkedDocSwitchedToCard(std);
} else {
this.model.doc.updateBlock(this.model, props);
component.requestUpdate();
}
}
);
track(this.std, this.model, this._viewType, 'OpenedAliasPopup', {
control: 'edit',
});
};
private readonly _peek = () => {
if (!this._blockComponent) return;
peek(this._blockComponent);
};
private readonly _setCardStyle = (style: EmbedCardStyle) => {
const bounds = Bound.deserialize(this.model.xywh);
bounds.w = EMBED_CARD_WIDTH[style];
bounds.h = EMBED_CARD_HEIGHT[style];
const xywh = bounds.serialize();
this.model.doc.updateBlock(this.model, { style, xywh });
track(this.std, this.model, this._viewType, 'SelectedCardStyle', {
control: 'select card style',
type: style,
});
};
private readonly _setEmbedScale = (scale: number) => {
if (isEmbedHtmlBlock(this.model)) return;
const bound = Bound.deserialize(this.model.xywh);
if ('scale' in this.model) {
const oldScale = this.model.scale ?? 1;
const ratio = scale / oldScale;
bound.w *= ratio;
bound.h *= ratio;
const xywh = bound.serialize();
this.model.doc.updateBlock(this.model, { scale, xywh });
} else {
bound.h = EMBED_CARD_HEIGHT[this.model.style] * scale;
bound.w = EMBED_CARD_WIDTH[this.model.style] * scale;
const xywh = bound.serialize();
this.model.doc.updateBlock(this.model, { xywh });
}
this._embedScale = scale;
track(this.std, this.model, this._viewType, 'SelectedCardScale', {
control: 'select card scale',
type: `${scale}`,
});
};
private readonly _toggleCardScaleSelector = (e: Event) => {
const opened = (e as CustomEvent<boolean>).detail;
if (!opened) return;
track(this.std, this.model, this._viewType, 'OpenedCardScaleSelector', {
control: 'switch card scale',
});
};
private readonly _toggleCardStyleSelector = (e: Event) => {
const opened = (e as CustomEvent<boolean>).detail;
if (!opened) return;
track(this.std, this.model, this._viewType, 'OpenedCardStyleSelector', {
control: 'switch card style',
});
};
private readonly _toggleViewSelector = (e: Event) => {
const opened = (e as CustomEvent<boolean>).detail;
if (!opened) return;
track(this.std, this.model, this._viewType, 'OpenedViewSelector', {
control: 'switch view',
});
};
private readonly _trackViewSelected = (type: string) => {
track(this.std, this.model, this._viewType, 'SelectedView', {
control: 'select view',
type: `${type} view`,
});
};
private get _blockComponent() {
const blockSelection =
this.edgeless.service.selection.surfaceSelections.filter(sel =>
sel.elements.includes(this.model.id)
);
if (blockSelection.length !== 1) {
return;
}
const blockComponent = this.std.view.getBlock(
blockSelection[0].blockId
) as BuiltInEmbedBlockComponent | null;
if (!blockComponent) return;
return blockComponent;
}
private get _canConvertToEmbedView() {
const block = this._blockComponent;
// synced doc entry controlled by awareness flag
if (!!block && isEmbedLinkedDocBlock(block.model)) {
const isSyncedDocEnabled = block.doc
.get(FeatureFlagService)
.getFlag('enable_synced_doc_block');
if (!isSyncedDocEnabled) {
return false;
}
}
return (
(block && 'convertToEmbed' in block) ||
this._embedOptions?.viewType === 'embed'
);
}
private get _canShowCardStylePanel() {
return (
isBookmarkBlock(this.model) ||
isEmbedGithubBlock(this.model) ||
isEmbedLinkedDocBlock(this.model)
);
}
private get _canShowFullScreenButton() {
return isEmbedHtmlBlock(this.model);
}
private get _canShowUrlOptions() {
return (
'url' in this.model &&
(isBookmarkBlock(this.model) ||
isEmbedGithubBlock(this.model) ||
isEmbedLinkedDocBlock(this.model))
);
}
private get _doc() {
return this.model.doc;
}
private get _embedViewButtonDisabled() {
if (this._doc.readonly) {
return true;
}
return (
isEmbedLinkedDocBlock(this.model) &&
(referenceToNode(this.model) ||
!!this._blockComponent?.closest('affine-embed-synced-doc-block') ||
this.model.pageId === this._doc.id)
);
}
private get _getCardStyleOptions(): {
style: EmbedCardStyle;
Icon: TemplateResult<1>;
tooltip: string;
}[] {
const theme = this.std.get(ThemeProvider).theme;
const {
EmbedCardHorizontalIcon,
EmbedCardListIcon,
EmbedCardVerticalIcon,
EmbedCardCubeIcon,
} = getEmbedCardIcons(theme);
return [
{
style: 'horizontal',
Icon: EmbedCardHorizontalIcon,
tooltip: 'Large horizontal style',
},
{
style: 'list',
Icon: EmbedCardListIcon,
tooltip: 'Small horizontal style',
},
{
style: 'vertical',
Icon: EmbedCardVerticalIcon,
tooltip: 'Large vertical style',
},
{
style: 'cube',
Icon: EmbedCardCubeIcon,
tooltip: 'Small vertical style',
},
];
}
private get _isCardView() {
if (isBookmarkBlock(this.model) || isEmbedLinkedDocBlock(this.model)) {
return true;
}
return this._embedOptions?.viewType === 'card';
}
private get _isEmbedView() {
return (
!isBookmarkBlock(this.model) &&
(isEmbedSyncedDocBlock(this.model) ||
this._embedOptions?.viewType === 'embed')
);
}
get _originalDocInfo(): AliasInfo | undefined {
const model = this.model;
const doc = isInternalEmbedModel(model)
? this.std.workspace.getDoc(model.pageId)
: null;
if (doc) {
const title = doc.meta?.title;
const description = isEmbedLinkedDocBlock(model)
? getDocContentWithMaxLength(doc)
: undefined;
return { title, description };
}
return undefined;
}
get _originalDocTitle() {
const model = this.model;
const doc = isInternalEmbedModel(model)
? this.std.workspace.getDoc(model.pageId)
: null;
return doc?.meta?.title || 'Untitled';
}
private get _viewType(): 'inline' | 'embed' | 'card' {
if (this._isCardView) {
return 'card';
}
if (this._isEmbedView) {
return 'embed';
}
// unreachable
return 'inline';
}
private get std() {
return this.edgeless.std;
}
private _openMenuButton() {
const openDocConfig = this.std.get(OpenDocExtensionIdentifier);
const buttons: MenuItem[] = openDocConfig.items
.map(item => {
if (
item.type === 'open-in-center-peek' &&
this._blockComponent &&
!isPeekable(this._blockComponent)
) {
return null;
}
if (
!(
isEmbedLinkedDocBlock(this.model) ||
isEmbedSyncedDocBlock(this.model)
)
) {
return null;
}
return {
label: item.label,
type: item.type,
icon: item.icon,
disabled:
this.model.pageId === this._doc.id &&
item.type === 'open-in-active-view',
action: () => {
if (item.type === 'open-in-center-peek') {
this._peek();
} else {
this._open({ openMode: item.type });
}
},
};
})
.filter(item => item !== null);
// todo: abstract this?
if (this._canShowFullScreenButton) {
buttons.push({
type: 'open-this-doc',
label: 'Open this doc',
icon: ExpandFullSmallIcon,
action: this._open,
});
}
if (buttons.length === 0) {
return nothing;
}
return html`
<editor-menu-button
.contentPadding=${'8px'}
.button=${html`
<editor-icon-button
aria-label="Open"
.justify=${'space-between'}
.labelHeight=${'20px'}
>
${OpenIcon}${SmallArrowDownIcon}
</editor-icon-button>
`}
>
<div data-size="small" data-orientation="vertical">
${repeat(
buttons,
button => button.label,
({ label, icon, action, disabled }) => html`
<editor-menu-action
aria-label=${ifDefined(label)}
?disabled=${disabled}
@click=${action}
>
${icon}<span class="label">${label}</span>
</editor-menu-action>
`
)}
</div>
</editor-menu-button>
`;
}
private _showCaption() {
this._blockComponent?.captionEditor?.show();
track(this.std, this.model, this._viewType, 'OpenedCaptionEditor', {
control: 'add caption',
});
}
private _viewSelector() {
if (this._canConvertToEmbedView || this._isEmbedView) {
const buttons = [
{
type: 'card',
label: 'Card view',
action: () => this._convertToCardView(),
disabled: this.model.doc.readonly,
},
{
type: 'embed',
label: 'Embed view',
action: () => this._convertToEmbedView(),
disabled: this.model.doc.readonly || this._embedViewButtonDisabled,
},
];
return html`
<editor-menu-button
.contentPadding=${'8px'}
.button=${html`
<editor-icon-button
aria-label="Switch view"
.justify=${'space-between'}
.labelHeight=${'20px'}
.iconContainerWidth=${'110px'}
>
<div class="label">
<span style="text-transform: capitalize"
>${this._viewType}</span
>
view
</div>
${SmallArrowDownIcon}
</editor-icon-button>
`}
@toggle=${this._toggleViewSelector}
>
<div data-size="small" data-orientation="vertical">
${repeat(
buttons,
button => button.type,
({ type, label, action, disabled }) => html`
<editor-menu-action
data-testid=${`link-to-${type}`}
aria-label=${ifDefined(label)}
?data-selected=${this._viewType === type}
?disabled=${disabled || this._viewType === type}
@click=${() => {
action();
this._trackViewSelected(type);
}}
>
${label}
</editor-menu-action>
`
)}
</div>
</editor-menu-button>
`;
}
return nothing;
}
override connectedCallback() {
super.connectedCallback();
this._embedScale = this._getScale();
}
override render() {
const model = this.model;
const isHtmlBlockModel = isEmbedHtmlBlock(model);
if ('url' in this.model) {
this._embedOptions = this.std
.get(EmbedOptionProvider)
.getEmbedBlockOptions(this.model.url);
}
const buttons = [
this._openMenuButton(),
this._canShowUrlOptions && 'url' in model
? html`
<a
class="affine-link-preview"
href=${model.url}
rel="noopener noreferrer"
target="_blank"
>
<span>${getHostName(model.url)}</span>
</a>
`
: nothing,
// internal embed model
isEmbedLinkedDocBlock(model) && model.title
? html`
<editor-icon-button
class="doc-title"
aria-label="Doc title"
.hover=${false}
.labelHeight=${'20px'}
.tooltip=${this._originalDocTitle}
@click=${this._open}
>
<span class="label">${this._originalDocTitle}</span>
</editor-icon-button>
`
: nothing,
isHtmlBlockModel
? nothing
: html`
<editor-icon-button
aria-label="Click link"
.tooltip=${'Click link'}
class="change-embed-card-button copy"
?disabled=${this._doc.readonly}
@click=${this._copyUrl}
>
${CopyIcon}
</editor-icon-button>
<editor-icon-button
aria-label="Edit"
.tooltip=${'Edit'}
class="change-embed-card-button edit"
?disabled=${this._doc.readonly}
@click=${this._openEditPopup}
>
${EditIcon}
</editor-icon-button>
`,
this._viewSelector(),
'style' in model && this._canShowCardStylePanel
? html`
<editor-menu-button
.contentPadding=${'8px'}
.button=${html`
<editor-icon-button
aria-label="Card style"
.tooltip=${'Card style'}
>
${PaletteIcon}
</editor-icon-button>
`}
@toggle=${this._toggleCardStyleSelector}
>
<card-style-panel
.value=${model.style}
.options=${this._getCardStyleOptions}
.onSelect=${this._setCardStyle}
>
</card-style-panel>
</editor-menu-button>
`
: nothing,
'caption' in model
? html`
<editor-icon-button
aria-label="Add caption"
.tooltip=${'Add caption'}
class="change-embed-card-button caption"
?disabled=${this._doc.readonly}
@click=${this._showCaption}
>
${CaptionIcon}
</editor-icon-button>
`
: nothing,
this.quickConnectButton,
isHtmlBlockModel
? nothing
: html`
<editor-menu-button
.contentPadding=${'8px'}
.button=${html`
<editor-icon-button
aria-label="Scale"
.tooltip=${'Scale'}
.justify=${'space-between'}
.iconContainerWidth=${'65px'}
.labelHeight=${'20px'}
>
<span class="label">
${Math.round(this._embedScale * 100) + '%'}
</span>
${SmallArrowDownIcon}
</editor-icon-button>
`}
@toggle=${this._toggleCardScaleSelector}
>
<edgeless-scale-panel
class="embed-scale-popper"
.scale=${Math.round(this._embedScale * 100)}
.onSelect=${this._setEmbedScale}
></edgeless-scale-panel>
</editor-menu-button>
`,
];
return join(
buttons.filter(button => button !== nothing),
renderToolbarSeparator
);
}
@state()
private accessor _embedScale = 1;
@property({ attribute: false })
accessor edgeless!: EdgelessRootBlockComponent;
@property({ attribute: false })
accessor model!: BuiltInEmbedModel;
@property({ attribute: false })
accessor quickConnectButton!: TemplateResult<1> | typeof nothing;
}
export function renderEmbedButton(
edgeless: EdgelessRootBlockComponent,
models?: EdgelessChangeEmbedCardButton['model'][],
quickConnectButton?: TemplateResult<1>[]
) {
if (models?.length !== 1) return nothing;
return html`
<edgeless-change-embed-card-button
.model=${models[0]}
.edgeless=${edgeless}
.quickConnectButton=${quickConnectButton?.pop() ?? nothing}
></edgeless-change-embed-card-button>
`;
}
function track(
std: BlockStdScope,
model: BuiltInEmbedModel,
viewType: string,
event: LinkEventType,
props: Partial<TelemetryEvent>
) {
std.getOptional(TelemetryProvider)?.track(event, {
segment: 'toolbar',
page: 'whiteboard editor',
module: 'element toolbar',
type: `${viewType} view`,
category: isInternalEmbedModel(model) ? 'linked doc' : 'link',
...props,
});
}
@@ -0,0 +1,266 @@
import type { EdgelessFrameManager } from '@blocksuite/affine-block-frame';
import { EdgelessCRUDIdentifier } from '@blocksuite/affine-block-surface';
import type {
EdgelessColorPickerButton,
PickColorEvent,
} from '@blocksuite/affine-components/color-picker';
import {
packColor,
packColorsWithColorScheme,
} from '@blocksuite/affine-components/color-picker';
import { toast } from '@blocksuite/affine-components/toast';
import { renderToolbarSeparator } from '@blocksuite/affine-components/toolbar';
import {
type ColorScheme,
DEFAULT_NOTE_HEIGHT,
DefaultTheme,
type FrameBlockModel,
NoteBlockModel,
NoteDisplayMode,
resolveColor,
} from '@blocksuite/affine-model';
import { FeatureFlagService } from '@blocksuite/affine-shared/services';
import type { ColorEvent } from '@blocksuite/affine-shared/utils';
import { matchModels } from '@blocksuite/affine-shared/utils';
import { GfxExtensionIdentifier } from '@blocksuite/block-std/gfx';
import {
countBy,
deserializeXYWH,
maxBy,
serializeXYWH,
WithDisposable,
} from '@blocksuite/global/utils';
import { EditIcon, PageIcon, UngroupIcon } from '@blocksuite/icons/lit';
import { html, LitElement, nothing } from 'lit';
import { property, query } from 'lit/decorators.js';
import { join } from 'lit/directives/join.js';
import { when } from 'lit/directives/when.js';
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
import { mountFrameTitleEditor } from '../../edgeless/utils/text.js';
function getMostCommonColor(
elements: FrameBlockModel[],
colorScheme: ColorScheme
): string {
const colors = countBy(elements, (ele: FrameBlockModel) =>
resolveColor(ele.background, colorScheme)
);
const max = maxBy(Object.entries(colors), ([_k, count]) => count);
return max ? (max[0] as string) : 'transparent';
}
export class EdgelessChangeFrameButton extends WithDisposable(LitElement) {
get crud() {
return this.edgeless.std.get(EdgelessCRUDIdentifier);
}
private readonly _setFrameBackground = (e: ColorEvent) => {
const background = e.detail.value;
this.frames.forEach(frame => {
this.crud.updateElement(frame.id, { background });
});
};
pickColor = (e: PickColorEvent) => {
const field = 'background';
if (e.type === 'pick') {
const color = e.detail.value;
this.frames.forEach(ele => {
const props = packColor(field, color);
this.crud.updateElement(ele.id, props);
});
return;
}
this.frames.forEach(ele =>
ele[e.type === 'start' ? 'stash' : 'pop'](field)
);
};
get service() {
return this.edgeless.service;
}
private _insertIntoPage() {
if (!this.edgeless.doc.root) return;
const rootModel = this.edgeless.doc.root;
const notes = rootModel.children.filter(
model =>
matchModels(model, [NoteBlockModel]) &&
model.displayMode !== NoteDisplayMode.EdgelessOnly
);
const lastNote = notes[notes.length - 1];
const referenceFrame = this.frames[0];
let targetParent = lastNote?.id;
if (!lastNote) {
const targetXYWH = deserializeXYWH(referenceFrame.xywh);
targetXYWH[1] = targetXYWH[1] + targetXYWH[3];
targetXYWH[3] = DEFAULT_NOTE_HEIGHT;
const newAddedNote = this.edgeless.doc.addBlock(
'affine:note',
{
xywh: serializeXYWH(...targetXYWH),
},
rootModel.id
);
targetParent = newAddedNote;
}
this.edgeless.doc.addBlock(
'affine:surface-ref',
{
reference: this.frames[0].id,
refFlavour: 'affine:frame',
},
targetParent
);
toast(this.edgeless.host, 'Frame has been inserted into doc');
}
protected override render() {
const { frames } = this;
const len = frames.length;
const onlyOne = len === 1;
const colorScheme = this.edgeless.surface.renderer.getColorScheme();
const background = getMostCommonColor(frames, colorScheme);
return join(
[
onlyOne
? html`
<editor-icon-button
aria-label=${'Insert into Page'}
.tooltip=${'Insert into Page'}
.iconSize=${'20px'}
.labelHeight=${'20px'}
@click=${this._insertIntoPage}
>
${PageIcon()}
<span class="label">Insert into Page</span>
</editor-icon-button>
`
: nothing,
onlyOne
? html`
<editor-icon-button
aria-label="Rename"
.tooltip=${'Rename'}
.iconSize=${'20px'}
@click=${() =>
mountFrameTitleEditor(this.frames[0], this.edgeless)}
>
${EditIcon()}
</editor-icon-button>
`
: nothing,
html`
<editor-icon-button
aria-label="Ungroup"
.tooltip=${'Ungroup'}
.iconSize=${'20px'}
@click=${() => {
this.edgeless.doc.captureSync();
const frameMgr = this.edgeless.std.get(
GfxExtensionIdentifier('frame-manager')
) as EdgelessFrameManager;
frames.forEach(frame =>
frameMgr.removeAllChildrenFromFrame(frame)
);
frames.forEach(frame => {
this.edgeless.service.removeElement(frame);
});
this.edgeless.service.selection.clear();
}}
>
${UngroupIcon()}
</editor-icon-button>
`,
when(
this.edgeless.doc
.get(FeatureFlagService)
.getFlag('enable_color_picker'),
() => {
const { type, colors } = packColorsWithColorScheme(
colorScheme,
background,
this.frames[0].background
);
return html`
<edgeless-color-picker-button
class="background"
.label=${'Background'}
.pick=${this.pickColor}
.color=${background}
.colors=${colors}
.colorType=${type}
.theme=${colorScheme}
.palettes=${DefaultTheme.Palettes}
>
</edgeless-color-picker-button>
`;
},
() => html`
<editor-menu-button
.contentPadding=${'8px'}
.button=${html`
<editor-icon-button
aria-label="Background"
.tooltip=${'Background'}
>
<edgeless-color-button
.color=${background}
></edgeless-color-button>
</editor-icon-button>
`}
>
<edgeless-color-panel
.value=${background}
.theme=${colorScheme}
.palettes=${DefaultTheme.Palettes}
@select=${this._setFrameBackground}
>
</edgeless-color-panel>
</editor-menu-button>
`
),
].filter(button => button !== nothing),
renderToolbarSeparator
);
}
@query('edgeless-color-picker-button.background')
accessor backgroundButton!: EdgelessColorPickerButton;
@property({ attribute: false })
accessor edgeless!: EdgelessRootBlockComponent;
@property({ attribute: false })
accessor frames: FrameBlockModel[] = [];
}
export function renderFrameButton(
edgeless: EdgelessRootBlockComponent,
frames?: FrameBlockModel[]
) {
if (!frames?.length) return nothing;
return html`
<edgeless-change-frame-button
.edgeless=${edgeless}
.frames=${frames}
></edgeless-change-frame-button>
`;
}
@@ -0,0 +1,134 @@
import { toast } from '@blocksuite/affine-components/toast';
import { renderToolbarSeparator } from '@blocksuite/affine-components/toolbar';
import type { GroupElementModel } from '@blocksuite/affine-model';
import {
DEFAULT_NOTE_HEIGHT,
NoteBlockModel,
NoteDisplayMode,
} from '@blocksuite/affine-model';
import { matchModels } from '@blocksuite/affine-shared/utils';
import {
deserializeXYWH,
serializeXYWH,
WithDisposable,
} from '@blocksuite/global/utils';
import { EditIcon, PageIcon, UngroupIcon } from '@blocksuite/icons/lit';
import { html, LitElement, nothing } from 'lit';
import { property } from 'lit/decorators.js';
import { join } from 'lit/directives/join.js';
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
import { mountGroupTitleEditor } from '../../edgeless/utils/text.js';
export class EdgelessChangeGroupButton extends WithDisposable(LitElement) {
private _insertIntoPage() {
if (!this.edgeless.doc.root) return;
const rootModel = this.edgeless.doc.root;
const notes = rootModel.children.filter(
model =>
matchModels(model, [NoteBlockModel]) &&
model.displayMode !== NoteDisplayMode.EdgelessOnly
);
const lastNote = notes[notes.length - 1];
const referenceGroup = this.groups[0];
let targetParent = lastNote?.id;
if (!lastNote) {
const targetXYWH = deserializeXYWH(referenceGroup.xywh);
targetXYWH[1] = targetXYWH[1] + targetXYWH[3];
targetXYWH[3] = DEFAULT_NOTE_HEIGHT;
const newAddedNote = this.edgeless.doc.addBlock(
'affine:note',
{
xywh: serializeXYWH(...targetXYWH),
},
rootModel.id
);
targetParent = newAddedNote;
}
this.edgeless.doc.addBlock(
'affine:surface-ref',
{
reference: this.groups[0].id,
refFlavour: 'group',
},
targetParent
);
toast(this.edgeless.host, 'Group has been inserted into page');
}
protected override render() {
const { groups } = this;
const onlyOne = groups.length === 1;
return join(
[
onlyOne
? html`
<editor-icon-button
aria-label="Insert into Page"
.tooltip=${'Insert into Page'}
.iconSize=${'20px'}
.labelHeight=${'20px'}
@click=${this._insertIntoPage}
>
${PageIcon()}
<span class="label">Insert into Page</span>
</editor-icon-button>
`
: nothing,
onlyOne
? html`
<editor-icon-button
aria-label="Rename"
.tooltip=${'Rename'}
.iconSize=${'20px'}
@click=${() => mountGroupTitleEditor(groups[0], this.edgeless)}
>
${EditIcon()}
</editor-icon-button>
`
: nothing,
html`
<editor-icon-button
aria-label="Ungroup"
.tooltip=${'Ungroup'}
.iconSize=${'20px'}
@click=${() =>
groups.forEach(group => this.edgeless.service.ungroup(group))}
>
${UngroupIcon()}
</editor-icon-button>
`,
].filter(button => button !== nothing),
renderToolbarSeparator
);
}
@property({ attribute: false })
accessor edgeless!: EdgelessRootBlockComponent;
@property({ attribute: false })
accessor groups!: GroupElementModel[];
}
export function renderGroupButton(
edgeless: EdgelessRootBlockComponent,
groups?: GroupElementModel[]
) {
if (!groups?.length) return nothing;
return html`
<edgeless-change-group-button .edgeless=${edgeless} .groups=${groups}>
</edgeless-change-group-button>
`;
}
@@ -0,0 +1,87 @@
import {
downloadImageBlob,
type ImageBlockComponent,
} from '@blocksuite/affine-block-image';
import { CaptionIcon, DownloadIcon } from '@blocksuite/affine-components/icons';
import type { ImageBlockModel } from '@blocksuite/affine-model';
import { WithDisposable } from '@blocksuite/global/utils';
import { html, LitElement, nothing } from 'lit';
import { property } from 'lit/decorators.js';
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
export class EdgelessChangeImageButton extends WithDisposable(LitElement) {
private readonly _download = () => {
if (!this._blockComponent) return;
downloadImageBlob(this._blockComponent).catch(console.error);
};
private readonly _showCaption = () => {
this._blockComponent?.captionEditor?.show();
};
private get _blockComponent() {
const blockSelection =
this.edgeless.service.selection.surfaceSelections.filter(sel =>
sel.elements.includes(this.model.id)
);
if (blockSelection.length !== 1) {
return;
}
const block = this.edgeless.std.view.getBlock(
blockSelection[0].blockId
) as ImageBlockComponent | null;
return block;
}
private get _doc() {
return this.model.doc;
}
override render() {
return html`
<editor-icon-button
aria-label="Download"
.tooltip=${'Download'}
?disabled=${this._doc.readonly}
@click=${this._download}
>
${DownloadIcon}
</editor-icon-button>
<editor-toolbar-separator></editor-toolbar-separator>
<editor-icon-button
aria-label="Add caption"
.tooltip=${'Add caption'}
class="change-image-button caption"
?disabled=${this._doc.readonly}
@click=${this._showCaption}
>
${CaptionIcon}
</editor-icon-button>
`;
}
@property({ attribute: false })
accessor edgeless!: EdgelessRootBlockComponent;
@property({ attribute: false })
accessor model!: ImageBlockModel;
}
export function renderChangeImageButton(
edgeless: EdgelessRootBlockComponent,
images?: ImageBlockModel[]
) {
if (images?.length !== 1) return nothing;
return html`
<edgeless-change-image-button
.model=${images[0]}
.edgeless=${edgeless}
></edgeless-change-image-button>
`;
}
@@ -0,0 +1,297 @@
import {
MindmapStyleFour,
MindmapStyleOne,
MindmapStyleThree,
MindmapStyleTwo,
} from '@blocksuite/affine-block-surface';
import { renderToolbarSeparator } from '@blocksuite/affine-components/toolbar';
import type {
MindmapElementModel,
ShapeElementModel,
} from '@blocksuite/affine-model';
import { LayoutType, MindmapStyle } from '@blocksuite/affine-model';
import { EditPropsStore } from '@blocksuite/affine-shared/services';
import { countBy, maxBy, WithDisposable } from '@blocksuite/global/utils';
import { RadiantIcon, RightLayoutIcon, StyleIcon } from '@blocksuite/icons/lit';
import { css, html, LitElement, nothing, type TemplateResult } from 'lit';
import { property, state } from 'lit/decorators.js';
import { join } from 'lit/directives/join.js';
import { repeat } from 'lit/directives/repeat.js';
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
import { SmallArrowDownIcon } from './icons.js';
const iconSize = { width: '20', height: '20' };
const MINDMAP_STYLE_LIST = [
{
value: MindmapStyle.ONE,
icon: MindmapStyleOne,
},
{
value: MindmapStyle.FOUR,
icon: MindmapStyleFour,
},
{
value: MindmapStyle.THREE,
icon: MindmapStyleThree,
},
{
value: MindmapStyle.TWO,
icon: MindmapStyleTwo,
},
];
interface LayoutItem {
name: string;
value: LayoutType;
icon: TemplateResult<1>;
}
const MINDMAP_LAYOUT_LIST: LayoutItem[] = [
{
name: 'Left',
value: LayoutType.LEFT,
icon: RightLayoutIcon({
...iconSize,
style: 'transform: rotate(0.5turn); transform-origin: center;',
}),
},
{
name: 'Radial',
value: LayoutType.BALANCE,
icon: RadiantIcon(iconSize),
},
{
name: 'Right',
value: LayoutType.RIGHT,
icon: RightLayoutIcon(iconSize),
},
] as const;
export class EdgelessChangeMindmapStylePanel extends LitElement {
static override styles = css`
:host {
display: flex;
align-items: center;
justify-content: center;
flex-direction: row;
gap: 8px;
background: var(--affine-background-overlay-panel-color);
}
.style-item {
border-radius: 4px;
}
.style-item > svg {
vertical-align: middle;
}
.style-item.active,
.style-item:hover {
cursor: pointer;
background-color: var(--affine-hover-color);
}
`;
override render() {
return repeat(
MINDMAP_STYLE_LIST,
item => item.value,
({ value, icon }) => html`
<div
role="button"
class="style-item ${value === this.mindmapStyle ? 'active' : ''}"
@click=${() => this.onSelect(value)}
>
${icon}
</div>
`
);
}
@property({ attribute: false })
accessor mindmapStyle!: MindmapStyle | null;
@property({ attribute: false })
accessor onSelect!: (style: MindmapStyle) => void;
}
export class EdgelessChangeMindmapLayoutPanel extends LitElement {
static override styles = css`
:host {
display: flex;
align-items: center;
justify-content: center;
flex-direction: row;
gap: 8px;
}
`;
override render() {
return repeat(
MINDMAP_LAYOUT_LIST,
item => item.value,
({ name, value, icon }) => html`
<editor-icon-button
aria-label=${name}
.tooltip=${name}
.tipPosition=${'top'}
.active=${this.mindmapLayout === value}
.activeMode=${'background'}
@click=${() => this.onSelect(value)}
>
${icon}
</editor-icon-button>
`
);
}
@property({ attribute: false })
accessor mindmapLayout!: LayoutType | null;
@property({ attribute: false })
accessor onSelect!: (style: LayoutType) => void;
}
export class EdgelessChangeMindmapButton extends WithDisposable(LitElement) {
private readonly _updateLayoutType = (layoutType: LayoutType) => {
this.edgeless.std.get(EditPropsStore).recordLastProps('mindmap', {
layoutType,
});
this.elements.forEach(element => {
element.layoutType = layoutType;
element.layout();
});
this.layoutType = layoutType;
};
private readonly _updateStyle = (style: MindmapStyle) => {
this.edgeless.std.get(EditPropsStore).recordLastProps('mindmap', { style });
this._mindmaps.forEach(element => (element.style = style));
};
private get _mindmaps() {
const mindmaps = new Set<MindmapElementModel>();
return this.elements.reduce((_, el) => {
mindmaps.add(el);
return mindmaps;
}, mindmaps);
}
get layout() {
const layoutType = this.layoutType ?? this._getCommonLayoutType();
return MINDMAP_LAYOUT_LIST.find(item => item.value === layoutType)!;
}
private _getCommonLayoutType() {
const values = countBy(this.elements, element => element.layoutType);
const max = maxBy(Object.entries(values), ([_k, count]) => count);
return max ? (Number(max[0]) as LayoutType) : LayoutType.BALANCE;
}
private _getCommonStyle() {
const values = countBy(this.elements, element => element.style);
const max = maxBy(Object.entries(values), ([_k, count]) => count);
return max ? (Number(max[0]) as MindmapStyle) : MindmapStyle.ONE;
}
private _isSubnode() {
return (
this.nodes.length === 1 &&
(this.nodes[0].group as MindmapElementModel).tree.element !==
this.nodes[0]
);
}
override render() {
return join(
[
html`
<editor-menu-button
.contentPadding=${'8px'}
.button=${html`
<editor-icon-button aria-label="Style" .tooltip=${'Style'}>
${StyleIcon(iconSize)}${SmallArrowDownIcon}
</editor-icon-button>
`}
>
<edgeless-change-mindmap-style-panel
.mindmapStyle=${this._getCommonStyle()}
.onSelect=${this._updateStyle}
>
</edgeless-change-mindmap-style-panel>
</editor-menu-button>
`,
this._isSubnode()
? nothing
: html`
<editor-menu-button
.button=${html`
<editor-icon-button aria-label="Layout" .tooltip=${'Layout'}>
${this.layout.icon}${SmallArrowDownIcon}
</editor-icon-button>
`}
>
<edgeless-change-mindmap-layout-panel
.mindmapLayout=${this.layout.value}
.onSelect=${this._updateLayoutType}
>
</edgeless-change-mindmap-layout-panel>
</editor-menu-button>
`,
].filter(button => button !== nothing),
renderToolbarSeparator
);
}
@property({ attribute: false })
accessor edgeless!: EdgelessRootBlockComponent;
@property({ attribute: false })
accessor elements!: MindmapElementModel[];
@state()
accessor layoutType!: LayoutType;
@property({ attribute: false })
accessor nodes!: ShapeElementModel[];
}
export function renderMindmapButton(
edgeless: EdgelessRootBlockComponent,
elements?: (ShapeElementModel | MindmapElementModel)[]
) {
if (!elements?.length) return nothing;
const mindmaps: MindmapElementModel[] = [];
elements.forEach(e => {
if (e.type === 'mindmap') {
mindmaps.push(e as MindmapElementModel);
}
const group = edgeless.service.surface.getGroup(e.id);
if (group && 'type' in group && group.type === 'mindmap') {
mindmaps.push(group as MindmapElementModel);
}
});
if (mindmaps.length === 0) {
return nothing;
}
return html`
<edgeless-change-mindmap-button
.elements=${mindmaps}
.nodes=${elements.filter(e => e.type === 'shape')}
.edgeless=${edgeless}
>
</edgeless-change-mindmap-button>
`;
}
@@ -0,0 +1,616 @@
import {
changeNoteDisplayMode,
NoteConfigExtension,
} from '@blocksuite/affine-block-note';
import { EdgelessCRUDIdentifier } from '@blocksuite/affine-block-surface';
import type {
EdgelessColorPickerButton,
PickColorEvent,
} from '@blocksuite/affine-components/color-picker';
import {
packColor,
packColorsWithColorScheme,
} from '@blocksuite/affine-components/color-picker';
import {
type EditorMenuButton,
renderToolbarSeparator,
} from '@blocksuite/affine-components/toolbar';
import {
type ColorScheme,
DefaultTheme,
type NoteBlockModel,
NoteDisplayMode,
resolveColor,
type StrokeStyle,
} from '@blocksuite/affine-model';
import {
FeatureFlagService,
NotificationProvider,
SidebarExtensionIdentifier,
TelemetryProvider,
ThemeProvider,
} from '@blocksuite/affine-shared/services';
import {
Bound,
countBy,
maxBy,
WithDisposable,
} from '@blocksuite/global/utils';
import {
AutoHeightIcon,
CornerIcon,
CustomizedHeightIcon,
LineStyleIcon,
LinkedPageIcon,
NoteShadowDuotoneIcon,
ScissorsIcon,
} from '@blocksuite/icons/lit';
import { html, LitElement, nothing, type TemplateResult } from 'lit';
import { property, query } from 'lit/decorators.js';
import { join } from 'lit/directives/join.js';
import { createRef, type Ref, ref } from 'lit/directives/ref.js';
import { when } from 'lit/directives/when.js';
import {
type LineStyleEvent,
LineStylesPanel,
} from '../../edgeless/components/panel/line-styles-panel.js';
import { getTooltipWithShortcut } from '../../edgeless/components/utils.js';
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
import { SmallArrowDownIcon } from './icons.js';
import * as styles from './styles.css';
const SIZE_LIST = [
{ name: 'None', value: 0 },
{ name: 'Small', value: 8 },
{ name: 'Medium', value: 16 },
{ name: 'Large', value: 24 },
{ name: 'Huge', value: 32 },
] as const;
const DisplayModeMap = {
[NoteDisplayMode.DocAndEdgeless]: 'Both',
[NoteDisplayMode.EdgelessOnly]: 'Edgeless',
[NoteDisplayMode.DocOnly]: 'Page',
} as const satisfies Record<NoteDisplayMode, string>;
function getMostCommonBackground(
elements: NoteBlockModel[],
colorScheme: ColorScheme
): string {
const colors = countBy(elements, (ele: NoteBlockModel) =>
resolveColor(ele.background, colorScheme)
);
const max = maxBy(Object.entries(colors), ([_k, count]) => count);
return max
? (max[0] as string)
: resolveColor(DefaultTheme.noteBackgrounColor, colorScheme);
}
export class EdgelessChangeNoteButton extends WithDisposable(LitElement) {
get crud() {
return this.edgeless.std.get(EdgelessCRUDIdentifier);
}
private readonly _setBackground = (background: string) => {
this.notes.forEach(element => {
this.crud.updateElement(element.id, { background });
});
};
private readonly _setBorderRadius = (borderRadius: number) => {
this.notes.forEach(note => {
const props = {
edgeless: {
...note.edgeless,
style: {
...note.edgeless.style,
borderRadius,
},
},
};
this.crud.updateElement(note.id, props);
});
};
private readonly _setNoteScale = (scale: number) => {
this.notes.forEach(note => {
this.doc.updateBlock(note, () => {
const bound = Bound.deserialize(note.xywh);
const oldScale = note.edgeless.scale ?? 1;
const ratio = scale / oldScale;
bound.w *= ratio;
bound.h *= ratio;
const xywh = bound.serialize();
note.xywh = xywh;
note.edgeless.scale = scale;
});
});
};
pickColor = (e: PickColorEvent) => {
const field = 'background';
if (e.type === 'pick') {
const color = e.detail.value;
this.notes.forEach(element => {
const props = packColor(field, color);
this.crud.updateElement(element.id, props);
});
return;
}
this.notes.forEach(ele => ele[e.type === 'start' ? 'stash' : 'pop'](field));
};
private get _advancedVisibilityEnabled() {
return this.doc
.get(FeatureFlagService)
.getFlag('enable_advanced_block_visibility');
}
private get doc() {
return this.edgeless.doc;
}
private _getScaleLabel(scale: number) {
return Math.round(scale * 100) + '%';
}
private _handleNoteSlicerButtonClick() {
const surfaceService = this.edgeless.service;
if (!surfaceService) return;
this.edgeless.slots.toggleNoteSlicer.emit();
}
private _setCollapse() {
this.doc.captureSync();
this.notes.forEach(note => {
const { collapse, collapsedHeight } = note.edgeless;
if (collapse) {
this.doc.updateBlock(note, () => {
note.edgeless.collapse = false;
});
} else if (collapsedHeight) {
const { xywh, edgeless } = note;
const bound = Bound.deserialize(xywh);
bound.h = collapsedHeight * (edgeless.scale ?? 1);
this.doc.updateBlock(note, () => {
note.edgeless.collapse = true;
note.xywh = bound.serialize();
});
}
});
this.requestUpdate();
}
private _setDisplayMode(note: NoteBlockModel, newMode: NoteDisplayMode) {
const oldMode = note.displayMode;
this.edgeless.std.command.exec(changeNoteDisplayMode, {
noteId: note.id,
mode: newMode,
stopCapture: true,
});
// if change note to page only, should clear the selection
if (newMode === NoteDisplayMode.DocOnly) {
this.edgeless.service.selection.clear();
}
const abortController = new AbortController();
const clear = () => {
this.doc.history.off('stack-item-added', addHandler);
this.doc.history.off('stack-item-popped', popHandler);
disposable.dispose();
};
const closeNotify = () => {
abortController.abort();
clear();
};
const addHandler = this.doc.history.on('stack-item-added', closeNotify);
const popHandler = this.doc.history.on('stack-item-popped', closeNotify);
const disposable = this.edgeless.std.host.slots.unmounted.on(closeNotify);
const undo = () => {
this.doc.undo();
closeNotify();
};
const viewInToc = () => {
const sidebar = this.edgeless.std.getOptional(SidebarExtensionIdentifier);
sidebar?.open('outline');
closeNotify();
};
const title =
newMode !== NoteDisplayMode.EdgelessOnly
? 'Note displayed in Page Mode'
: 'Note removed from Page Mode';
const message =
newMode !== NoteDisplayMode.EdgelessOnly
? 'Content added to your page.'
: 'Content removed from your page.';
const notification = this.edgeless.std.getOptional(NotificationProvider);
notification?.notify({
title: title,
message: `${message}. Find it in the TOC for quick navigation.`,
accent: 'success',
duration: 5 * 1000,
footer: html`<div class=${styles.viewInPageNotifyFooter}>
<button
class=${styles.viewInPageNotifyFooterButton}
@click=${undo}
data-testid="undo-display-in-page"
>
Undo
</button>
<button
class=${styles.viewInPageNotifyFooterButton}
@click=${viewInToc}
data-testid="view-in-toc"
>
View in Toc
</button>
</div>`,
abort: abortController.signal,
onClose: () => {
clear();
},
});
this.edgeless.std
.getOptional(TelemetryProvider)
?.track('NoteDisplayModeChanged', {
page: 'whiteboard editor',
segment: 'element toolbar',
module: 'element toolbar',
control: 'display mode',
type: 'note',
other: `from ${oldMode} to ${newMode}`,
});
}
private _setShadowType(shadowType: string) {
this.notes.forEach(note => {
const props = {
edgeless: {
...note.edgeless,
style: {
...note.edgeless.style,
shadowType,
},
},
};
this.crud.updateElement(note.id, props);
});
}
private _setStrokeStyle(borderStyle: StrokeStyle) {
this.notes.forEach(note => {
const props = {
edgeless: {
...note.edgeless,
style: {
...note.edgeless.style,
borderStyle,
},
},
};
this.crud.updateElement(note.id, props);
});
}
private _setStrokeWidth(borderSize: number) {
this.notes.forEach(note => {
const props = {
edgeless: {
...note.edgeless,
style: {
...note.edgeless.style,
borderSize,
},
},
};
this.crud.updateElement(note.id, props);
});
}
private _setStyles({ type, value }: LineStyleEvent) {
if (type === 'size') {
this._setStrokeWidth(value);
return;
}
if (type === 'lineStyle') {
this._setStrokeStyle(value);
}
}
override render() {
const len = this.notes.length;
const note = this.notes[0];
const { edgeless, displayMode } = note;
const { shadowType, borderRadius, borderSize, borderStyle } =
edgeless.style;
const colorScheme = this.edgeless.surface.renderer.getColorScheme();
const background = getMostCommonBackground(this.notes, colorScheme);
const { collapse } = edgeless;
const scale = edgeless.scale ?? 1;
const currentMode = DisplayModeMap[displayMode];
const onlyOne = len === 1;
const isDocOnly = displayMode === NoteDisplayMode.DocOnly;
const hasPageBlockHeader = !!this.edgeless.std.getOptional(
NoteConfigExtension.identifier
)?.edgelessNoteHeader;
const theme = this.edgeless.std.get(ThemeProvider).theme;
const buttonIconSize = { width: '20px', height: '20px' };
const buttons = [
onlyOne && this._advancedVisibilityEnabled
? html`
<span class="display-mode-button-label">Show in</span>
<editor-menu-button
.contentPadding=${'8px'}
.button=${html`
<editor-icon-button
aria-label="Mode"
.tooltip=${'Display mode'}
.justify=${'space-between'}
.labelHeight=${'20px'}
>
<span class="label">${currentMode}</span>
${SmallArrowDownIcon}
</editor-icon-button>
`}
>
<note-display-mode-panel
.displayMode=${displayMode}
.onSelect=${(newMode: NoteDisplayMode) =>
this._setDisplayMode(note, newMode)}
>
</note-display-mode-panel>
</editor-menu-button>
`
: nothing,
onlyOne && !note.isPageBlock() && !this._advancedVisibilityEnabled
? html`<editor-icon-button
aria-label="Display In Page"
.showTooltip=${displayMode === NoteDisplayMode.DocAndEdgeless}
.tooltip=${'This note is part of Page Mode. Click to remove it from the page.'}
data-testid="display-in-page"
@click=${() =>
this._setDisplayMode(
note,
displayMode === NoteDisplayMode.EdgelessOnly
? NoteDisplayMode.DocAndEdgeless
: NoteDisplayMode.EdgelessOnly
)}
>
${LinkedPageIcon(buttonIconSize)}
<span class="label"
>${displayMode === NoteDisplayMode.EdgelessOnly
? 'Display In Page'
: 'Displayed In Page'}</span
>
</editor-icon-button>`
: nothing,
isDocOnly
? nothing
: when(
this.edgeless.doc
.get(FeatureFlagService)
.getFlag('enable_color_picker'),
() => {
const { type, colors } = packColorsWithColorScheme(
colorScheme,
background,
note.background
);
return html`
<edgeless-color-picker-button
class="background"
.label=${'Background'}
.pick=${this.pickColor}
.color=${background}
.colorPanelClass=${'small'}
.colorType=${type}
.colors=${colors}
.theme=${colorScheme}
.palettes=${DefaultTheme.NoteBackgroundColorPalettes}
>
</edgeless-color-picker-button>
`;
},
() => html`
<editor-menu-button
.contentPadding=${'8px'}
.button=${html`
<editor-icon-button
aria-label="Background"
.tooltip=${'Background'}
>
<edgeless-color-button
.color=${background}
></edgeless-color-button>
</editor-icon-button>
`}
>
<edgeless-color-panel
class="small"
.value=${background}
.theme=${colorScheme}
.palettes=${DefaultTheme.NoteBackgroundColorPalettes}
@select=${this._setBackground}
>
</edgeless-color-panel>
</editor-menu-button>
`
),
isDocOnly
? nothing
: html`
<editor-menu-button
.contentPadding=${'6px'}
.button=${html`
<editor-icon-button
aria-label="Shadow style"
.tooltip=${'Shadow style'}
>
${NoteShadowDuotoneIcon(buttonIconSize)}${SmallArrowDownIcon}
</editor-icon-button>
`}
>
<edgeless-note-shadow-panel
.theme=${theme}
.value=${shadowType}
.background=${background}
.onSelect=${(value: string) => this._setShadowType(value)}
>
</edgeless-note-shadow-panel>
</editor-menu-button>
<editor-menu-button
.button=${html`
<editor-icon-button
aria-label="Border style"
.tooltip=${'Border style'}
>
${LineStyleIcon(buttonIconSize)}${SmallArrowDownIcon}
</editor-icon-button>
`}
>
<div data-orientation="horizontal">
${LineStylesPanel({
selectedLineSize: borderSize,
selectedLineStyle: borderStyle,
onClick: event => this._setStyles(event),
})}
</div>
</editor-menu-button>
<editor-menu-button
${ref(this._cornersPanelRef)}
.contentPadding=${'8px'}
.button=${html`
<editor-icon-button aria-label="Corners" .tooltip=${'Corners'}>
${CornerIcon(buttonIconSize)}${SmallArrowDownIcon}
</editor-icon-button>
`}
>
<edgeless-size-panel
.size=${borderRadius}
.sizeList=${SIZE_LIST}
.minSize=${0}
.onSelect=${(size: number) => this._setBorderRadius(size)}
.onPopperCose=${() => this._cornersPanelRef.value?.hide()}
>
</edgeless-size-panel>
</editor-menu-button>
`,
onlyOne && this._advancedVisibilityEnabled
? html`
<editor-icon-button
aria-label="Slicer"
.tooltip=${getTooltipWithShortcut('Cutting mode', '-')}
.active=${this.enableNoteSlicer}
.iconSize=${'20px'}
@click=${() => this._handleNoteSlicerButtonClick()}
>
${ScissorsIcon()}
</editor-icon-button>
`
: nothing,
onlyOne ? this.quickConnectButton : nothing,
!this.notes[0].isPageBlock() || !hasPageBlockHeader
? html`<editor-icon-button
aria-label="Size"
data-testid="edgeless-note-auto-height"
.tooltip=${collapse ? 'Auto height' : 'Customized height'}
.iconSize=${'20px'}
@click=${() => this._setCollapse()}
>
${collapse ? AutoHeightIcon() : CustomizedHeightIcon()}
</editor-icon-button>`
: nothing,
html`
<editor-menu-button
${ref(this._scalePanelRef)}
.contentPadding=${'8px'}
.button=${html`
<editor-icon-button
aria-label="Scale"
.tooltip=${'Scale'}
.justify=${'space-between'}
.labelHeight=${'20px'}
.iconContainerWidth=${'65px'}
>
<span class="label">${this._getScaleLabel(scale)}</span
>${SmallArrowDownIcon}
</editor-icon-button>
`}
>
<edgeless-scale-panel
.scale=${Math.round(scale * 100)}
.onSelect=${(scale: number) => this._setNoteScale(scale)}
.onPopperCose=${() => this._scalePanelRef.value?.hide()}
></edgeless-scale-panel>
</editor-menu-button>
`,
];
return join(
buttons.filter(button => button !== nothing),
renderToolbarSeparator
);
}
private accessor _cornersPanelRef: Ref<EditorMenuButton> = createRef();
private accessor _scalePanelRef: Ref<EditorMenuButton> = createRef();
@query('edgeless-color-picker-button.background')
accessor backgroundButton!: EdgelessColorPickerButton;
@property({ attribute: false })
accessor edgeless!: EdgelessRootBlockComponent;
@property({ attribute: false })
accessor enableNoteSlicer!: boolean;
@property({ attribute: false })
accessor notes: NoteBlockModel[] = [];
@property({ attribute: false })
accessor quickConnectButton!: TemplateResult<1> | typeof nothing;
}
export function renderNoteButton(
edgeless: EdgelessRootBlockComponent,
notes?: NoteBlockModel[],
quickConnectButton?: TemplateResult<1>[]
) {
if (!notes?.length) return nothing;
return html`
<edgeless-change-note-button
.notes=${notes}
.edgeless=${edgeless}
.enableNoteSlicer=${false}
.quickConnectButton=${quickConnectButton?.pop() ?? nothing}
>
</edgeless-change-note-button>
`;
}
@@ -0,0 +1,510 @@
import { EdgelessCRUDIdentifier } from '@blocksuite/affine-block-surface';
import type {
EdgelessColorPickerButton,
PickColorEvent,
} from '@blocksuite/affine-components/color-picker';
import {
packColor,
packColorsWithColorScheme,
} from '@blocksuite/affine-components/color-picker';
import { renderToolbarSeparator } from '@blocksuite/affine-components/toolbar';
import type {
Color,
ColorScheme,
ShapeElementModel,
ShapeProps,
} from '@blocksuite/affine-model';
import {
DefaultTheme,
FontFamily,
getShapeName,
getShapeRadius,
getShapeType,
isTransparent,
LineWidth,
MindmapElementModel,
resolveColor,
ShapeStyle,
StrokeStyle,
} from '@blocksuite/affine-model';
import { FeatureFlagService } from '@blocksuite/affine-shared/services';
import type { ColorEvent } from '@blocksuite/affine-shared/utils';
import { countBy, maxBy, WithDisposable } from '@blocksuite/global/utils';
import {
AddTextIcon,
ShapeIcon,
StyleGeneralIcon,
StyleScribbleIcon,
} from '@blocksuite/icons/lit';
import { css, html, LitElement, nothing, type TemplateResult } from 'lit';
import { property, query } from 'lit/decorators.js';
import { cache } from 'lit/directives/cache.js';
import { choose } from 'lit/directives/choose.js';
import { join } from 'lit/directives/join.js';
import { styleMap } from 'lit/directives/style-map.js';
import { when } from 'lit/directives/when.js';
import { isEqual } from 'lodash-es';
import {
type LineStyleEvent,
LineStylesPanel,
} from '../../edgeless/components/panel/line-styles-panel.js';
import type { EdgelessShapePanel } from '../../edgeless/components/panel/shape-panel.js';
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
import type { ShapeToolOption } from '../../edgeless/gfx-tool/shape-tool.js';
import { mountShapeTextEditor } from '../../edgeless/utils/text.js';
import { SmallArrowDownIcon } from './icons.js';
const changeShapeButtonStyles = [
css`
.edgeless-component-line-size-button {
display: flex;
justify-content: center;
align-items: center;
width: 16px;
height: 16px;
}
.edgeless-component-line-size-button div {
border-radius: 50%;
background-color: var(--affine-icon-color);
}
.edgeless-component-line-size-button.size-s div {
width: 4px;
height: 4px;
}
.edgeless-component-line-size-button.size-l div {
width: 10px;
height: 10px;
}
`,
];
function getMostCommonFillColor(
elements: ShapeElementModel[],
colorScheme: ColorScheme
): string {
const colors = countBy(elements, (ele: ShapeElementModel) =>
ele.filled ? resolveColor(ele.fillColor, colorScheme) : 'transparent'
);
const max = maxBy(Object.entries(colors), ([_k, count]) => count);
return max
? (max[0] as string)
: resolveColor(DefaultTheme.shapeFillColor, colorScheme);
}
function getMostCommonStrokeColor(
elements: ShapeElementModel[],
colorScheme: ColorScheme
): string {
const colors = countBy(elements, (ele: ShapeElementModel) =>
resolveColor(ele.strokeColor, colorScheme)
);
const max = maxBy(Object.entries(colors), ([_k, count]) => count);
return max
? (max[0] as string)
: resolveColor(DefaultTheme.shapeStrokeColor, colorScheme);
}
function getMostCommonShape(
elements: ShapeElementModel[]
): ShapeToolOption['shapeName'] | null {
const shapeTypes = countBy(elements, (ele: ShapeElementModel) =>
getShapeName(ele.shapeType, ele.radius)
);
const max = maxBy(Object.entries(shapeTypes), ([_k, count]) => count);
return max ? (max[0] as ShapeToolOption['shapeName']) : null;
}
function getMostCommonLineSize(elements: ShapeElementModel[]): LineWidth {
const sizes = countBy(elements, (ele: ShapeElementModel) => ele.strokeWidth);
const max = maxBy(Object.entries(sizes), ([_k, count]) => count);
return max ? (Number(max[0]) as LineWidth) : LineWidth.Four;
}
function getMostCommonLineStyle(elements: ShapeElementModel[]): StrokeStyle {
const sizes = countBy(elements, (ele: ShapeElementModel) => ele.strokeStyle);
const max = maxBy(Object.entries(sizes), ([_k, count]) => count);
return max ? (max[0] as StrokeStyle) : StrokeStyle.Solid;
}
function getMostCommonShapeStyle(elements: ShapeElementModel[]): ShapeStyle {
const roughnesses = countBy(
elements,
(ele: ShapeElementModel) => ele.shapeStyle
);
const max = maxBy(Object.entries(roughnesses), ([_k, count]) => count);
return max ? (max[0] as ShapeStyle) : ShapeStyle.Scribbled;
}
export class EdgelessChangeShapeButton extends WithDisposable(LitElement) {
static override styles = [changeShapeButtonStyles];
private readonly _setShapeFillColor = (e: ColorEvent) => {
const fillColor = e.detail.value;
const filled = !isTransparent(fillColor);
const color = this._getTextColor(fillColor, filled);
this.elements.forEach(ele =>
this.crud.updateElement(ele.id, { filled, fillColor, color })
);
};
private readonly _setShapeStrokeColor = (e: ColorEvent) => {
const strokeColor = e.detail.value;
this.elements.forEach(ele =>
this.crud.updateElement(ele.id, { strokeColor })
);
};
private readonly _setShapeStyles = ({ type, value }: LineStyleEvent) => {
if (type === 'size') {
this._setShapeStrokeWidth(value);
return;
}
if (type === 'lineStyle') {
this._setShapeStrokeStyle(value);
}
};
get service() {
return this.edgeless.service;
}
get crud() {
return this.edgeless.std.get(EdgelessCRUDIdentifier);
}
private _addText() {
mountShapeTextEditor(this.elements[0], this.edgeless);
}
private _getTextColor(fillColor: Color, isNotTransparent = false) {
// When the shape is filled with black color, the text color should be white.
// When the shape is transparent, the text color should be set according to the theme.
// Otherwise, the text color should be black.
if (isNotTransparent) {
if (isEqual(fillColor, DefaultTheme.black)) {
return DefaultTheme.white;
} else if (isEqual(fillColor, DefaultTheme.white)) {
return DefaultTheme.black;
}
}
return DefaultTheme.black;
}
private _setShapeStrokeStyle(strokeStyle: StrokeStyle) {
this.elements.forEach(ele =>
this.crud.updateElement(ele.id, { strokeStyle })
);
}
private _setShapeStrokeWidth(strokeWidth: number) {
this.elements.forEach(ele =>
this.crud.updateElement(ele.id, { strokeWidth })
);
}
private _setShapeStyle(shapeStyle: ShapeStyle) {
const fontFamily =
shapeStyle === ShapeStyle.General ? FontFamily.Inter : FontFamily.Kalam;
this.elements.forEach(ele => {
this.crud.updateElement(ele.id, { shapeStyle, fontFamily });
});
}
private _showAddButtonOrTextMenu() {
if (this.elements.length === 1 && !this.elements[0].text) {
return 'button';
}
if (!this.elements.some(e => !e.text)) {
return 'menu';
}
return 'nothing';
}
override firstUpdated() {
const _disposables = this._disposables;
_disposables.add(
this._shapePanel.slots.select.on(shapeName => {
this.edgeless.doc.captureSync();
this.elements.forEach(element => {
this.crud.updateElement(element.id, {
shapeType: getShapeType(shapeName),
radius: getShapeRadius(shapeName),
});
});
})
);
}
pickColor<K extends keyof Pick<ShapeProps, 'fillColor' | 'strokeColor'>>(
field: K
) {
return (e: PickColorEvent) => {
if (e.type === 'pick') {
const color = e.detail.value;
this.elements.forEach(ele => {
const props = packColor(field, color);
// If `filled` can be set separately, this logic can be removed
if (field === 'fillColor' && !ele.filled) {
Object.assign(props, { filled: true });
}
this.crud.updateElement(ele.id, props);
});
return;
}
this.elements.forEach(ele =>
ele[e.type === 'start' ? 'stash' : 'pop'](field)
);
};
}
override render() {
const colorScheme = this.edgeless.surface.renderer.getColorScheme();
const elements = this.elements;
const selectedShape = getMostCommonShape(elements);
const selectedFillColor = getMostCommonFillColor(elements, colorScheme);
const selectedStrokeColor = getMostCommonStrokeColor(elements, colorScheme);
const selectedLineSize = getMostCommonLineSize(elements);
const selectedLineStyle = getMostCommonLineStyle(elements);
const selectedShapeStyle = getMostCommonShapeStyle(elements);
const iconSize = { width: '20px', height: '20px' };
return join(
[
html`
<editor-menu-button
.button=${html`
<editor-icon-button
aria-label="Switch type"
.tooltip=${'Switch type'}
>
${ShapeIcon(iconSize)}${SmallArrowDownIcon}
</editor-icon-button>
`}
>
<edgeless-shape-panel
.selectedShape=${selectedShape}
.shapeStyle=${selectedShapeStyle}
>
</edgeless-shape-panel>
</editor-menu-button>
`,
html`
<editor-menu-button
.button=${html`
<editor-icon-button aria-label="Style" .tooltip=${'Style'}>
${cache(
selectedShapeStyle === ShapeStyle.General
? StyleGeneralIcon(iconSize)
: StyleScribbleIcon(iconSize)
)}
${SmallArrowDownIcon}
</editor-icon-button>
`}
>
<edgeless-shape-style-panel
.value=${selectedShapeStyle}
.onSelect=${(value: ShapeStyle) => this._setShapeStyle(value)}
>
</edgeless-shape-style-panel>
</editor-menu-button>
`,
when(
this.edgeless.doc
.get(FeatureFlagService)
.getFlag('enable_color_picker'),
() => {
const { type, colors } = packColorsWithColorScheme(
colorScheme,
selectedFillColor,
elements[0].fillColor
);
return html`
<edgeless-color-picker-button
class="fill-color"
.label=${'Fill color'}
.pick=${this.pickColor('fillColor')}
.color=${selectedFillColor}
.colors=${colors}
.colorType=${type}
.theme=${colorScheme}
.palettes=${DefaultTheme.Palettes}
>
</edgeless-color-picker-button>
`;
},
() => html`
<editor-menu-button
.contentPadding=${'8px'}
.button=${html`
<editor-icon-button
aria-label="Fill color"
.tooltip=${'Fill color'}
>
<edgeless-color-button
.color=${selectedFillColor}
></edgeless-color-button>
</editor-icon-button>
`}
>
<edgeless-color-panel
role="listbox"
aria-label="Fill colors"
.value=${selectedFillColor}
.theme=${colorScheme}
.palettes=${DefaultTheme.Palettes}
@select=${this._setShapeFillColor}
>
</edgeless-color-panel>
</editor-menu-button>
`
),
when(
this.edgeless.doc
.get(FeatureFlagService)
.getFlag('enable_color_picker'),
() => {
const { type, colors } = packColorsWithColorScheme(
colorScheme,
selectedStrokeColor,
elements[0].strokeColor
);
return html`
<edgeless-color-picker-button
class="border-style"
.label=${'Border style'}
.pick=${this.pickColor('strokeColor')}
.color=${selectedStrokeColor}
.colors=${colors}
.colorType=${type}
.theme=${colorScheme}
.palettes=${DefaultTheme.Palettes}
.hollowCircle=${true}
>
<div
slot="other"
class="line-styles"
style=${styleMap({
display: 'flex',
flexDirection: 'row',
gap: '8px',
alignItems: 'center',
})}
>
${LineStylesPanel({
selectedLineSize: selectedLineSize,
selectedLineStyle: selectedLineStyle,
onClick: this._setShapeStyles,
})}
</div>
<editor-toolbar-separator
slot="separator"
data-orientation="horizontal"
></editor-toolbar-separator>
</edgeless-color-picker-button>
`;
},
() => html`
<editor-menu-button
.contentPadding=${'8px'}
.button=${html`
<editor-icon-button
aria-label="Border style"
.tooltip=${'Border style'}
>
<edgeless-color-button
.color=${selectedStrokeColor}
.hollowCircle=${true}
></edgeless-color-button>
</editor-icon-button>
`}
>
<stroke-style-panel
.theme=${colorScheme}
.hollowCircle=${true}
.strokeWidth=${selectedLineSize}
.strokeStyle=${selectedLineStyle}
.strokeColor=${selectedStrokeColor}
.setStrokeStyle=${this._setShapeStyles}
.setStrokeColor=${this._setShapeStrokeColor}
>
</stroke-style-panel>
</editor-menu-button>
`
),
choose<string, TemplateResult<1> | typeof nothing>(
this._showAddButtonOrTextMenu(),
[
[
'button',
() => html`
<editor-icon-button
aria-label="Add text"
.tooltip=${'Add text'}
.iconSize=${'20px'}
@click=${this._addText}
>
${AddTextIcon()}
</editor-icon-button>
`,
],
[
'menu',
() => html`
<edgeless-change-text-menu
.elementType=${'shape'}
.elements=${elements}
.edgeless=${this.edgeless}
></edgeless-change-text-menu>
`,
],
['nothing', () => nothing],
]
),
].filter(button => button !== nothing),
renderToolbarSeparator
);
}
@query('edgeless-shape-panel')
private accessor _shapePanel!: EdgelessShapePanel;
@query('edgeless-color-picker-button.border-style')
accessor borderStyleButton!: EdgelessColorPickerButton;
@property({ attribute: false })
accessor edgeless!: EdgelessRootBlockComponent;
@property({ attribute: false })
accessor elements: ShapeElementModel[] = [];
@query('edgeless-color-picker-button.fill-color')
accessor fillColorButton!: EdgelessColorPickerButton;
}
export function renderChangeShapeButton(
edgeless: EdgelessRootBlockComponent,
elements?: ShapeElementModel[]
) {
if (!elements?.length) return nothing;
if (elements.some(e => e.group instanceof MindmapElementModel))
return nothing;
return html`
<edgeless-change-shape-button .elements=${elements} .edgeless=${edgeless}>
</edgeless-change-shape-button>
`;
}
@@ -0,0 +1,19 @@
import type { TextElementModel } from '@blocksuite/affine-model';
import { html, nothing } from 'lit';
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
export function renderChangeTextButton(
edgeless: EdgelessRootBlockComponent,
elements?: TextElementModel[]
) {
if (!elements?.length) return nothing;
return html`
<edgeless-change-text-menu
.elementType=${'text'}
.elements=${elements}
.edgeless=${edgeless}
></edgeless-change-text-menu>
`;
}
@@ -0,0 +1,517 @@
import {
ConnectorUtils,
EdgelessCRUDIdentifier,
normalizeShapeBound,
TextUtils,
} from '@blocksuite/affine-block-surface';
import type {
EdgelessColorPickerButton,
PickColorEvent,
} from '@blocksuite/affine-components/color-picker';
import {
packColor,
packColorsWithColorScheme,
} from '@blocksuite/affine-components/color-picker';
import { renderToolbarSeparator } from '@blocksuite/affine-components/toolbar';
import {
type ColorScheme,
ConnectorElementModel,
DefaultTheme,
EdgelessTextBlockModel,
FontFamily,
FontStyle,
FontWeight,
resolveColor,
ShapeElementModel,
type SurfaceTextModel,
type SurfaceTextModelMap,
TextAlign,
TextElementModel,
type TextStyleProps,
} from '@blocksuite/affine-model';
import { FeatureFlagService } from '@blocksuite/affine-shared/services';
import type { ColorEvent } from '@blocksuite/affine-shared/utils';
import {
Bound,
countBy,
maxBy,
WithDisposable,
} from '@blocksuite/global/utils';
import {
TextAlignCenterIcon,
TextAlignLeftIcon,
TextAlignRightIcon,
} from '@blocksuite/icons/lit';
import { css, html, LitElement, nothing, type TemplateResult } from 'lit';
import { property, query } from 'lit/decorators.js';
import { choose } from 'lit/directives/choose.js';
import { join } from 'lit/directives/join.js';
import { when } from 'lit/directives/when.js';
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
import { SmallArrowDownIcon } from './icons.js';
const FONT_SIZE_LIST = [
{ value: 16 },
{ value: 24 },
{ value: 32 },
{ value: 40 },
{ value: 64 },
{ value: 128 },
] as const;
const FONT_WEIGHT_CHOOSE: [FontWeight, () => string][] = [
[FontWeight.Light, () => 'Light'],
[FontWeight.Regular, () => 'Regular'],
[FontWeight.SemiBold, () => 'Semibold'],
] as const;
const FONT_STYLE_CHOOSE: [FontStyle, () => string | typeof nothing][] = [
[FontStyle.Normal, () => nothing],
[FontStyle.Italic, () => 'Italic'],
] as const;
const iconSize = { width: '20px', height: '20px' };
const TEXT_ALIGN_CHOOSE: [TextAlign, () => TemplateResult<1>][] = [
[TextAlign.Left, () => TextAlignLeftIcon(iconSize)],
[TextAlign.Center, () => TextAlignCenterIcon(iconSize)],
[TextAlign.Right, () => TextAlignRightIcon(iconSize)],
] as const;
function countByField<K extends keyof Omit<TextStyleProps, 'color'>>(
elements: SurfaceTextModel[],
field: K
) {
return countBy(elements, element => extractField(element, field));
}
function extractField<K extends keyof Omit<TextStyleProps, 'color'>>(
element: SurfaceTextModel,
field: K
) {
//TODO: It's not a very good handling method.
// The edgeless-change-text-menu should be refactored into a widget to allow external registration of its own logic.
if (element instanceof EdgelessTextBlockModel) {
return field === 'fontSize'
? null
: (element[field as keyof EdgelessTextBlockModel] as TextStyleProps[K]);
}
return (
element instanceof ConnectorElementModel
? element.labelStyle[field]
: element[field]
) as TextStyleProps[K];
}
function getMostCommonValue<K extends keyof Omit<TextStyleProps, 'color'>>(
elements: SurfaceTextModel[],
field: K
) {
const values = countByField(elements, field);
return maxBy(Object.entries(values), ([_k, count]) => count);
}
function getMostCommonAlign(elements: SurfaceTextModel[]) {
const max = getMostCommonValue(elements, 'textAlign');
return max ? (max[0] as TextAlign) : TextAlign.Left;
}
function getMostCommonColor(
elements: SurfaceTextModel[],
colorScheme: ColorScheme
): string {
const colors = countBy(elements, (ele: SurfaceTextModel) => {
const color =
ele instanceof ConnectorElementModel ? ele.labelStyle.color : ele.color;
return resolveColor(color, colorScheme);
});
const max = maxBy(Object.entries(colors), ([_k, count]) => count);
return max
? (max[0] as string)
: resolveColor(DefaultTheme.textColor, colorScheme);
}
function getMostCommonFontFamily(elements: SurfaceTextModel[]) {
const max = getMostCommonValue(elements, 'fontFamily');
return max ? (max[0] as FontFamily) : FontFamily.Inter;
}
function getMostCommonFontSize(elements: SurfaceTextModel[]) {
const max = getMostCommonValue(elements, 'fontSize');
return max ? Number(max[0]) : FONT_SIZE_LIST[0].value;
}
function getMostCommonFontStyle(elements: SurfaceTextModel[]) {
const max = getMostCommonValue(elements, 'fontStyle');
return max ? (max[0] as FontStyle) : FontStyle.Normal;
}
function getMostCommonFontWeight(elements: SurfaceTextModel[]) {
const max = getMostCommonValue(elements, 'fontWeight');
return max ? (max[0] as FontWeight) : FontWeight.Regular;
}
function buildProps(
element: SurfaceTextModel,
props: { [K in keyof TextStyleProps]?: TextStyleProps[K] }
) {
if (element instanceof ConnectorElementModel) {
return {
labelStyle: {
...element.labelStyle,
...props,
},
};
}
return { ...props };
}
export class EdgelessChangeTextMenu extends WithDisposable(LitElement) {
static override styles = css`
:host {
display: inherit;
align-items: inherit;
justify-content: inherit;
gap: inherit;
height: 100%;
}
`;
get crud() {
return this.edgeless.std.get(EdgelessCRUDIdentifier);
}
private readonly _setFontFamily = (fontFamily: FontFamily) => {
const currentFontWeight = getMostCommonFontWeight(this.elements);
const fontWeight = TextUtils.isFontWeightSupported(
fontFamily,
currentFontWeight
)
? currentFontWeight
: FontWeight.Regular;
const currentFontStyle = getMostCommonFontStyle(this.elements);
const fontStyle = TextUtils.isFontStyleSupported(
fontFamily,
currentFontStyle
)
? currentFontStyle
: FontStyle.Normal;
const props = { fontFamily, fontWeight, fontStyle };
this.elements.forEach(element => {
this.crud.updateElement(element.id, buildProps(element, props));
this._updateElementBound(element);
});
};
private readonly _setFontSize = (fontSize: number) => {
const props = { fontSize };
this.elements.forEach(element => {
this.crud.updateElement(element.id, buildProps(element, props));
this._updateElementBound(element);
});
};
private readonly _setFontWeightAndStyle = (
fontWeight: FontWeight,
fontStyle: FontStyle
) => {
const props = { fontWeight, fontStyle };
this.elements.forEach(element => {
this.crud.updateElement(element.id, buildProps(element, props));
this._updateElementBound(element);
});
};
private readonly _setTextAlign = (textAlign: TextAlign) => {
const props = { textAlign };
this.elements.forEach(element => {
this.crud.updateElement(element.id, buildProps(element, props));
});
};
private readonly _setTextColor = (e: ColorEvent) => {
const color = e.detail.value;
const props = { color };
this.elements.forEach(element => {
this.crud.updateElement(element.id, buildProps(element, props));
});
};
private readonly _updateElementBound = (element: SurfaceTextModel) => {
const elementType = this.elementType;
if (elementType === 'text' && element instanceof TextElementModel) {
// the change of font family will change the bound of the text
const {
text: yText,
fontFamily,
fontStyle,
fontSize,
fontWeight,
hasMaxWidth,
} = element;
const newBound = TextUtils.normalizeTextBound(
{
yText,
fontFamily,
fontStyle,
fontSize,
fontWeight,
hasMaxWidth,
},
Bound.fromXYWH(element.deserializedXYWH)
);
this.crud.updateElement(element.id, {
xywh: newBound.serialize(),
});
} else if (
elementType === 'connector' &&
ConnectorUtils.isConnectorWithLabel(element)
) {
const {
text,
labelXYWH,
labelStyle: { fontFamily, fontStyle, fontSize, fontWeight },
labelConstraints: { hasMaxWidth, maxWidth },
} = element as ConnectorElementModel;
const prevBounds = Bound.fromXYWH(labelXYWH || [0, 0, 16, 16]);
const center = prevBounds.center;
const bounds = TextUtils.normalizeTextBound(
{
yText: text!,
fontFamily,
fontStyle,
fontSize,
fontWeight,
hasMaxWidth,
maxWidth,
},
prevBounds
);
bounds.center = center;
this.crud.updateElement(element.id, {
labelXYWH: bounds.toXYWH(),
});
} else if (
elementType === 'shape' &&
element instanceof ShapeElementModel
) {
const newBound = normalizeShapeBound(
element,
Bound.fromXYWH(element.deserializedXYWH)
);
this.crud.updateElement(element.id, {
xywh: newBound.serialize(),
});
}
// no need to update the bound of edgeless text block, which updates itself using ResizeObserver
};
pickColor = (e: PickColorEvent) => {
if (e.type === 'pick') {
const color = e.detail.value;
this.elements.forEach(element => {
const props = packColor('color', color);
this.crud.updateElement(element.id, buildProps(element, props));
this._updateElementBound(element);
});
return;
}
const key = this.elementType === 'connector' ? 'labelStyle' : 'color';
this.elements.forEach(ele => {
ele[e.type === 'start' ? 'stash' : 'pop'](key as 'color');
});
};
get service() {
return this.edgeless.service;
}
override render() {
const colorScheme = this.edgeless.surface.renderer.getColorScheme();
const elements = this.elements;
const selectedAlign = getMostCommonAlign(elements);
const selectedColor = getMostCommonColor(elements, colorScheme);
const selectedFontFamily = getMostCommonFontFamily(elements);
const selectedFontSize = Math.trunc(getMostCommonFontSize(elements));
const selectedFontStyle = getMostCommonFontStyle(elements);
const selectedFontWeight = getMostCommonFontWeight(elements);
const matchFontFaces =
TextUtils.getFontFacesByFontFamily(selectedFontFamily);
const fontStyleBtnDisabled =
matchFontFaces.length === 1 &&
matchFontFaces[0].style === selectedFontStyle &&
matchFontFaces[0].weight === selectedFontWeight;
return join(
[
html`
<editor-menu-button
.contentPadding=${'8px'}
.button=${html`
<editor-icon-button
aria-label="Font"
.tooltip=${'Font'}
.justify=${'space-between'}
.labelHeight=${'20px'}
.iconContainerWidth=${'40px'}
>
<span
class="label padding0"
style=${`font-family: ${TextUtils.wrapFontFamily(selectedFontFamily)}`}
>Aa</span
>${SmallArrowDownIcon}
</editor-icon-button>
`}
>
<edgeless-font-family-panel
.value=${selectedFontFamily}
.onSelect=${this._setFontFamily}
></edgeless-font-family-panel>
</editor-menu-button>
`,
when(
this.edgeless.doc
.get(FeatureFlagService)
.getFlag('enable_color_picker'),
() => {
const { type, colors } = packColorsWithColorScheme(
colorScheme,
selectedColor,
elements[0] instanceof ConnectorElementModel
? elements[0].labelStyle.color
: elements[0].color
);
return html`
<edgeless-color-picker-button
class="text-color"
.label=${'Text color'}
.pick=${this.pickColor}
.isText=${true}
.color=${selectedColor}
.colors=${colors}
.colorType=${type}
.theme=${colorScheme}
.palettes=${DefaultTheme.Palettes}
>
</edgeless-color-picker-button>
`;
},
() => html`
<editor-menu-button
.contentPadding=${'8px'}
.button=${html`
<editor-icon-button
aria-label="Text color"
.tooltip=${'Text color'}
>
<edgeless-text-color-icon
.color=${selectedColor}
></edgeless-text-color-icon>
</editor-icon-button>
`}
>
<edgeless-color-panel
.value=${selectedColor}
.theme=${colorScheme}
.palettes=${DefaultTheme.Palettes}
@select=${this._setTextColor}
></edgeless-color-panel>
</editor-menu-button>
`
),
html`
<editor-menu-button
.contentPadding=${'8px'}
.button=${html`
<editor-icon-button
aria-label="Font style"
.tooltip=${'Font style'}
.justify=${'space-between'}
.labelHeight=${'20px'}
.iconContainerWidth=${'90px'}
.disabled=${fontStyleBtnDisabled}
>
<span class="label ellipsis">
${choose(selectedFontWeight, FONT_WEIGHT_CHOOSE)}
${choose(selectedFontStyle, FONT_STYLE_CHOOSE)}
</span>
${SmallArrowDownIcon}
</editor-icon-button>
`}
>
<edgeless-font-weight-and-style-panel
.fontFamily=${selectedFontFamily}
.fontWeight=${selectedFontWeight}
.fontStyle=${selectedFontStyle}
.onSelect=${this._setFontWeightAndStyle}
></edgeless-font-weight-and-style-panel>
</editor-menu-button>
`,
this.elementType === 'edgeless-text'
? nothing
: html`
<editor-menu-button
.contentPadding=${'8px'}
.button=${html`
<editor-icon-button
aria-label="Font size"
.tooltip=${'Font size'}
.justify=${'space-between'}
.labelHeight=${'20px'}
.iconContainerWidth=${'60px'}
>
<span class="label">${selectedFontSize}</span>
${SmallArrowDownIcon}
</editor-icon-button>
`}
>
<edgeless-size-panel
data-type="check"
.size=${selectedFontSize}
.sizeList=${FONT_SIZE_LIST}
.onSelect=${this._setFontSize}
></edgeless-size-panel>
</editor-menu-button>
`,
html`
<editor-menu-button
.button=${html`
<editor-icon-button
aria-label="Alignment"
.tooltip=${'Alignment'}
>
${choose(selectedAlign, TEXT_ALIGN_CHOOSE)}${SmallArrowDownIcon}
</editor-icon-button>
`}
>
<edgeless-align-panel
.value=${selectedAlign}
.onSelect=${this._setTextAlign}
></edgeless-align-panel>
</editor-menu-button>
`,
].filter(b => b !== nothing),
renderToolbarSeparator
);
}
@property({ attribute: false })
accessor edgeless!: EdgelessRootBlockComponent;
@property({ attribute: false })
accessor elements!: SurfaceTextModel[];
@property({ attribute: false })
accessor elementType!: keyof SurfaceTextModelMap;
@query('edgeless-color-picker-button.text-color')
accessor textColorButton!: EdgelessColorPickerButton;
}
@@ -0,0 +1,114 @@
import { EdgelessAddFrameButton } from './add-frame-button.js';
import { EdgelessAddGroupButton } from './add-group-button.js';
import { EdgelessAlignButton } from './align-button.js';
import { EdgelessChangeAttachmentButton } from './change-attachment-button.js';
import { EdgelessChangeBrushButton } from './change-brush-button.js';
import { EdgelessChangeConnectorButton } from './change-connector-button.js';
import { EdgelessChangeEmbedCardButton } from './change-embed-card-button.js';
import { EdgelessChangeFrameButton } from './change-frame-button.js';
import { EdgelessChangeGroupButton } from './change-group-button.js';
import { EdgelessChangeImageButton } from './change-image-button.js';
import {
EdgelessChangeMindmapButton,
EdgelessChangeMindmapLayoutPanel,
EdgelessChangeMindmapStylePanel,
} from './change-mindmap-button.js';
import { EdgelessChangeNoteButton } from './change-note-button.js';
import { EdgelessChangeShapeButton } from './change-shape-button.js';
import { EdgelessChangeTextMenu } from './change-text-menu.js';
import {
EDGELESS_ELEMENT_TOOLBAR_WIDGET,
EdgelessElementToolbarWidget,
} from './index.js';
import { EdgelessLockButton } from './lock-button.js';
import { EdgelessMoreButton } from './more-menu/button.js';
import { EdgelessReleaseFromGroupButton } from './release-from-group-button.js';
export function effects() {
customElements.define(
EDGELESS_ELEMENT_TOOLBAR_WIDGET,
EdgelessElementToolbarWidget
);
customElements.define('edgeless-add-frame-button', EdgelessAddFrameButton);
customElements.define('edgeless-add-group-button', EdgelessAddGroupButton);
customElements.define('edgeless-align-button', EdgelessAlignButton);
customElements.define(
'edgeless-change-attachment-button',
EdgelessChangeAttachmentButton
);
customElements.define(
'edgeless-change-brush-button',
EdgelessChangeBrushButton
);
customElements.define(
'edgeless-change-connector-button',
EdgelessChangeConnectorButton
);
customElements.define(
'edgeless-change-embed-card-button',
EdgelessChangeEmbedCardButton
);
customElements.define(
'edgeless-change-frame-button',
EdgelessChangeFrameButton
);
customElements.define(
'edgeless-change-group-button',
EdgelessChangeGroupButton
);
customElements.define(
'edgeless-change-image-button',
EdgelessChangeImageButton
);
customElements.define(
'edgeless-change-mindmap-style-panel',
EdgelessChangeMindmapStylePanel
);
customElements.define(
'edgeless-change-mindmap-layout-panel',
EdgelessChangeMindmapLayoutPanel
);
customElements.define(
'edgeless-change-mindmap-button',
EdgelessChangeMindmapButton
);
customElements.define(
'edgeless-change-note-button',
EdgelessChangeNoteButton
);
customElements.define(
'edgeless-change-shape-button',
EdgelessChangeShapeButton
);
customElements.define('edgeless-change-text-menu', EdgelessChangeTextMenu);
customElements.define(
'edgeless-release-from-group-button',
EdgelessReleaseFromGroupButton
);
customElements.define('edgeless-more-button', EdgelessMoreButton);
customElements.define('edgeless-lock-button', EdgelessLockButton);
}
declare global {
interface HTMLElementTagNameMap {
[EDGELESS_ELEMENT_TOOLBAR_WIDGET]: EdgelessElementToolbarWidget;
'edgeless-add-frame-button': EdgelessAddFrameButton;
'edgeless-add-group-button': EdgelessAddGroupButton;
'edgeless-align-button': EdgelessAlignButton;
'edgeless-change-attachment-button': EdgelessChangeAttachmentButton;
'edgeless-change-brush-button': EdgelessChangeBrushButton;
'edgeless-change-connector-button': EdgelessChangeConnectorButton;
'edgeless-change-embed-card-button': EdgelessChangeEmbedCardButton;
'edgeless-change-frame-button': EdgelessChangeFrameButton;
'edgeless-change-group-button': EdgelessChangeGroupButton;
'edgeless-change-mindmap-style-panel': EdgelessChangeMindmapStylePanel;
'edgeless-change-mindmap-layout-panel': EdgelessChangeMindmapLayoutPanel;
'edgeless-change-mindmap-button': EdgelessChangeMindmapButton;
'edgeless-change-note-button': EdgelessChangeNoteButton;
'edgeless-change-shape-button': EdgelessChangeShapeButton;
'edgeless-change-text-menu': EdgelessChangeTextMenu;
'edgeless-release-from-group-button': EdgelessReleaseFromGroupButton;
'edgeless-more-button': EdgelessMoreButton;
'edgeless-lock-button': EdgelessLockButton;
}
}
@@ -0,0 +1,6 @@
import { ArrowDownSmallIcon } from '@blocksuite/icons/lit';
export const SmallArrowDownIcon = ArrowDownSmallIcon({
width: '16',
height: '16',
});
@@ -0,0 +1,485 @@
import { isFrameBlock } from '@blocksuite/affine-block-frame';
import { isNoteBlock } from '@blocksuite/affine-block-surface';
import {
cloneGroups,
darkToolbarStyles,
getMoreMenuConfig,
lightToolbarStyles,
type MenuItemGroup,
renderToolbarSeparator,
} from '@blocksuite/affine-components/toolbar';
import type {
AttachmentBlockModel,
BrushElementModel,
BuiltInEmbedModel,
ConnectorElementModel,
EdgelessTextBlockModel,
FrameBlockModel,
ImageBlockModel,
MindmapElementModel,
NoteBlockModel,
RootBlockModel,
TextElementModel,
} from '@blocksuite/affine-model';
import {
ConnectorMode,
GroupElementModel,
ShapeElementModel,
} from '@blocksuite/affine-model';
import { ThemeProvider } from '@blocksuite/affine-shared/services';
import { requestConnectedFrame } from '@blocksuite/affine-shared/utils';
import { WidgetComponent } from '@blocksuite/block-std';
import type { GfxModel } from '@blocksuite/block-std/gfx';
import {
atLeastNMatches,
clamp,
getCommonBoundWithRotation,
groupBy,
pickValues,
} from '@blocksuite/global/utils';
import { ConnectorCIcon } from '@blocksuite/icons/lit';
import { css, html, nothing, type TemplateResult, unsafeCSS } from 'lit';
import { property, state } from 'lit/decorators.js';
import { join } from 'lit/directives/join.js';
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
import {
isAttachmentBlock,
isBookmarkBlock,
isEdgelessTextBlock,
isEmbeddedBlock,
isImageBlock,
} from '../../edgeless/utils/query.js';
import { renderAddFrameButton } from './add-frame-button.js';
import { renderAddGroupButton } from './add-group-button.js';
import { renderAlignButton } from './align-button.js';
import { renderAttachmentButton } from './change-attachment-button.js';
import { renderChangeBrushButton } from './change-brush-button.js';
import { renderConnectorButton } from './change-connector-button.js';
import { renderChangeEdgelessTextButton } from './change-edgeless-text-button.js';
import { renderEmbedButton } from './change-embed-card-button.js';
import { renderFrameButton } from './change-frame-button.js';
import { renderGroupButton } from './change-group-button.js';
import { renderChangeImageButton } from './change-image-button.js';
import { renderMindmapButton } from './change-mindmap-button.js';
import { renderNoteButton } from './change-note-button.js';
import { renderChangeShapeButton } from './change-shape-button.js';
import { renderChangeTextButton } from './change-text-button.js';
import { BUILT_IN_GROUPS } from './more-menu/config.js';
import type { ElementToolbarMoreMenuContext } from './more-menu/context.js';
import { renderReleaseFromGroupButton } from './release-from-group-button.js';
type CategorizedElements = {
shape?: ShapeElementModel[];
brush?: BrushElementModel[];
text?: TextElementModel[];
group?: GroupElementModel[];
connector?: ConnectorElementModel[];
note?: NoteBlockModel[];
frame?: FrameBlockModel[];
image?: ImageBlockModel[];
attachment?: AttachmentBlockModel[];
mindmap?: MindmapElementModel[];
embedCard?: BuiltInEmbedModel[];
edgelessText?: EdgelessTextBlockModel[];
};
type CustomEntry = {
render: (edgeless: EdgelessRootBlockComponent) => TemplateResult | null;
when: (model: GfxModel[]) => boolean;
};
export const EDGELESS_ELEMENT_TOOLBAR_WIDGET =
'edgeless-element-toolbar-widget';
export class EdgelessElementToolbarWidget extends WidgetComponent<
RootBlockModel,
EdgelessRootBlockComponent
> {
static override styles = css`
:host {
position: absolute;
z-index: 3;
transform: translateZ(0);
will-change: transform;
-webkit-user-select: none;
user-select: none;
}
editor-toolbar[data-app-theme='light'] {
${unsafeCSS(lightToolbarStyles.join('\n'))}
}
editor-toolbar[data-app-theme='dark'] {
${unsafeCSS(darkToolbarStyles.join('\n'))}
}
`;
private readonly _quickConnect = ({ x, y }: MouseEvent) => {
const element = this.selection.selectedElements[0];
const point = this.edgeless.service.viewport.toViewCoordFromClientCoord([
x,
y,
]);
this.edgeless.doc.captureSync();
this.edgeless.gfx.tool.setTool('connector', {
mode: ConnectorMode.Curve,
});
const ctc = this.edgeless.gfx.tool.get('connector');
ctc.quickConnect(point, element);
};
private readonly _updateOnSelectedChange = (
element: string | { id: string }
) => {
const id = typeof element === 'string' ? element : element.id;
if (this.isConnected && !this._dragging && this.selection.has(id)) {
this._recalculatePosition();
this.requestUpdate();
}
};
/*
* Caches the more menu items.
* Currently only supports configuring more menu.
*/
moreGroups: MenuItemGroup<ElementToolbarMoreMenuContext>[] =
cloneGroups(BUILT_IN_GROUPS);
get edgeless() {
return this.block as EdgelessRootBlockComponent;
}
get selection() {
return this.edgeless.service.selection;
}
get slots() {
return this.edgeless.slots;
}
get surface() {
return this.edgeless.surface;
}
private _groupSelected(): CategorizedElements {
const result = groupBy(this.selection.selectedElements, model => {
if (isNoteBlock(model)) {
return 'note';
} else if (isFrameBlock(model)) {
return 'frame';
} else if (isImageBlock(model)) {
return 'image';
} else if (isAttachmentBlock(model)) {
return 'attachment';
} else if (isBookmarkBlock(model) || isEmbeddedBlock(model)) {
return 'embedCard';
} else if (isEdgelessTextBlock(model)) {
return 'edgelessText';
}
return model.type;
});
return result as CategorizedElements;
}
private _recalculatePosition() {
const { selection, viewport } = this.edgeless.service;
const elements = selection.selectedElements;
if (elements.length === 0) {
this.style.transform = 'translate3d(0, 0, 0)';
return;
}
const bound = getCommonBoundWithRotation(elements);
const { width, height } = viewport;
const { x, y, w } = viewport.toViewBound(bound);
let left = x;
let top = y;
const hasLocked = elements.some(e => e.isLocked());
let offset = 37 + 12;
// frame, group, shape
let hasFrame = false;
let hasGroup = false;
if (
(hasFrame = elements.some(ele => isFrameBlock(ele))) ||
(hasGroup = elements.some(ele => ele instanceof GroupElementModel))
) {
offset += 16 + 4;
if (hasFrame) {
offset += 8;
}
} else if (
elements.length === 1 &&
elements[0] instanceof ShapeElementModel
) {
offset += 22 + 4;
}
top = y - offset;
if (top < 0) {
top = y + bound.h * viewport.zoom + offset - 37;
if (hasFrame || hasGroup) {
top -= 16 + 4;
if (hasFrame) {
top -= 8;
}
}
}
requestConnectedFrame(() => {
const rect = this.getBoundingClientRect();
if (hasLocked) {
left += 0.5 * (w - rect.width);
}
left = clamp(left, 10, width - rect.width - 10);
top = clamp(top, 10, height - rect.height - 150);
this.style.transform = `translate3d(${left}px, ${top}px, 0)`;
}, this);
}
private _renderButtons() {
if (this.doc.readonly || this._dragging || !this.toolbarVisible) {
return [];
}
const { selectedElements } = this.selection;
if (selectedElements.some(e => e.isLocked())) {
return [
html`<edgeless-lock-button
.edgeless=${this.edgeless}
></edgeless-lock-button>`,
];
}
const groupedSelected = this._groupSelected();
const { edgeless, selection } = this;
const {
shape,
brush,
connector,
note,
text,
frame,
group,
embedCard,
attachment,
image,
edgelessText,
mindmap: mindmaps,
} = groupedSelected;
const selectedAtLeastTwoTypes = atLeastNMatches(
Object.values(groupedSelected),
e => !!e.length,
2
);
const quickConnectButton =
selectedElements.length === 1 && !connector?.length
? this._renderQuickConnectButton()
: undefined;
const generalButtons =
selectedElements.length !== connector?.length
? [
renderAddFrameButton(edgeless, selectedElements),
renderAddGroupButton(edgeless, selectedElements),
renderAlignButton(edgeless, selectedElements),
]
: [];
const buttons: (symbol | TemplateResult)[] = selectedAtLeastTwoTypes
? generalButtons
: [
...generalButtons,
renderMindmapButton(edgeless, mindmaps),
renderMindmapButton(edgeless, shape),
renderChangeShapeButton(edgeless, shape),
renderChangeBrushButton(edgeless, brush),
renderConnectorButton(edgeless, connector),
renderNoteButton(edgeless, note, quickConnectButton),
renderChangeTextButton(edgeless, text),
renderChangeEdgelessTextButton(edgeless, edgelessText),
renderFrameButton(edgeless, frame),
renderGroupButton(edgeless, group),
renderEmbedButton(edgeless, embedCard, quickConnectButton),
renderAttachmentButton(edgeless, attachment),
renderChangeImageButton(edgeless, image),
];
if (selectedElements.length === 1) {
if (selection.firstElement.group instanceof GroupElementModel) {
buttons.unshift(renderReleaseFromGroupButton(this.edgeless));
}
if (!connector?.length) {
buttons.push(quickConnectButton?.pop() ?? nothing);
}
}
buttons.push(
html`<edgeless-lock-button
.edgeless=${this.edgeless}
></edgeless-lock-button>`
);
this._registeredEntries
.filter(entry => entry.when(selectedElements))
.map(entry => entry.render(this.edgeless))
.forEach(entry => entry && buttons.unshift(entry));
buttons.push(html`
<edgeless-more-button
.elements=${selectedElements}
.edgeless=${edgeless}
.groups=${this.moreGroups}
.vertical=${true}
></edgeless-more-button>
`);
return buttons;
}
private _renderQuickConnectButton() {
return [
html`
<editor-icon-button
aria-label="Draw connector"
.tooltip=${'Draw connector'}
.activeMode=${'background'}
.iconSize=${'20px'}
@click=${this._quickConnect}
>
${ConnectorCIcon()}
</editor-icon-button>
`,
];
}
protected override firstUpdated() {
const { _disposables, edgeless } = this;
this.moreGroups = getMoreMenuConfig(this.std).configure(this.moreGroups);
_disposables.add(
edgeless.service.viewport.viewportUpdated.on(() => {
this._recalculatePosition();
})
);
_disposables.add(
this.selection.slots.updated.on(() => {
if (
this.selection.selectedIds.length === 0 ||
this.selection.editing ||
this.selection.inoperable
) {
this.toolbarVisible = false;
} else {
this.selectedIds = this.selection.selectedIds;
this._recalculatePosition();
this.toolbarVisible = true;
}
})
);
pickValues(this.edgeless.service.surface, [
'elementAdded',
'elementUpdated',
]).forEach(slot => _disposables.add(slot.on(this._updateOnSelectedChange)));
_disposables.add(
this.doc.slots.blockUpdated.on(this._updateOnSelectedChange)
);
_disposables.add(
edgeless.dispatcher.add('dragStart', () => {
this._dragging = true;
})
);
_disposables.add(
edgeless.dispatcher.add('dragEnd', () => {
this._dragging = false;
this._recalculatePosition();
})
);
_disposables.add(
edgeless.slots.elementResizeStart.on(() => {
this._dragging = true;
})
);
_disposables.add(
edgeless.slots.elementResizeEnd.on(() => {
this._dragging = false;
this._recalculatePosition();
})
);
_disposables.add(
edgeless.slots.readonlyUpdated.on(() => this.requestUpdate())
);
this.updateComplete
.then(() => {
_disposables.add(
this.std
.get(ThemeProvider)
.theme$.subscribe(() => this.requestUpdate())
);
})
.catch(console.error);
}
registerEntry(entry: CustomEntry) {
this._registeredEntries.push(entry);
}
override render() {
const buttons = this._renderButtons();
if (buttons.length === 0) return nothing;
const appTheme = this.std.get(ThemeProvider).app$.value;
return html`
<editor-toolbar data-app-theme=${appTheme}>
${join(
buttons.filter(b => b !== nothing),
renderToolbarSeparator
)}
</editor-toolbar>
`;
}
@state()
private accessor _dragging = false;
@state()
private accessor _registeredEntries: {
render: (edgeless: EdgelessRootBlockComponent) => TemplateResult | null;
when: (model: GfxModel[]) => boolean;
}[] = [];
@property({ attribute: false })
accessor enableNoteSlicer!: boolean;
@state({
hasChanged: (value: string[], oldValue: string[]) => {
if (value.length !== oldValue?.length) {
return true;
}
return value.some((id, index) => id !== oldValue[index]);
},
})
accessor selectedIds: string[] = [];
@state()
accessor toolbarVisible = false;
}
@@ -0,0 +1,158 @@
import {
GroupElementModel,
MindmapElementModel,
} from '@blocksuite/affine-model';
import {
type ElementLockEvent,
TelemetryProvider,
} from '@blocksuite/affine-shared/services';
import type { BlockStdScope } from '@blocksuite/block-std';
import type { GfxModel } from '@blocksuite/block-std/gfx';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import { LockIcon, UnlockIcon } from '@blocksuite/icons/lit';
import { html, LitElement, nothing } from 'lit';
import { property } from 'lit/decorators.js';
import type { EdgelessRootBlockComponent } from '../../edgeless/index.js';
export class EdgelessLockButton extends SignalWatcher(
WithDisposable(LitElement)
) {
private get _selectedElements() {
const elements = new Set<GfxModel>();
this.edgeless.service.selection.selectedElements.forEach(element => {
if (element.group instanceof MindmapElementModel) {
elements.add(element.group);
} else {
elements.add(element);
}
});
return [...elements];
}
private _lock() {
const { service, doc, std } = this.edgeless;
// get most top selected elements(*) from tree, like in a tree below
// G0
// / \
// E1* G1
// / \
// E2* E3*
//
// (*) selected elements, [E1, E2, E3]
// return [E1]
const selectedElements = this._selectedElements;
if (selectedElements.length === 0) return;
const levels = selectedElements.map(element => element.groups.length);
const topElement = selectedElements[levels.indexOf(Math.min(...levels))];
const otherElements = selectedElements.filter(
element => element !== topElement
);
doc.captureSync();
// release other elements from their groups and group with top element
otherElements.forEach(element => {
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
element.group?.removeChild(element);
topElement.group?.addChild(element);
});
if (otherElements.length === 0) {
topElement.lock();
this.edgeless.gfx.selection.set({
editing: false,
elements: [topElement.id],
});
track(std, topElement, 'lock');
return;
}
const groupId = service.createGroup([topElement, ...otherElements]);
if (groupId) {
const group = service.crud.getElementById(groupId);
if (group) {
group.lock();
this.edgeless.gfx.selection.set({
editing: false,
elements: [groupId],
});
track(std, group, 'group-lock');
return;
}
}
selectedElements.forEach(e => {
e.lock();
track(std, e, 'lock');
});
this.edgeless.gfx.selection.set({
editing: false,
elements: selectedElements.map(e => e.id),
});
}
private _unlock() {
const { service, doc } = this.edgeless;
const selectedElements = this._selectedElements;
if (selectedElements.length === 0) return;
doc.captureSync();
selectedElements.forEach(element => {
if (element instanceof GroupElementModel) {
service.ungroup(element);
} else {
element.lockedBySelf = false;
}
track(this.edgeless.std, element, 'unlock');
});
}
override render() {
const hasLocked = this._selectedElements.some(element =>
element.isLocked()
);
this.dataset.locked = hasLocked ? 'true' : 'false';
const icon = hasLocked ? UnlockIcon : LockIcon;
return html`<editor-icon-button
@click=${hasLocked ? this._unlock : this._lock}
>
${icon({ width: '20px', height: '20px' })}
${hasLocked
? html`<span class="label medium">Click to unlock</span>`
: nothing}
</editor-icon-button>`;
}
@property({ attribute: false })
accessor edgeless!: EdgelessRootBlockComponent;
}
function track(
std: BlockStdScope,
element: GfxModel,
control: ElementLockEvent['control']
) {
const type =
'flavour' in element
? (element.flavour.split(':')[1] ?? element.flavour)
: element.type;
std.getOptional(TelemetryProvider)?.track('EdgelessElementLocked', {
page: 'whiteboard editor',
segment: 'element toolbar',
module: 'element toolbar',
control,
type,
});
}
@@ -0,0 +1,50 @@
import type { MenuItemGroup } from '@blocksuite/affine-components/toolbar';
import { renderGroups } from '@blocksuite/affine-components/toolbar';
import type { GfxModel } from '@blocksuite/block-std/gfx';
import { WithDisposable } from '@blocksuite/global/utils';
import { MoreHorizontalIcon, MoreVerticalIcon } from '@blocksuite/icons/lit';
import { html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import type { EdgelessRootBlockComponent } from '../../../edgeless/edgeless-root-block.js';
import { ElementToolbarMoreMenuContext } from './context.js';
export class EdgelessMoreButton extends WithDisposable(LitElement) {
override render() {
const context = new ElementToolbarMoreMenuContext(this.edgeless);
const actions = renderGroups(this.groups, context);
return html`
<editor-menu-button
.contentPadding=${'8px'}
.button=${html`
<editor-icon-button aria-label="More" .tooltip=${'More'}>
${this.vertical
? MoreVerticalIcon({ width: '20', height: '20' })
: MoreHorizontalIcon({ width: '20', height: '20' })}
</editor-icon-button>
`}
>
<div
class="more-actions-container"
data-size="large"
data-orientation="vertical"
>
${actions}
</div>
</editor-menu-button>
`;
}
@property({ attribute: false })
accessor edgeless!: EdgelessRootBlockComponent;
@property({ attribute: false })
accessor elements: GfxModel[] = [];
@property({ attribute: false })
accessor groups!: MenuItemGroup<ElementToolbarMoreMenuContext>[];
@property({ attribute: false })
accessor vertical = false;
}
@@ -0,0 +1,459 @@
import type { AttachmentBlockComponent } from '@blocksuite/affine-block-attachment';
import type { BookmarkBlockComponent } from '@blocksuite/affine-block-bookmark';
import {
type EmbedFigmaBlockComponent,
type EmbedGithubBlockComponent,
type EmbedLoomBlockComponent,
type EmbedYoutubeBlockComponent,
notifyDocCreated,
promptDocTitle,
} from '@blocksuite/affine-block-embed';
import type { ImageBlockComponent } from '@blocksuite/affine-block-image';
import { EdgelessCRUDIdentifier } from '@blocksuite/affine-block-surface';
import { isPeekable, peek } from '@blocksuite/affine-components/peek';
import type { MenuItemGroup } from '@blocksuite/affine-components/toolbar';
import {
OpenDocExtensionIdentifier,
TelemetryProvider,
} from '@blocksuite/affine-shared/services';
import { Bound, getCommonBoundWithRotation } from '@blocksuite/global/utils';
import {
ArrowDownBigBottomIcon,
ArrowDownBigIcon,
ArrowUpBigIcon,
ArrowUpBigTopIcon,
CenterPeekIcon,
CopyIcon,
DeleteIcon,
DuplicateIcon,
ExpandFullIcon,
FrameIcon,
GroupIcon,
LinkedPageIcon,
OpenInNewIcon,
ResetIcon,
SplitViewIcon,
} from '@blocksuite/icons/lit';
import { duplicate } from '../../../edgeless/utils/clipboard-utils.js';
import { getSortedCloneElements } from '../../../edgeless/utils/clone-utils.js';
import { moveConnectors } from '../../../edgeless/utils/connector.js';
import { deleteElements } from '../../../edgeless/utils/crud.js';
import type { ElementToolbarMoreMenuContext } from './context.js';
import {
createLinkedDocFromEdgelessElements,
createLinkedDocFromNote,
} from './render-linked-doc.js';
type EmbedLinkBlockComponent =
| EmbedGithubBlockComponent
| EmbedFigmaBlockComponent
| EmbedLoomBlockComponent
| EmbedYoutubeBlockComponent;
type RefreshableBlockComponent =
| EmbedLinkBlockComponent
| ImageBlockComponent
| AttachmentBlockComponent
| BookmarkBlockComponent;
// Section Group: frame & group
export const sectionGroup: MenuItemGroup<ElementToolbarMoreMenuContext> = {
type: 'section',
items: [
{
icon: FrameIcon({ width: '20', height: '20' }),
label: 'Frame section',
type: 'create-frame',
action: ({ service, edgeless, std }) => {
const frame = service.frame.createFrameOnSelected();
if (!frame) return;
std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', {
control: 'context-menu',
page: 'whiteboard editor',
module: 'toolbar',
segment: 'toolbar',
type: 'frame',
});
edgeless.surface.fitToViewport(Bound.deserialize(frame.xywh));
},
},
{
icon: GroupIcon({ width: '20', height: '20' }),
label: 'Group section',
type: 'create-group',
action: ({ service }) => {
service.createGroupFromSelected();
},
when: ctx => !ctx.hasFrame(),
},
],
};
// Reorder Group
export const reorderGroup: MenuItemGroup<ElementToolbarMoreMenuContext> = {
type: 'reorder',
items: [
{
icon: ArrowUpBigTopIcon({ width: '20', height: '20' }),
label: 'Bring to Front',
type: 'front',
action: ({ service, selectedElements }) => {
selectedElements.forEach(el => {
service.reorderElement(el, 'front');
});
},
},
{
icon: ArrowUpBigIcon({ width: '20', height: '20' }),
label: 'Bring Forward',
type: 'forward',
action: ({ service, selectedElements }) => {
selectedElements.forEach(el => {
service.reorderElement(el, 'forward');
});
},
},
{
icon: ArrowDownBigIcon({ width: '20', height: '20' }),
label: 'Send Backward',
type: 'backward',
action: ({ service, selectedElements }) => {
selectedElements.forEach(el => {
service.reorderElement(el, 'backward');
});
},
},
{
icon: ArrowDownBigBottomIcon({ width: '20', height: '20' }),
label: 'Send to Back',
type: 'back',
action: ({ service, selectedElements }) => {
selectedElements.forEach(el => {
service.reorderElement(el, 'back');
});
},
},
],
};
// Open Group
// TODO: construct this group dynamically
export const openGroup: MenuItemGroup<ElementToolbarMoreMenuContext> = {
type: 'open',
items: [
{
icon: ExpandFullIcon({ width: '20', height: '20' }),
label: 'Open this doc',
type: 'open',
generate: ctx => {
const linkedDocBlock = ctx.getLinkedDocBlock();
if (!linkedDocBlock) return;
const disabled = linkedDocBlock.pageId === ctx.doc.id;
return {
action: () => {
const blockComponent = ctx.firstBlockComponent;
if (!blockComponent) return;
if (!('open' in blockComponent)) return;
if (typeof blockComponent.open !== 'function') return;
blockComponent.open();
},
disabled,
};
},
when: ctx => {
const openDocService = ctx.std.get(OpenDocExtensionIdentifier);
return openDocService.isAllowed('open-in-active-view');
},
},
{
icon: SplitViewIcon({ width: '20', height: '20' }),
label: 'Open in split view',
type: 'open-in-split-view',
generate: ctx => {
const linkedDocBlock = ctx.getLinkedDocBlock();
if (!linkedDocBlock) return;
return {
action: () => {
const blockComponent = ctx.firstBlockComponent;
if (!blockComponent) return;
if (!('open' in blockComponent)) return;
if (typeof blockComponent.open !== 'function') return;
blockComponent.open({ openMode: 'open-in-new-view' });
},
};
},
when: ctx => {
const openDocService = ctx.std.get(OpenDocExtensionIdentifier);
return openDocService.isAllowed('open-in-new-view');
},
},
{
icon: OpenInNewIcon({ width: '20', height: '20' }),
label: 'Open in new tab',
type: 'open-in-new-tab',
generate: ctx => {
const linkedDocBlock = ctx.getLinkedDocBlock();
if (!linkedDocBlock) return;
return {
action: () => {
const blockComponent = ctx.firstBlockComponent;
if (!blockComponent) return;
if (!('open' in blockComponent)) return;
if (typeof blockComponent.open !== 'function') return;
blockComponent.open({ openMode: 'open-in-new-tab' });
},
};
},
when: ctx => {
const openDocService = ctx.std.get(OpenDocExtensionIdentifier);
return openDocService.isAllowed('open-in-new-tab');
},
},
{
icon: CenterPeekIcon({ width: '20', height: '20' }),
label: 'Open in center peek',
type: 'center-peek',
generate: ctx => {
const valid =
ctx.isSingle() &&
!!ctx.firstBlockComponent &&
isPeekable(ctx.firstBlockComponent);
if (!valid) return;
return {
action: () => {
if (!ctx.firstBlockComponent) return;
peek(ctx.firstBlockComponent);
},
};
},
when: ctx => {
const openDocService = ctx.std.get(OpenDocExtensionIdentifier);
return openDocService.isAllowed('open-in-center-peek');
},
},
],
};
// Clipboard Group
export const clipboardGroup: MenuItemGroup<ElementToolbarMoreMenuContext> = {
type: 'clipboard',
items: [
{
icon: CopyIcon({ width: '20', height: '20' }),
label: 'Copy',
type: 'copy',
action: ({ edgeless }) => edgeless.clipboardController.copy(),
},
{
icon: DuplicateIcon({ width: '20', height: '20' }),
label: 'Duplicate',
type: 'duplicate',
action: ({ edgeless, selectedElements }) =>
duplicate(edgeless, selectedElements),
},
{
icon: ResetIcon({ width: '20', height: '20' }),
label: 'Reload',
type: 'reload',
generate: ctx => {
if (ctx.hasFrame()) {
return;
}
const blocks = ctx.selection.surfaceSelections
.map(s => ctx.getBlockComponent(s.blockId))
.filter(block => !!block)
.filter(block => ctx.refreshable(block.model));
if (
!blocks.length ||
blocks.length !== ctx.selection.surfaceSelections.length
) {
return;
}
return {
action: () =>
blocks.forEach(block =>
(block as RefreshableBlockComponent).refreshData()
),
};
},
},
],
};
// Conversions Group
export const conversionsGroup: MenuItemGroup<ElementToolbarMoreMenuContext> = {
type: 'conversions',
items: [
{
icon: LinkedPageIcon({ width: '20', height: '20' }),
label: 'Turn into linked doc',
type: 'turn-into-linked-doc',
action: async ctx => {
const { doc, service, surface, std } = ctx;
const element = ctx.getNoteBlock();
if (!element) return;
const title = await promptDocTitle(std);
if (title === null) return;
const linkedDoc = createLinkedDocFromNote(doc, element, title);
const crud = std.get(EdgelessCRUDIdentifier);
// insert linked doc card
const cardId = crud.addBlock(
'affine:embed-synced-doc',
{
xywh: element.xywh,
style: 'syncedDoc',
pageId: linkedDoc.id,
index: element.index,
},
surface.model.id
);
std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', {
control: 'context-menu',
page: 'whiteboard editor',
module: 'toolbar',
segment: 'toolbar',
type: 'embed-synced-doc',
});
std.getOptional(TelemetryProvider)?.track('DocCreated', {
control: 'turn into linked doc',
page: 'whiteboard editor',
module: 'format toolbar',
type: 'embed-linked-doc',
});
std.getOptional(TelemetryProvider)?.track('LinkedDocCreated', {
control: 'turn into linked doc',
page: 'whiteboard editor',
module: 'format toolbar',
type: 'embed-linked-doc',
other: 'new doc',
});
moveConnectors(element.id, cardId, service);
// delete selected note
doc.transact(() => {
doc.deleteBlock(element);
});
service.selection.set({
elements: [cardId],
editing: false,
});
},
when: ctx => !!ctx.getNoteBlock(),
},
{
icon: LinkedPageIcon({ width: '20', height: '20' }),
label: 'Create linked doc',
type: 'create-linked-doc',
action: async ({ doc, selection, surface, edgeless, host, std }) => {
const title = await promptDocTitle(std);
if (title === null) return;
const elements = getSortedCloneElements(selection.selectedElements);
const linkedDoc = createLinkedDocFromEdgelessElements(
host,
elements,
title
);
const crud = std.get(EdgelessCRUDIdentifier);
// delete selected elements
doc.transact(() => {
deleteElements(edgeless, elements);
});
// insert linked doc card
const width = 364;
const height = 390;
const bound = getCommonBoundWithRotation(elements);
const cardId = crud.addBlock(
'affine:embed-linked-doc',
{
xywh: `[${bound.center[0] - width / 2}, ${bound.center[1] - height / 2}, ${width}, ${height}]`,
style: 'vertical',
pageId: linkedDoc.id,
},
surface.model.id
);
selection.set({
elements: [cardId],
editing: false,
});
std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', {
control: 'context-menu',
page: 'whiteboard editor',
module: 'toolbar',
segment: 'toolbar',
type: 'embed-linked-doc',
});
std.getOptional(TelemetryProvider)?.track('DocCreated', {
control: 'create linked doc',
page: 'whiteboard editor',
module: 'format toolbar',
type: 'embed-linked-doc',
});
std.getOptional(TelemetryProvider)?.track('LinkedDocCreated', {
control: 'create linked doc',
page: 'whiteboard editor',
module: 'format toolbar',
type: 'embed-linked-doc',
other: 'new doc',
});
notifyDocCreated(std, doc);
},
when: ctx => !(ctx.getLinkedDocBlock() || ctx.getNoteBlock()),
},
],
};
// Delete Group
export const deleteGroup: MenuItemGroup<ElementToolbarMoreMenuContext> = {
type: 'delete',
items: [
{
icon: DeleteIcon({ width: '20', height: '20' }),
label: 'Delete',
type: 'delete',
action: ({ doc, selection, selectedElements, edgeless }) => {
doc.captureSync();
deleteElements(edgeless, selectedElements);
selection.set({
elements: [],
editing: false,
});
},
},
],
};
export const BUILT_IN_GROUPS = [
sectionGroup,
reorderGroup,
openGroup,
clipboardGroup,
conversionsGroup,
deleteGroup,
];
@@ -0,0 +1,152 @@
import { isFrameBlock } from '@blocksuite/affine-block-frame';
import {
isNoteBlock,
type SurfaceBlockComponent,
} from '@blocksuite/affine-block-surface';
import { MenuContext } from '@blocksuite/affine-components/toolbar';
import { getSelectedModelsCommand } from '@blocksuite/affine-shared/commands';
import {
GfxPrimitiveElementModel,
type GfxSelectionManager,
} from '@blocksuite/block-std/gfx';
import type { BlockModel } from '@blocksuite/store';
import type { EdgelessRootBlockComponent } from '../../../edgeless/edgeless-root-block.js';
import type { EdgelessRootService } from '../../../edgeless/edgeless-root-service.js';
import {
isAttachmentBlock,
isBookmarkBlock,
isEmbeddedLinkBlock,
isEmbedLinkedDocBlock,
isEmbedSyncedDocBlock,
isImageBlock,
} from '../../../edgeless/utils/query.js';
export class ElementToolbarMoreMenuContext extends MenuContext {
readonly #empty: boolean;
readonly #includedFrame: boolean;
readonly #multiple: boolean;
readonly #single: boolean;
edgeless!: EdgelessRootBlockComponent;
get doc() {
return this.edgeless.doc;
}
get firstBlockComponent() {
return this.getBlockComponent(this.firstElement.id);
}
override get firstElement() {
return this.selection.firstElement;
}
get host() {
return this.edgeless.host;
}
get selectedBlockModels() {
const [result, { selectedModels }] = this.std.command.exec(
getSelectedModelsCommand
);
if (!result) return [];
return selectedModels ?? [];
}
get selectedElements() {
return this.selection.selectedElements;
}
get selection(): GfxSelectionManager {
return this.service.selection;
}
get service(): EdgelessRootService {
return this.edgeless.service;
}
get std() {
return this.edgeless.host.std;
}
get surface(): SurfaceBlockComponent {
return this.edgeless.surface;
}
get view() {
return this.host.view;
}
constructor(edgeless: EdgelessRootBlockComponent) {
super();
this.edgeless = edgeless;
const selectedElements = this.selection.selectedElements;
const len = selectedElements.length;
this.#empty = len === 0;
this.#single = len === 1;
this.#multiple = !this.#empty && !this.#single;
this.#includedFrame = !this.#empty && selectedElements.some(isFrameBlock);
}
getBlockComponent(id: string) {
return this.view.getBlock(id);
}
getLinkedDocBlock() {
const valid =
this.#single &&
(isEmbedLinkedDocBlock(this.firstElement) ||
isEmbedSyncedDocBlock(this.firstElement));
if (!valid) return null;
return this.firstElement;
}
getNoteBlock() {
const valid = this.#single && isNoteBlock(this.firstElement);
if (!valid) return null;
return this.firstElement;
}
hasFrame() {
return this.#includedFrame;
}
override isElement() {
return (
this.#single && this.firstElement instanceof GfxPrimitiveElementModel
);
}
override isEmpty() {
return this.#empty;
}
isMultiple() {
return this.#multiple;
}
isSingle() {
return this.#single;
}
refreshable(model: BlockModel) {
return (
isImageBlock(model) ||
isBookmarkBlock(model) ||
isAttachmentBlock(model) ||
isEmbeddedLinkBlock(model)
);
}
}
@@ -0,0 +1,104 @@
import { isFrameBlock } from '@blocksuite/affine-block-frame';
import { getSurfaceBlock, isNoteBlock } from '@blocksuite/affine-block-surface';
import type { FrameBlockModel, NoteBlockModel } from '@blocksuite/affine-model';
import { NoteDisplayMode } from '@blocksuite/affine-model';
import { DocModeProvider } from '@blocksuite/affine-shared/services';
import { getBlockProps } from '@blocksuite/affine-shared/utils';
import type { EditorHost } from '@blocksuite/block-std';
import { GfxBlockElementModel, type GfxModel } from '@blocksuite/block-std/gfx';
import { type BlockModel, type Store, Text } from '@blocksuite/store';
import {
getElementProps,
mapFrameIds,
sortEdgelessElements,
} from '../../../edgeless/utils/clone-utils.js';
function addBlocksToDoc(targetDoc: Store, model: BlockModel, parentId: string) {
// Add current block to linked doc
const blockProps = getBlockProps(model);
const newModelId = targetDoc.addBlock(model.flavour, blockProps, parentId);
// Add children to linked doc, parent is the new model
const children = model.children;
if (children.length > 0) {
children.forEach(child => {
addBlocksToDoc(targetDoc, child, newModelId);
});
}
}
export function createLinkedDocFromNote(
doc: Store,
note: NoteBlockModel,
docTitle?: string
) {
const linkedDoc = doc.workspace.createDoc({});
linkedDoc.load(() => {
const rootId = linkedDoc.addBlock('affine:page', {
title: new Text(docTitle),
});
linkedDoc.addBlock('affine:surface', {}, rootId);
const blockProps = getBlockProps(note);
// keep note props & show in both mode
const noteId = linkedDoc.addBlock(
'affine:note',
{
...blockProps,
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
},
rootId
);
// Add note to linked doc recursively
note.children.forEach(model => {
addBlocksToDoc(linkedDoc, model, noteId);
});
});
return linkedDoc;
}
export function createLinkedDocFromEdgelessElements(
host: EditorHost,
elements: GfxModel[],
docTitle?: string
) {
const linkedDoc = host.doc.workspace.createDoc({});
linkedDoc.load(() => {
const rootId = linkedDoc.addBlock('affine:page', {
title: new Text(docTitle),
});
const surfaceId = linkedDoc.addBlock('affine:surface', {}, rootId);
const surface = getSurfaceBlock(linkedDoc);
if (!surface) return;
const sortedElements = sortEdgelessElements(elements);
const ids = new Map<string, string>();
sortedElements.forEach(model => {
let newId = model.id;
if (model instanceof GfxBlockElementModel) {
const blockProps = getBlockProps(model);
if (isNoteBlock(model)) {
newId = linkedDoc.addBlock('affine:note', blockProps, rootId);
// Add note children to linked doc recursively
model.children.forEach(model => {
addBlocksToDoc(linkedDoc, model, newId);
});
} else {
if (isFrameBlock(model)) {
mapFrameIds(blockProps as unknown as FrameBlockModel, ids);
}
newId = linkedDoc.addBlock(model.flavour, blockProps, surfaceId);
}
} else {
const props = getElementProps(model, ids);
newId = surface.addElement(props);
}
ids.set(model.id, newId);
});
});
host.std.get(DocModeProvider).setPrimaryMode('edgeless', linkedDoc.id);
return linkedDoc;
}
@@ -0,0 +1,54 @@
import { GroupElementModel } from '@blocksuite/affine-model';
import { WithDisposable } from '@blocksuite/global/utils';
import { ReleaseFromGroupIcon } from '@blocksuite/icons/lit';
import { html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
export class EdgelessReleaseFromGroupButton extends WithDisposable(LitElement) {
private _releaseFromGroup() {
const service = this.edgeless.service;
const element = service.selection.firstElement;
if (!(element.group instanceof GroupElementModel)) return;
const group = element.group;
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
group.removeChild(element);
element.index = service.layer.generateIndex();
const parent = group.group;
if (parent instanceof GroupElementModel) {
parent.addChild(element);
}
}
protected override render() {
return html`
<editor-icon-button
aria-label="Release from group"
.tooltip=${'Release from group'}
.iconSize=${'20px'}
@click=${() => this._releaseFromGroup()}
>
${ReleaseFromGroupIcon()}
</editor-icon-button>
`;
}
@property({ attribute: false })
accessor edgeless!: EdgelessRootBlockComponent;
}
export function renderReleaseFromGroupButton(
edgeless: EdgelessRootBlockComponent
) {
return html`
<edgeless-release-from-group-button
.edgeless=${edgeless}
></edgeless-release-from-group-button>
`;
}
@@ -0,0 +1,24 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const viewInPageNotifyFooter = style({
display: 'flex',
justifyContent: 'flex-end',
gap: '12px',
});
export const viewInPageNotifyFooterButton = style({
padding: '0px 6px',
borderRadius: '4px',
color: cssVarV2('text/primary'),
fontSize: cssVar('fontSm'),
lineHeight: '22px',
fontWeight: '500',
textAlign: 'center',
':hover': {
background: cssVarV2('layer/background/hoverOverlay'),
},
});
@@ -0,0 +1,97 @@
import {
CopyIcon,
DeleteIcon,
DuplicateIcon,
RefreshIcon,
} from '@blocksuite/affine-components/icons';
import { toast } from '@blocksuite/affine-components/toast';
import type { MenuItemGroup } from '@blocksuite/affine-components/toolbar';
import { getBlockProps } from '@blocksuite/affine-shared/utils';
import { Slice } from '@blocksuite/store';
import {
isAttachmentBlock,
isBookmarkBlock,
isEmbeddedLinkBlock,
isImageBlock,
} from '../../edgeless/utils/query.js';
import type { EmbedCardToolbarContext } from './context.js';
export const BUILT_IN_GROUPS: MenuItemGroup<EmbedCardToolbarContext>[] = [
{
type: 'clipboard',
items: [
{
type: 'copy',
label: 'Copy',
icon: CopyIcon,
disabled: ({ doc }) => doc.readonly,
action: async ({ host, doc, std, blockComponent, close }) => {
const slice = Slice.fromModels(doc, [blockComponent.model]);
await std.clipboard.copySlice(slice);
toast(host, 'Copied link to clipboard');
close();
},
},
{
type: 'duplicate',
label: 'Duplicate',
icon: DuplicateIcon,
disabled: ({ doc }) => doc.readonly,
action: ({ doc, blockComponent, close }) => {
const model = blockComponent.model;
const blockProps = getBlockProps(model);
const {
width: _width,
height: _height,
xywh: _xywh,
rotate: _rotate,
zIndex: _zIndex,
...duplicateProps
} = blockProps;
const parent = doc.getParent(model);
const index = parent?.children.indexOf(model);
doc.addBlock(model.flavour, duplicateProps, parent, index);
close();
},
},
{
type: 'reload',
label: 'Reload',
icon: RefreshIcon,
disabled: ({ doc }) => doc.readonly,
action: ({ blockComponent, close }) => {
blockComponent?.refreshData();
close();
},
when: ({ blockComponent }) => {
const model = blockComponent.model;
return (
!!model &&
(isImageBlock(model) ||
isBookmarkBlock(model) ||
isAttachmentBlock(model) ||
isEmbeddedLinkBlock(model))
);
},
},
],
},
{
type: 'delete',
items: [
{
type: 'delete',
label: 'Delete',
icon: DeleteIcon,
disabled: ({ doc }) => doc.readonly,
action: ({ doc, blockComponent, close }) => {
doc.deleteBlock(blockComponent.model);
close();
},
},
],
},
];
@@ -0,0 +1,45 @@
import { MenuContext } from '@blocksuite/affine-components/toolbar';
import type { BuiltInEmbedBlockComponent } from '../../utils';
export class EmbedCardToolbarContext extends MenuContext {
override close = () => {
this.abortController.abort();
};
get doc() {
return this.blockComponent.doc;
}
get host() {
return this.blockComponent.host;
}
get selectedBlockModels() {
if (this.blockComponent.model) return [this.blockComponent.model];
return [];
}
get std() {
return this.host.std;
}
constructor(
public blockComponent: BuiltInEmbedBlockComponent,
public abortController: AbortController
) {
super();
}
isEmpty() {
return false;
}
isMultiple() {
return false;
}
isSingle() {
return true;
}
}
@@ -0,0 +1,934 @@
import {
EmbedLinkedDocBlockComponent,
EmbedSyncedDocBlockComponent,
getDocContentWithMaxLength,
getEmbedCardIcons,
} from '@blocksuite/affine-block-embed';
import {
toggleEmbedCardCaptionEditModal,
toggleEmbedCardEditModal,
} from '@blocksuite/affine-components/embed-card-modal';
import {
CaptionIcon,
CopyIcon,
EditIcon,
OpenIcon,
PaletteIcon,
} from '@blocksuite/affine-components/icons';
import {
notifyLinkedDocClearedAliases,
notifyLinkedDocSwitchedToCard,
notifyLinkedDocSwitchedToEmbed,
} from '@blocksuite/affine-components/notification';
import { isPeekable, peek } from '@blocksuite/affine-components/peek';
import { toast } from '@blocksuite/affine-components/toast';
import {
cloneGroups,
getMoreMenuConfig,
type MenuItem,
type MenuItemGroup,
renderGroups,
renderToolbarSeparator,
} from '@blocksuite/affine-components/toolbar';
import {
type AliasInfo,
type BookmarkBlockModel,
BookmarkStyles,
type BuiltInEmbedModel,
type EmbedCardStyle,
type EmbedGithubModel,
type EmbedLinkedDocModel,
isInternalEmbedModel,
type RootBlockModel,
} from '@blocksuite/affine-model';
import {
EmbedOptionProvider,
type EmbedOptions,
FeatureFlagService,
GenerateDocUrlProvider,
type GenerateDocUrlService,
type LinkEventType,
OpenDocExtensionIdentifier,
type TelemetryEvent,
TelemetryProvider,
ThemeProvider,
} from '@blocksuite/affine-shared/services';
import { getHostName, referenceToNode } from '@blocksuite/affine-shared/utils';
import {
BlockSelection,
type BlockStdScope,
TextSelection,
WidgetComponent,
} from '@blocksuite/block-std';
import { ArrowDownSmallIcon, MoreVerticalIcon } from '@blocksuite/icons/lit';
import { type BlockModel, Text } from '@blocksuite/store';
import { autoUpdate, computePosition, flip, offset } from '@floating-ui/dom';
import { html, nothing, type TemplateResult } from 'lit';
import { query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { join } from 'lit/directives/join.js';
import { repeat } from 'lit/directives/repeat.js';
import * as Y from 'yjs';
import {
isBookmarkBlock,
isEmbedGithubBlock,
isEmbedHtmlBlock,
isEmbedLinkedDocBlock,
isEmbedSyncedDocBlock,
} from '../../edgeless/utils/query.js';
import type { RootBlockComponent } from '../../types.js';
import {
type BuiltInEmbedBlockComponent,
isEmbedCardBlockComponent,
} from '../../utils/types';
import { BUILT_IN_GROUPS } from './config.js';
import { EmbedCardToolbarContext } from './context.js';
import { embedCardToolbarStyle } from './styles.js';
export const AFFINE_EMBED_CARD_TOOLBAR_WIDGET = 'affine-embed-card-toolbar';
export class EmbedCardToolbar extends WidgetComponent<
RootBlockModel,
RootBlockComponent
> {
static override styles = embedCardToolbarStyle;
private _abortController = new AbortController();
private readonly _copyUrl = () => {
const model = this.focusModel;
if (!model) return;
let url!: ReturnType<GenerateDocUrlService['generateDocUrl']>;
const isInternal = isInternalEmbedModel(model);
if ('url' in model) {
url = model.url;
} else if (isInternal) {
url = this.std
.getOptional(GenerateDocUrlProvider)
?.generateDocUrl(model.pageId, model.params);
}
if (!url) return;
navigator.clipboard.writeText(url).catch(console.error);
toast(this.std.host, 'Copied link to clipboard');
track(this.std, model, this._viewType, 'CopiedLink', {
control: 'copy link',
});
};
private _embedOptions: EmbedOptions | null = null;
private readonly _openEditPopup = (e: MouseEvent) => {
e.stopPropagation();
const model = this.focusModel;
if (!model || isEmbedHtmlBlock(model)) return;
const originalDocInfo = this._originalDocInfo;
this._hide();
toggleEmbedCardEditModal(
this.host,
model,
this._viewType,
originalDocInfo,
(std, component) => {
if (
isEmbedLinkedDocBlock(model) &&
component instanceof EmbedLinkedDocBlockComponent
) {
component.refreshData();
notifyLinkedDocClearedAliases(std);
}
},
(std, component, props) => {
if (
isEmbedSyncedDocBlock(model) &&
component instanceof EmbedSyncedDocBlockComponent
) {
component.convertToCard(props);
notifyLinkedDocSwitchedToCard(std);
} else {
this.model.doc.updateBlock(model, props);
component.requestUpdate();
}
}
);
track(this.std, model, this._viewType, 'OpenedAliasPopup', {
control: 'edit',
});
};
private readonly _resetAbortController = () => {
this._abortController.abort();
this._abortController = new AbortController();
};
private readonly _showCaption = () => {
const focusBlock = this.focusBlock;
if (!focusBlock) {
return;
}
try {
focusBlock.captionEditor?.show();
} catch {
toggleEmbedCardCaptionEditModal(focusBlock);
}
this._resetAbortController();
const model = this.focusModel;
if (!model) return;
track(this.std, model, this._viewType, 'OpenedCaptionEditor', {
control: 'add caption',
});
};
private readonly _toggleCardStyleSelector = (e: Event) => {
const opened = (e as CustomEvent<boolean>).detail;
if (!opened) return;
const model = this.focusModel;
if (!model) return;
track(this.std, model, this._viewType, 'OpenedCardStyleSelector', {
control: 'switch card style',
});
};
private readonly _toggleViewSelector = (e: Event) => {
const opened = (e as CustomEvent<boolean>).detail;
if (!opened) return;
const model = this.focusModel;
if (!model) return;
track(this.std, model, this._viewType, 'OpenedViewSelector', {
control: 'switch view',
});
};
private readonly _trackViewSelected = (type: string) => {
const model = this.focusModel;
if (!model) return;
track(this.std, model, this._viewType, 'SelectedView', {
control: 'selected view',
type: `${type} view`,
});
};
/*
* Caches the more menu items.
* Currently only supports configuring more menu.
*/
moreGroups: MenuItemGroup<EmbedCardToolbarContext>[] =
cloneGroups(BUILT_IN_GROUPS);
private get _canConvertToEmbedView() {
// synced doc entry controlled by awareness flag
if (this.focusModel && isEmbedLinkedDocBlock(this.focusModel)) {
const isSyncedDocEnabled = this.doc
.get(FeatureFlagService)
.getFlag('enable_synced_doc_block');
if (!isSyncedDocEnabled) {
return false;
}
}
if (!this.focusBlock) return false;
return (
'convertToEmbed' in this.focusBlock ||
this._embedOptions?.viewType === 'embed'
);
}
private get _canShowUrlOptions() {
return this.focusModel && 'url' in this.focusModel && this._isCardView;
}
private get _embedViewButtonDisabled() {
if (this.doc.readonly) {
return true;
}
return (
this.focusModel &&
this.focusBlock &&
isEmbedLinkedDocBlock(this.focusModel) &&
(referenceToNode(this.focusModel) ||
!!this.focusBlock.closest('affine-embed-synced-doc-block') ||
this.focusModel.pageId === this.doc.id)
);
}
private get _isCardView() {
return (
this.focusModel &&
(isBookmarkBlock(this.focusModel) ||
isEmbedLinkedDocBlock(this.focusModel) ||
this._embedOptions?.viewType === 'card')
);
}
private get _isEmbedView() {
return (
this.focusModel &&
!isBookmarkBlock(this.focusModel) &&
(isEmbedSyncedDocBlock(this.focusModel) ||
this._embedOptions?.viewType === 'embed')
);
}
get _openButtonDisabled() {
return (
this.focusModel &&
isEmbedLinkedDocBlock(this.focusModel) &&
this.focusModel.pageId === this.doc.id
);
}
get _originalDocInfo(): AliasInfo | undefined {
const model = this.focusModel;
if (!model) return undefined;
const doc = isInternalEmbedModel(model)
? this.std.workspace.getDoc(model.pageId)
: null;
if (doc) {
const title = doc.meta?.title;
const description = isEmbedLinkedDocBlock(model)
? getDocContentWithMaxLength(doc)
: undefined;
return { title, description };
}
return undefined;
}
get _originalDocTitle() {
const model = this.focusModel;
if (!model) return undefined;
const doc = isInternalEmbedModel(model)
? this.std.workspace.getDoc(model.pageId)
: null;
return doc?.meta?.title || 'Untitled';
}
private get _selection() {
return this.host.selection;
}
private get _viewType(): 'inline' | 'embed' | 'card' {
if (this._isCardView) {
return 'card';
}
if (this._isEmbedView) {
return 'embed';
}
return 'inline';
}
get focusModel(): BuiltInEmbedModel | undefined {
return this.focusBlock?.model;
}
private _canShowCardStylePanel(
model: BlockModel
): model is BookmarkBlockModel | EmbedGithubModel | EmbedLinkedDocModel {
return (
isBookmarkBlock(model) ||
isEmbedGithubBlock(model) ||
isEmbedLinkedDocBlock(model)
);
}
private _cardStyleSelector() {
const model = this.focusModel;
if (!model) return nothing;
if (!this._canShowCardStylePanel(model)) return nothing;
const theme = this.std.get(ThemeProvider).theme;
const { EmbedCardHorizontalIcon, EmbedCardListIcon } =
getEmbedCardIcons(theme);
const buttons = [
{
type: 'horizontal',
label: 'Large horizontal style',
icon: EmbedCardHorizontalIcon,
},
{
type: 'list',
label: 'Small horizontal style',
icon: EmbedCardListIcon,
},
] as {
type: EmbedCardStyle;
label: string;
icon: TemplateResult<1>;
}[];
return html`
<editor-menu-button
class="card-style-select"
.contentPadding=${'8px'}
.button=${html`
<editor-icon-button aria-label="Card style" .tooltip=${'Card style'}>
${PaletteIcon}
</editor-icon-button>
`}
@toggle=${this._toggleCardStyleSelector}
>
<div>
${repeat(
buttons,
button => button.type,
({ type, label, icon }) => html`
<icon-button
width="76px"
height="76px"
aria-label=${label}
class=${classMap({
selected: model.style === type,
})}
@click=${() => this._setEmbedCardStyle(type)}
>
${icon}
<affine-tooltip .offset=${4}>${label}</affine-tooltip>
</icon-button>
`
)}
</div>
</editor-menu-button>
`;
}
private _convertToCardView() {
if (this._isCardView) {
return;
}
if (!this.focusBlock) {
return;
}
if ('convertToCard' in this.focusBlock) {
this.focusBlock.convertToCard();
return;
}
if (!this.focusModel || !('url' in this.focusModel)) {
return;
}
const targetModel = this.focusModel;
const { doc, url, style, caption } = targetModel;
let targetFlavour = 'affine:bookmark',
targetStyle = style;
if (this._embedOptions && this._embedOptions.viewType === 'card') {
const { flavour, styles } = this._embedOptions;
targetFlavour = flavour;
targetStyle = styles.includes(style) ? style : styles[0];
} else {
targetStyle = BookmarkStyles.includes(style)
? style
: BookmarkStyles.filter(
style => style !== 'vertical' && style !== 'cube'
)[0];
}
const parent = doc.getParent(targetModel);
if (!parent) return;
const index = parent.children.indexOf(targetModel);
doc.addBlock(
targetFlavour as never,
{ url, style: targetStyle, caption },
parent,
index
);
this.std.selection.setGroup('note', []);
doc.deleteBlock(targetModel);
}
private _convertToEmbedView() {
if (this._isEmbedView) {
return;
}
if (!this.focusBlock) {
return;
}
if ('convertToEmbed' in this.focusBlock) {
const referenceInfo = this.focusBlock.referenceInfo$.peek();
this.focusBlock.convertToEmbed();
if (referenceInfo.title || referenceInfo.description) {
notifyLinkedDocSwitchedToEmbed(this.std);
}
return;
}
if (!this.focusModel || !('url' in this.focusModel)) {
return;
}
const targetModel = this.focusModel;
const { doc, url, style, caption } = targetModel;
if (!this._embedOptions || this._embedOptions.viewType !== 'embed') {
return;
}
const { flavour, styles } = this._embedOptions;
const targetStyle = styles.includes(style)
? style
: styles.filter(style => style !== 'vertical' && style !== 'cube')[0];
const parent = doc.getParent(targetModel);
if (!parent) return;
const index = parent.children.indexOf(targetModel);
doc.addBlock(
flavour as never,
{ url, style: targetStyle, caption },
parent,
index
);
this.std.selection.setGroup('note', []);
doc.deleteBlock(targetModel);
}
private _hide() {
this._resetAbortController();
this.focusBlock = null;
this.hide = true;
}
private _moreActions() {
if (!this.focusBlock) return nothing;
const context = new EmbedCardToolbarContext(
this.focusBlock,
this._abortController
);
return renderGroups(this.moreGroups, context);
}
private _openMenuButton() {
const openDocConfig = this.std.get(OpenDocExtensionIdentifier);
const element = this.focusBlock;
const buttons: MenuItem[] = openDocConfig.items
.map(item => {
if (
item.type === 'open-in-center-peek' &&
element &&
!isPeekable(element)
) {
return null;
}
if (
!(
this.focusModel &&
(isEmbedLinkedDocBlock(this.focusModel) ||
isEmbedSyncedDocBlock(this.focusModel))
)
) {
return null;
}
return {
label: item.label,
type: item.type,
icon: item.icon,
action: () => {
if (item.type === 'open-in-center-peek') {
element && peek(element);
} else {
this.focusBlock?.open({ openMode: item.type });
}
},
};
})
.filter(item => item !== null);
if (buttons.length === 0) {
return nothing;
}
return html`
<editor-menu-button
.contentPadding=${'8px'}
.button=${html`
<editor-icon-button
aria-label="Open"
.justify=${'space-between'}
.labelHeight=${'20px'}
>
${OpenIcon}${ArrowDownSmallIcon({ width: '16px', height: '16px' })}
</editor-icon-button>
`}
>
<div data-size="small" data-orientation="vertical">
${repeat(
buttons,
button => button.label,
({ label, icon, action, disabled }) => html`
<editor-menu-action
aria-label=${ifDefined(label)}
?disabled=${disabled}
@click=${action}
>
${icon}<span class="label">${label}</span>
</editor-menu-action>
`
)}
</div>
</editor-menu-button>
`;
}
private _setEmbedCardStyle(style: EmbedCardStyle) {
const model = this.focusModel;
if (!model) return;
model.doc.updateBlock(model, { style });
this.requestUpdate();
this._abortController.abort();
track(this.std, model, this._viewType, 'SelectedCardStyle', {
control: 'select card style',
type: style,
});
}
private _show() {
if (!this.focusBlock) {
return;
}
this.hide = false;
this._abortController.signal.addEventListener(
'abort',
autoUpdate(this.focusBlock, this, () => {
if (!this.focusBlock) {
return;
}
computePosition(this.focusBlock, this, {
placement: 'top-start',
middleware: [flip(), offset(8)],
})
.then(({ x, y }) => {
this.style.left = `${x}px`;
this.style.top = `${y}px`;
})
.catch(console.error);
})
);
}
private _turnIntoInlineView() {
if (this.focusBlock && 'covertToInline' in this.focusBlock) {
this.focusBlock.covertToInline();
return;
}
if (!this.focusModel || !('url' in this.focusModel)) {
return;
}
const targetModel = this.focusModel;
const { doc, title, caption, url } = targetModel;
const parent = doc.getParent(targetModel);
const index = parent?.children.indexOf(targetModel);
const yText = new Y.Text();
const insert = title || caption || url;
yText.insert(0, insert);
yText.format(0, insert.length, { link: url });
const text = new Text(yText);
doc.addBlock(
'affine:paragraph',
{
text,
},
parent,
index
);
doc.deleteBlock(targetModel);
}
private _viewSelector() {
const buttons = [];
buttons.push({
type: 'inline',
label: 'Inline view',
action: () => this._turnIntoInlineView(),
disabled: this.doc.readonly,
});
buttons.push({
type: 'card',
label: 'Card view',
action: () => this._convertToCardView(),
disabled: this.doc.readonly,
});
if (this._canConvertToEmbedView || this._isEmbedView) {
buttons.push({
type: 'embed',
label: 'Embed view',
action: () => this._convertToEmbedView(),
disabled: this.doc.readonly || this._embedViewButtonDisabled,
});
}
return html`
<editor-menu-button
.contentPadding=${'8px'}
.button=${html`
<editor-icon-button
aria-label="Switch view"
.justify=${'space-between'}
.labelHeight=${'20px'}
.iconContainerWidth=${'110px'}
>
<div class="label">
<span style="text-transform: capitalize">${this._viewType}</span>
view
</div>
${ArrowDownSmallIcon({ width: '16px', height: '16px' })}
</editor-icon-button>
`}
@toggle=${this._toggleViewSelector}
>
<div data-size="small" data-orientation="vertical">
${repeat(
buttons,
button => button.type,
({ type, label, action, disabled }) => html`
<editor-menu-action
data-testid=${`link-to-${type}`}
aria-label=${ifDefined(label)}
?data-selected=${this._viewType === type}
?disabled=${disabled || this._viewType === type}
@click=${() => {
action();
this._trackViewSelected(type);
this._hide();
}}
>
${label}
</editor-menu-action>
`
)}
</div>
</editor-menu-button>
`;
}
override connectedCallback() {
super.connectedCallback();
this.moreGroups = getMoreMenuConfig(this.std).configure(this.moreGroups);
this.disposables.add(
this._selection.slots.changed.on(() => {
const hasTextSelection = this._selection.find(TextSelection);
if (hasTextSelection) {
this._hide();
return;
}
const blockSelections = this._selection.filter(BlockSelection);
if (!blockSelections || blockSelections.length !== 1) {
this._hide();
return;
}
const block = this.std.view.getBlock(blockSelections[0].blockId);
if (!block || !isEmbedCardBlockComponent(block)) {
this._hide();
return;
}
this.focusBlock = block as BuiltInEmbedBlockComponent;
this._show();
})
);
}
override render() {
if (this.hide) return nothing;
const model = this.focusModel;
if (!model) return nothing;
this._embedOptions =
'url' in model
? this.std.get(EmbedOptionProvider).getEmbedBlockOptions(model.url)
: null;
const hasUrl = this._canShowUrlOptions && 'url' in model;
const buttons = [
this._openMenuButton(),
hasUrl
? html`
<a
class="affine-link-preview"
href=${model.url}
rel="noopener noreferrer"
target="_blank"
>
<span>${getHostName(model.url)}</span>
</a>
`
: nothing,
// internal embed model
isEmbedLinkedDocBlock(model) && model.title
? html`
<editor-icon-button
class="doc-title"
aria-label="Doc title"
.hover=${false}
.labelHeight=${'20px'}
.tooltip=${this._originalDocTitle}
@click=${this.focusBlock?.open}
>
<span class="label">${this._originalDocTitle}</span>
</editor-icon-button>
`
: nothing,
isEmbedHtmlBlock(model)
? nothing
: html`
<editor-icon-button
aria-label="Copy link"
data-testid="copy-link"
.tooltip=${'Copy link'}
@click=${this._copyUrl}
>
${CopyIcon}
</editor-icon-button>
<editor-icon-button
aria-label="Edit"
data-testid="edit"
.tooltip=${'Edit'}
?disabled=${this.doc.readonly}
@click=${this._openEditPopup}
>
${EditIcon}
</editor-icon-button>
`,
this._viewSelector(),
this._cardStyleSelector(),
html`
<editor-icon-button
aria-label="Caption"
.tooltip=${'Add Caption'}
?disabled=${this.doc.readonly}
@click=${this._showCaption}
>
${CaptionIcon}
</editor-icon-button>
`,
html`
<editor-menu-button
.contentPadding=${'8px'}
.button=${html`
<editor-icon-button
aria-label="More"
.tooltip=${'More'}
.iconSize=${'20px'}
>
${MoreVerticalIcon()}
</editor-icon-button>
`}
>
<div data-size="large" data-orientation="vertical">
${this._moreActions()}
</div>
</editor-menu-button>
`,
];
return html`
<editor-toolbar class="embed-card-toolbar">
${join(
buttons.filter(button => button !== nothing),
renderToolbarSeparator
)}
</editor-toolbar>
`;
}
@query('.embed-card-toolbar-button.card-style')
accessor cardStyleButton: HTMLElement | null = null;
@query('.embed-card-toolbar')
accessor embedCardToolbarElement!: HTMLElement;
@state()
accessor focusBlock: BuiltInEmbedBlockComponent | null = null;
@state()
accessor hide: boolean = true;
@query('.embed-card-toolbar-button.more-button')
accessor moreButton: HTMLElement | null = null;
}
declare global {
interface HTMLElementTagNameMap {
[AFFINE_EMBED_CARD_TOOLBAR_WIDGET]: EmbedCardToolbar;
}
}
function track(
std: BlockStdScope,
model: BuiltInEmbedModel,
viewType: string,
event: LinkEventType,
props: Partial<TelemetryEvent>
) {
std.getOptional(TelemetryProvider)?.track(event, {
segment: 'toolbar',
page: 'doc editor',
module: 'embed card toolbar',
type: `${viewType} view`,
category: isInternalEmbedModel(model) ? 'linked doc' : 'link',
...props,
});
}
@@ -0,0 +1,67 @@
import { css } from 'lit';
export const embedCardToolbarStyle = css`
:host {
position: absolute;
top: 0;
left: 0;
z-index: var(--affine-z-index-popover);
}
.affine-link-preview {
display: flex;
justify-content: flex-start;
min-width: 60px;
max-width: 140px;
padding: var(--1, 0px);
border-radius: var(--1, 0px);
opacity: var(--add, 1);
user-select: none;
cursor: pointer;
color: var(--affine-link-color);
font-feature-settings:
'clig' off,
'liga' off;
font-family: var(--affine-font-family);
font-size: var(--affine-font-sm);
font-style: normal;
font-weight: 400;
text-decoration: none;
text-wrap: nowrap;
}
.affine-link-preview > span {
display: inline-block;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
text-overflow: ellipsis;
overflow: hidden;
opacity: var(--add, 1);
}
.card-style-select icon-button.selected {
border: 1px solid var(--affine-brand-color);
}
editor-icon-button.doc-title .label {
max-width: 110px;
display: inline-block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
user-select: none;
cursor: pointer;
color: var(--affine-link-color);
font-feature-settings:
'clig' off,
'liga' off;
font-family: var(--affine-font-family);
font-size: var(--affine-font-sm);
font-style: normal;
font-weight: 400;
text-decoration: none;
text-wrap: nowrap;
}
`;
@@ -0,0 +1,95 @@
import { isFormatSupported } from '@blocksuite/affine-components/rich-text';
import { renderToolbarSeparator } from '@blocksuite/affine-components/toolbar';
import { html, type TemplateResult } from 'lit';
import type { AffineFormatBarWidget } from '../format-bar.js';
import { HighlightButton } from './highlight/highlight-button.js';
import { ParagraphButton } from './paragraph-button.js';
export function ConfigRenderer(formatBar: AffineFormatBarWidget) {
return (
formatBar.configItems
.filter(item => {
if (item.type === 'paragraph-action') {
return false;
}
if (item.type === 'highlighter-dropdown') {
const [supported] = isFormatSupported(
formatBar.std.command.chain()
).run();
return supported;
}
if (item.type === 'inline-action') {
return item.showWhen(formatBar.std.command.chain(), formatBar);
}
return true;
})
.map(item => {
let template: TemplateResult | null = null;
switch (item.type) {
case 'divider':
template = renderToolbarSeparator();
break;
case 'highlighter-dropdown': {
template = HighlightButton(formatBar);
break;
}
case 'paragraph-dropdown':
template = ParagraphButton(formatBar);
break;
case 'inline-action': {
template = html`
<editor-icon-button
data-testid=${item.id}
?active=${item.isActive(
formatBar.std.command.chain(),
formatBar
)}
.tooltip=${item.name}
@click=${() => {
item.action(formatBar.std.command.chain(), formatBar);
formatBar.requestUpdate();
}}
>
${typeof item.icon === 'function' ? item.icon() : item.icon}
</editor-icon-button>
`;
break;
}
case 'custom': {
template = item.render(formatBar);
break;
}
default:
template = null;
}
return [template, item] as const;
})
.filter(([template]) => template !== null && template !== undefined)
// 1. delete the redundant dividers in the middle
.filter(([_, item], index, list) => {
if (
item.type === 'divider' &&
index + 1 < list.length &&
list[index + 1][1].type === 'divider'
) {
return false;
}
return true;
})
// 2. delete the redundant dividers at the head and tail
.filter(([_, item], index, list) => {
if (item.type === 'divider') {
if (index === 0) {
return false;
}
if (index === list.length - 1) {
return false;
}
}
return true;
})
.map(([template]) => template)
);
}
@@ -0,0 +1,42 @@
interface HighlightConfig {
name: string;
color: string | null;
hotkey: string | null;
}
const colors = [
'red',
'orange',
'yellow',
'green',
'teal',
'blue',
'purple',
'grey',
];
export const backgroundConfig: HighlightConfig[] = [
{
name: 'Default Background',
color: null,
hotkey: null,
},
...colors.map(color => ({
name: `${color[0].toUpperCase()}${color.slice(1)} Background`,
color: `var(--affine-text-highlight-${color})`,
hotkey: null,
})),
];
export const foregroundConfig: HighlightConfig[] = [
{
name: 'Default Color',
color: null,
hotkey: null,
},
...colors.map(color => ({
name: `${color[0].toUpperCase()}${color.slice(1)}`,
color: `var(--affine-text-highlight-foreground-${color})`,
hotkey: null,
})),
];
@@ -0,0 +1,166 @@
import { whenHover } from '@blocksuite/affine-components/hover';
import {
ArrowDownIcon,
HighLightDuotoneIcon,
TextBackgroundDuotoneIcon,
TextForegroundDuotoneIcon,
} from '@blocksuite/affine-components/icons';
import {
formatBlockCommand,
formatNativeCommand,
formatTextCommand,
} from '@blocksuite/affine-components/rich-text';
import {
getBlockSelectionsCommand,
getTextSelectionCommand,
} from '@blocksuite/affine-shared/commands';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import type { EditorHost } from '@blocksuite/block-std';
import { assertExists } from '@blocksuite/global/utils';
import { computePosition, flip, offset, shift } from '@floating-ui/dom';
import { html } from 'lit';
import { ref, type RefOrCallback } from 'lit/directives/ref.js';
import type { AffineFormatBarWidget } from '../../format-bar.js';
import { backgroundConfig, foregroundConfig } from './consts.js';
enum HighlightType {
Color = 'color',
Background = 'background',
}
let lastUsedColor: string | null = null;
let lastUsedHighlightType: HighlightType = HighlightType.Background;
const updateHighlight = (
host: EditorHost,
color: string | null,
highlightType: HighlightType
) => {
lastUsedColor = color;
lastUsedHighlightType = highlightType;
const payload: {
styles: AffineTextAttributes;
} = {
styles: {
[`${highlightType}`]: color,
},
};
host.std.command
.chain()
.try(chain => [
chain.pipe(getTextSelectionCommand).pipe(formatTextCommand, payload),
chain.pipe(getBlockSelectionsCommand).pipe(formatBlockCommand, payload),
chain.pipe(formatNativeCommand, payload),
])
.run();
};
const HighlightPanel = (
formatBar: AffineFormatBarWidget,
containerRef?: RefOrCallback
) => {
return html`
<editor-menu-content class="highlight-panel" data-show ${ref(containerRef)}>
<div data-orientation="vertical">
<!-- Text Color Highlight -->
<div class="highligh-panel-heading">Color</div>
${foregroundConfig.map(
({ name, color }) => html`
<editor-menu-action
data-testid="${color ?? 'unset'}"
@click="${() => {
updateHighlight(formatBar.host, color, HighlightType.Color);
formatBar.requestUpdate();
}}"
>
<span style="display: flex; color: ${color}">
${TextForegroundDuotoneIcon}
</span>
${name}
</editor-menu-action>
`
)}
<!-- Text Background Highlight -->
<div class="highligh-panel-heading">Background</div>
${backgroundConfig.map(
({ name, color }) => html`
<editor-menu-action
data-testid="${color ?? 'transparent'}"
@click="${() => {
updateHighlight(
formatBar.host,
color,
HighlightType.Background
);
formatBar.requestUpdate();
}}"
>
<span style="display: flex; color: ${color ?? 'transparent'}">
${TextBackgroundDuotoneIcon}
</span>
${name}
</editor-menu-action>
`
)}
</div>
</editor-menu-content>
`;
};
export const HighlightButton = (formatBar: AffineFormatBarWidget) => {
const editorHost = formatBar.host;
const { setFloating, setReference } = whenHover(isHover => {
if (!isHover) {
const panel =
formatBar.shadowRoot?.querySelector<HTMLElement>('.highlight-panel');
if (!panel) return;
panel.style.display = 'none';
return;
}
const button =
formatBar.shadowRoot?.querySelector<HTMLElement>('.highlight-button');
const panel =
formatBar.shadowRoot?.querySelector<HTMLElement>('.highlight-panel');
assertExists(button);
assertExists(panel);
panel.style.display = 'flex';
computePosition(button, panel, {
placement: 'bottom',
middleware: [
flip(),
offset(6),
shift({
padding: 6,
}),
],
})
.then(({ x, y }) => {
panel.style.left = `${x}px`;
panel.style.top = `${y}px`;
})
.catch(console.error);
});
const highlightPanel = HighlightPanel(formatBar, setFloating);
return html`
<div class="highlight-button" ${ref(setReference)}>
<editor-icon-button
class="highlight-icon"
data-last-used="${lastUsedColor ?? 'unset'}"
@click="${() =>
updateHighlight(editorHost, lastUsedColor, lastUsedHighlightType)}"
>
<span style="display: flex; color: ${lastUsedColor}">
${HighLightDuotoneIcon}
</span>
${ArrowDownIcon}
</editor-icon-button>
${highlightPanel}
</div>
`;
};
@@ -0,0 +1,127 @@
import { whenHover } from '@blocksuite/affine-components/hover';
import { ArrowDownIcon } from '@blocksuite/affine-components/icons';
import { textConversionConfigs } from '@blocksuite/affine-components/rich-text';
import type { ParagraphBlockModel } from '@blocksuite/affine-model';
import type { EditorHost } from '@blocksuite/block-std';
import { assertExists } from '@blocksuite/global/utils';
import { computePosition, flip, offset, shift } from '@floating-ui/dom';
import { html } from 'lit';
import { ref, type RefOrCallback } from 'lit/directives/ref.js';
import { repeat } from 'lit/directives/repeat.js';
import type { ParagraphActionConfigItem } from '../config.js';
import type { AffineFormatBarWidget } from '../format-bar.js';
interface ParagraphPanelProps {
host: EditorHost;
formatBar: AffineFormatBarWidget;
ref?: RefOrCallback;
}
const ParagraphPanel = ({
formatBar,
host,
ref: containerRef,
}: ParagraphPanelProps) => {
const config = formatBar.configItems
.filter(
(item): item is ParagraphActionConfigItem =>
item.type === 'paragraph-action'
)
.filter(({ flavour }) => host.doc.schema.flavourSchemaMap.has(flavour));
const renderedConfig = repeat(
config,
item => html`
<editor-menu-action
data-testid="${item.id}"
@click="${() => item.action(formatBar.std.command.chain(), formatBar)}"
>
${typeof item.icon === 'function' ? item.icon() : item.icon}
${item.name}
</editor-menu-action>
`
);
return html`
<editor-menu-content class="paragraph-panel" data-show ${ref(containerRef)}>
<div data-orientation="vertical">${renderedConfig}</div>
</editor-menu-content>
`;
};
export const ParagraphButton = (formatBar: AffineFormatBarWidget) => {
if (formatBar.displayType !== 'text' && formatBar.displayType !== 'block') {
return null;
}
const selectedBlocks = formatBar.selectedBlocks;
// only support model with text
if (selectedBlocks.some(el => !el.model.text)) {
return null;
}
const paragraphIcon =
selectedBlocks.length < 1
? textConversionConfigs[0].icon
: (textConversionConfigs.find(
({ flavour, type }) =>
selectedBlocks[0].flavour === flavour &&
(selectedBlocks[0].model as ParagraphBlockModel).type === type
)?.icon ?? textConversionConfigs[0].icon);
const rootComponent = formatBar.block;
if (rootComponent.model.flavour !== 'affine:page') {
console.error('paragraph button host is not a page component');
return null;
}
const { setFloating, setReference } = whenHover(isHover => {
if (!isHover) {
const panel =
formatBar.shadowRoot?.querySelector<HTMLElement>('.paragraph-panel');
if (!panel) return;
panel.style.display = 'none';
return;
}
const formatQuickBarElement = formatBar.formatBarElement;
const button =
formatBar.shadowRoot?.querySelector<HTMLElement>('.paragraph-button');
const panel =
formatBar.shadowRoot?.querySelector<HTMLElement>('.paragraph-panel');
assertExists(button);
assertExists(panel);
assertExists(formatQuickBarElement, 'format quick bar should exist');
panel.style.display = 'flex';
computePosition(formatQuickBarElement, panel, {
placement: 'top-start',
middleware: [
flip(),
offset(6),
shift({
padding: 6,
}),
],
})
.then(({ x, y }) => {
panel.style.left = `${x}px`;
panel.style.top = `${y}px`;
})
.catch(console.error);
});
const paragraphPanel = ParagraphPanel({
formatBar,
host: formatBar.host,
ref: setFloating,
});
return html`
<div class="paragraph-button" ${ref(setReference)}>
<editor-icon-button class="paragraph-button-icon">
${paragraphIcon} ${ArrowDownIcon}
</editor-icon-button>
${paragraphPanel}
</div>
`;
};
@@ -0,0 +1,487 @@
import {
convertToDatabase,
DATABASE_CONVERT_WHITE_LIST,
} from '@blocksuite/affine-block-database';
import {
convertSelectedBlocksToLinkedDoc,
getTitleFromSelectedModels,
notifyDocCreated,
promptDocTitle,
} from '@blocksuite/affine-block-embed';
import {
BoldIcon,
BulletedListIcon,
CheckBoxIcon,
CodeIcon,
CopyIcon,
DatabaseTableViewIcon20,
DeleteIcon,
DuplicateIcon,
Heading1Icon,
Heading2Icon,
Heading3Icon,
Heading4Icon,
Heading5Icon,
Heading6Icon,
ItalicIcon,
LinkedDocIcon,
LinkIcon,
NumberedListIcon,
QuoteIcon,
StrikethroughIcon,
TextIcon,
UnderlineIcon,
} from '@blocksuite/affine-components/icons';
import {
deleteTextCommand,
toggleBold,
toggleCode,
toggleItalic,
toggleLink,
toggleStrike,
toggleUnderline,
} from '@blocksuite/affine-components/rich-text';
import { toast } from '@blocksuite/affine-components/toast';
import type { MenuItemGroup } from '@blocksuite/affine-components/toolbar';
import { renderGroups } from '@blocksuite/affine-components/toolbar';
import {
copySelectedModelsCommand,
deleteSelectedModelsCommand,
draftSelectedModelsCommand,
getBlockIndexCommand,
getBlockSelectionsCommand,
getImageSelectionsCommand,
getSelectedBlocksCommand,
getSelectedModelsCommand,
getTextSelectionCommand,
} from '@blocksuite/affine-shared/commands';
import { TelemetryProvider } from '@blocksuite/affine-shared/services';
import type {
BlockComponent,
Chain,
InitCommandCtx,
} from '@blocksuite/block-std';
import { tableViewMeta } from '@blocksuite/data-view/view-presets';
import { assertExists } from '@blocksuite/global/utils';
import { MoreVerticalIcon } from '@blocksuite/icons/lit';
import { Slice } from '@blocksuite/store';
import { html, type TemplateResult } from 'lit';
import { FormatBarContext } from './context.js';
import type { AffineFormatBarWidget } from './format-bar.js';
export type DividerConfigItem = {
type: 'divider';
};
export type HighlighterDropdownConfigItem = {
type: 'highlighter-dropdown';
};
export type ParagraphDropdownConfigItem = {
type: 'paragraph-dropdown';
};
export type InlineActionConfigItem = {
id: string;
name: string;
type: 'inline-action';
action: (
chain: Chain<InitCommandCtx>,
formatBar: AffineFormatBarWidget
) => void;
icon: TemplateResult | (() => HTMLElement);
isActive: (
chain: Chain<InitCommandCtx>,
formatBar: AffineFormatBarWidget
) => boolean;
showWhen: (
chain: Chain<InitCommandCtx>,
formatBar: AffineFormatBarWidget
) => boolean;
};
export type ParagraphActionConfigItem = {
id: string;
type: 'paragraph-action';
name: string;
action: (
chain: Chain<InitCommandCtx>,
formatBar: AffineFormatBarWidget
) => void;
icon: TemplateResult | (() => HTMLElement);
flavour: string;
};
export type CustomConfigItem = {
type: 'custom';
render: (formatBar: AffineFormatBarWidget) => TemplateResult | null;
};
export type FormatBarConfigItem =
| DividerConfigItem
| HighlighterDropdownConfigItem
| ParagraphDropdownConfigItem
| ParagraphActionConfigItem
| InlineActionConfigItem
| CustomConfigItem;
export function toolbarDefaultConfig(toolbar: AffineFormatBarWidget) {
toolbar
.clearConfig()
.addParagraphDropdown()
.addDivider()
.addTextStyleToggle({
key: 'bold',
action: chain => chain.pipe(toggleBold).run(),
icon: BoldIcon,
})
.addTextStyleToggle({
key: 'italic',
action: chain => chain.pipe(toggleItalic).run(),
icon: ItalicIcon,
})
.addTextStyleToggle({
key: 'underline',
action: chain => chain.pipe(toggleUnderline).run(),
icon: UnderlineIcon,
})
.addTextStyleToggle({
key: 'strike',
action: chain => chain.pipe(toggleStrike).run(),
icon: StrikethroughIcon,
})
.addTextStyleToggle({
key: 'code',
action: chain => chain.pipe(toggleCode).run(),
icon: CodeIcon,
})
.addTextStyleToggle({
key: 'link',
action: chain => chain.pipe(toggleLink).run(),
icon: LinkIcon,
})
.addDivider()
.addHighlighterDropdown()
.addDivider()
.addInlineAction({
id: 'convert-to-database',
name: 'Create Table',
icon: DatabaseTableViewIcon20,
isActive: () => false,
action: () => {
convertToDatabase(toolbar.host, tableViewMeta.type);
},
showWhen: chain => {
const middleware = (count = 0) => {
return (
ctx: { selectedBlocks: BlockComponent[] },
next: () => void
) => {
const { selectedBlocks } = ctx;
if (!selectedBlocks || selectedBlocks.length === count) return;
const allowed = selectedBlocks.every(block =>
DATABASE_CONVERT_WHITE_LIST.includes(block.flavour)
);
if (!allowed) return;
next();
};
};
let [result] = chain
.pipe(getTextSelectionCommand)
.pipe(getSelectedBlocksCommand, {
types: ['text'],
})
.pipe(middleware(1))
.run();
if (result) return true;
[result] = chain
.tryAll(chain => [
chain.pipe(getBlockSelectionsCommand),
chain.pipe(getImageSelectionsCommand),
])
.pipe(getSelectedBlocksCommand, {
types: ['block', 'image'],
})
.pipe(middleware(0))
.run();
return result;
},
})
.addDivider()
.addInlineAction({
id: 'convert-to-linked-doc',
name: 'Create Linked Doc',
icon: LinkedDocIcon,
isActive: () => false,
action: (chain, formatBar) => {
const [_, ctx] = chain
.pipe(getSelectedModelsCommand, {
types: ['block', 'text'],
mode: 'flat',
})
.pipe(draftSelectedModelsCommand)
.run();
const { draftedModels, selectedModels, std } = ctx;
if (!selectedModels?.length || !draftedModels) return;
const host = formatBar.host;
host.selection.clear();
const doc = host.doc;
const autofill = getTitleFromSelectedModels(selectedModels);
promptDocTitle(std, autofill)
.then(async title => {
if (title === null) return;
await convertSelectedBlocksToLinkedDoc(
std,
doc,
draftedModels,
title
);
notifyDocCreated(std, doc);
host.std.getOptional(TelemetryProvider)?.track('DocCreated', {
control: 'create linked doc',
page: 'doc editor',
module: 'format toolbar',
type: 'embed-linked-doc',
});
host.std.getOptional(TelemetryProvider)?.track('LinkedDocCreated', {
control: 'create linked doc',
page: 'doc editor',
module: 'format toolbar',
type: 'embed-linked-doc',
});
})
.catch(console.error);
},
showWhen: chain => {
const [_, ctx] = chain
.pipe(getSelectedModelsCommand, {
types: ['block', 'text'],
mode: 'highest',
})
.run();
const { selectedModels } = ctx;
return !!selectedModels && selectedModels.length > 0;
},
})
.addBlockTypeSwitch({
flavour: 'affine:paragraph',
type: 'text',
name: 'Text',
icon: TextIcon,
})
.addBlockTypeSwitch({
flavour: 'affine:paragraph',
type: 'h1',
name: 'Heading 1',
icon: Heading1Icon,
})
.addBlockTypeSwitch({
flavour: 'affine:paragraph',
type: 'h2',
name: 'Heading 2',
icon: Heading2Icon,
})
.addBlockTypeSwitch({
flavour: 'affine:paragraph',
type: 'h3',
name: 'Heading 3',
icon: Heading3Icon,
})
.addBlockTypeSwitch({
flavour: 'affine:paragraph',
type: 'h4',
name: 'Heading 4',
icon: Heading4Icon,
})
.addBlockTypeSwitch({
flavour: 'affine:paragraph',
type: 'h5',
name: 'Heading 5',
icon: Heading5Icon,
})
.addBlockTypeSwitch({
flavour: 'affine:paragraph',
type: 'h6',
name: 'Heading 6',
icon: Heading6Icon,
})
.addBlockTypeSwitch({
flavour: 'affine:list',
type: 'bulleted',
name: 'Bulleted List',
icon: BulletedListIcon,
})
.addBlockTypeSwitch({
flavour: 'affine:list',
type: 'numbered',
name: 'Numbered List',
icon: NumberedListIcon,
})
.addBlockTypeSwitch({
flavour: 'affine:list',
type: 'todo',
name: 'To-do List',
icon: CheckBoxIcon,
})
.addBlockTypeSwitch({
flavour: 'affine:code',
name: 'Code Block',
icon: CodeIcon,
})
.addBlockTypeSwitch({
flavour: 'affine:paragraph',
type: 'quote',
name: 'Quote',
icon: QuoteIcon,
});
}
export const BUILT_IN_GROUPS: MenuItemGroup<FormatBarContext>[] = [
{
type: 'clipboard',
items: [
{
type: 'copy',
label: 'Copy',
icon: CopyIcon,
disabled: c => c.doc.readonly,
action: c => {
c.std.command
.chain()
.pipe(getSelectedModelsCommand)
.with({
onCopy: () => {
toast(c.host, 'Copied to clipboard');
},
})
.pipe(draftSelectedModelsCommand)
.pipe(copySelectedModelsCommand)
.run();
},
},
{
type: 'duplicate',
label: 'Duplicate',
icon: DuplicateIcon,
disabled: c => c.doc.readonly,
action: c => {
c.doc.captureSync();
c.std.command
.chain()
.try<{ currentSelectionPath: string }>(cmd => [
cmd.pipe(getTextSelectionCommand).pipe((ctx, next) => {
const textSelection = ctx.currentTextSelection;
assertExists(textSelection);
const end = textSelection.to ?? textSelection.from;
next({ currentSelectionPath: end.blockId });
}),
cmd.pipe(getBlockSelectionsCommand).pipe((ctx, next) => {
const currentBlockSelections = ctx.currentBlockSelections;
assertExists(currentBlockSelections);
const blockSelection = currentBlockSelections.at(-1);
if (!blockSelection) {
return;
}
next({ currentSelectionPath: blockSelection.blockId });
}),
])
.pipe(getBlockIndexCommand)
.pipe(getSelectedModelsCommand)
.pipe(draftSelectedModelsCommand)
.pipe((ctx, next) => {
ctx.draftedModels
.then(models => {
const slice = Slice.fromModels(ctx.std.store, models);
return ctx.std.clipboard.duplicateSlice(
slice,
ctx.std.store,
ctx.parentBlock?.model.id,
ctx.blockIndex ? ctx.blockIndex + 1 : 1
);
})
.catch(console.error);
return next();
})
.run();
},
},
],
},
{
type: 'delete',
items: [
{
type: 'delete',
label: 'Delete',
icon: DeleteIcon,
disabled: c => c.doc.readonly,
action: c => {
// remove text
const [result] = c.std.command
.chain()
.pipe(getTextSelectionCommand)
.pipe(deleteTextCommand)
.run();
if (result) {
return;
}
// remove blocks
c.std.command
.chain()
.tryAll(chain => [
chain.pipe(getBlockSelectionsCommand),
chain.pipe(getImageSelectionsCommand),
])
.pipe(getSelectedModelsCommand)
.pipe(deleteSelectedModelsCommand)
.run();
c.toolbar.reset();
},
},
],
},
];
export function toolbarMoreButton(toolbar: AffineFormatBarWidget) {
const richText = getRichText();
if (richText?.dataset.disableAskAi !== undefined) return null;
const context = new FormatBarContext(toolbar);
const actions = renderGroups(toolbar.moreGroups, context);
return html`
<editor-menu-button
.contentPadding="${'8px'}"
.button="${html`
<editor-icon-button
aria-label="More"
.tooltip=${'More'}
.iconSize=${'20px'}
>
${MoreVerticalIcon()}
</editor-icon-button>
`}"
>
<div data-size="large" data-orientation="vertical">${actions}</div>
</editor-menu-button>
`;
}
const getRichText = () => {
const selection = getSelection();
if (!selection) return null;
if (selection.rangeCount === 0) return null;
const range = selection.getRangeAt(0);
const commonAncestorContainer =
range.commonAncestorContainer instanceof Element
? range.commonAncestorContainer
: range.commonAncestorContainer.parentElement;
if (!commonAncestorContainer) return null;
return commonAncestorContainer.closest('rich-text');
};
@@ -0,0 +1,72 @@
import { MenuContext } from '@blocksuite/affine-components/toolbar';
import {
getBlockSelectionsCommand,
getImageSelectionsCommand,
getSelectedModelsCommand,
getTextSelectionCommand,
} from '@blocksuite/affine-shared/commands';
import type { AffineFormatBarWidget } from './format-bar.js';
export class FormatBarContext extends MenuContext {
get doc() {
return this.toolbar.host.doc;
}
get host() {
return this.toolbar.host;
}
get selectedBlockModels() {
const [success, result] = this.std.command
.chain()
.tryAll(chain => [
chain.pipe(getTextSelectionCommand),
chain.pipe(getBlockSelectionsCommand),
chain.pipe(getImageSelectionsCommand),
])
.pipe(getSelectedModelsCommand, {
mode: 'highest',
})
.run();
if (!success) {
return [];
}
// should return an empty array if `to` of the range is null
if (
result.currentTextSelection &&
!result.currentTextSelection.to &&
result.currentTextSelection.from.length === 0
) {
return [];
}
if (result.selectedModels?.length) {
return result.selectedModels;
}
return [];
}
get std() {
return this.toolbar.std;
}
constructor(public toolbar: AffineFormatBarWidget) {
super();
}
isEmpty() {
return this.selectedBlockModels.length === 0;
}
isMultiple() {
return this.selectedBlockModels.length > 1;
}
isSingle() {
return this.selectedBlockModels.length === 1;
}
}
@@ -0,0 +1,611 @@
import { updateBlockType } from '@blocksuite/affine-block-note';
import { HoverController } from '@blocksuite/affine-components/hover';
import {
isFormatSupported,
isTextStyleActive,
type RichText,
} from '@blocksuite/affine-components/rich-text';
import {
cloneGroups,
getMoreMenuConfig,
type MenuItemGroup,
} from '@blocksuite/affine-components/toolbar';
import {
CodeBlockModel,
ImageBlockModel,
ListBlockModel,
ParagraphBlockModel,
} from '@blocksuite/affine-model';
import {
getSelectedBlocksCommand,
getTextSelectionCommand,
} from '@blocksuite/affine-shared/commands';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { matchModels } from '@blocksuite/affine-shared/utils';
import {
type BlockComponent,
BlockSelection,
CursorSelection,
TextSelection,
WidgetComponent,
} from '@blocksuite/block-std';
import {
assertExists,
DisposableGroup,
nextTick,
} from '@blocksuite/global/utils';
import type { BaseSelection } from '@blocksuite/store';
import {
autoUpdate,
computePosition,
inline,
offset,
type Placement,
type ReferenceElement,
shift,
} from '@floating-ui/dom';
import { html, nothing } from 'lit';
import { query, state } from 'lit/decorators.js';
import { ConfigRenderer } from './components/config-renderer.js';
import {
BUILT_IN_GROUPS,
type FormatBarConfigItem,
type InlineActionConfigItem,
type ParagraphActionConfigItem,
toolbarDefaultConfig,
toolbarMoreButton,
} from './config.js';
import type { FormatBarContext } from './context.js';
import { formatBarStyle } from './styles.js';
export const AFFINE_FORMAT_BAR_WIDGET = 'affine-format-bar-widget';
export class AffineFormatBarWidget extends WidgetComponent {
static override styles = formatBarStyle;
private _abortController = new AbortController();
private _floatDisposables: DisposableGroup | null = null;
private _lastCursor: CursorSelection | undefined = undefined;
private _placement: Placement = 'top';
/*
* Caches the more menu items.
* Currently only supports configuring more menu.
*/
moreGroups: MenuItemGroup<FormatBarContext>[] = cloneGroups(BUILT_IN_GROUPS);
private get _selectionManager() {
return this.host.selection;
}
get displayType() {
return this._displayType;
}
get nativeRange() {
const sl = document.getSelection();
if (!sl || sl.rangeCount === 0) return null;
return sl.getRangeAt(0);
}
get selectedBlocks() {
return this._selectedBlocks;
}
private _calculatePlacement() {
const rootComponent = this.block;
this.handleEvent('dragStart', () => {
this._dragging = true;
});
this.handleEvent('dragEnd', () => {
this._dragging = false;
});
// calculate placement
this.disposables.add(
this.host.event.add('pointerUp', ctx => {
let targetRect: DOMRect | null = null;
if (this.displayType === 'text' || this.displayType === 'native') {
const range = this.nativeRange;
if (!range) {
this.reset();
return;
}
targetRect = range.getBoundingClientRect();
} else if (this.displayType === 'block') {
const block = this._selectedBlocks[0];
if (!block) return;
targetRect = block.getBoundingClientRect();
} else {
return;
}
const { top: editorHostTop, bottom: editorHostBottom } =
this.host.getBoundingClientRect();
const e = ctx.get('pointerState');
if (editorHostBottom - targetRect.bottom < 50) {
this._placement = 'top';
} else if (targetRect.top - Math.max(editorHostTop, 0) < 50) {
this._placement = 'bottom';
} else if (e.raw.y < targetRect.top + targetRect.height / 2) {
this._placement = 'top';
} else {
this._placement = 'bottom';
}
})
);
// listen to selection change
this.disposables.add(
this._selectionManager.slots.changed.on(() => {
const update = async () => {
const textSelection = rootComponent.selection.find(TextSelection);
const blockSelections =
rootComponent.selection.filter(BlockSelection);
// Should not re-render format bar when only cursor selection changed in edgeless
const cursorSelection = rootComponent.selection.find(CursorSelection);
if (cursorSelection) {
if (!this._lastCursor) {
this._lastCursor = cursorSelection;
return;
}
if (!this._selectionEqual(cursorSelection, this._lastCursor)) {
this._lastCursor = cursorSelection;
return;
}
}
// We cannot use `host.getUpdateComplete()` here
// because it would cause excessive DOM queries, leading to UI jamming.
await nextTick();
if (textSelection) {
const block = this.host.view.getBlock(textSelection.blockId);
if (
!textSelection.isCollapsed() &&
block &&
block.model.role === 'content'
) {
this._displayType = 'text';
if (!rootComponent.std.range) return;
this.host.std.command
.chain()
.pipe(getTextSelectionCommand)
.pipe(getSelectedBlocksCommand, {
types: ['text'],
})
.pipe(ctx => {
const { selectedBlocks } = ctx;
if (!selectedBlocks) return;
this._selectedBlocks = selectedBlocks;
})
.run();
return;
}
this.reset();
return;
}
if (this.block && blockSelections.length > 0) {
this._displayType = 'block';
const selectedBlocks = blockSelections
.map(selection => {
const path = selection.blockId;
return this.block.host.view.getBlock(path);
})
.filter((el): el is BlockComponent => !!el);
this._selectedBlocks = selectedBlocks;
return;
}
this.reset();
};
update().catch(console.error);
})
);
this.disposables.addFromEvent(document, 'selectionchange', () => {
if (!this.host.event.active) return;
const reset = () => {
this.reset();
this.requestUpdate();
};
const range = this.nativeRange;
if (!range) return;
const container =
range.commonAncestorContainer instanceof Element
? range.commonAncestorContainer
: range.commonAncestorContainer.parentElement;
if (!container) return;
const notBlockText = container.closest('rich-text')?.dataset.notBlockText;
if (notBlockText == null) return;
if (range.collapsed) return reset();
this._displayType = 'native';
this.requestUpdate();
});
}
private _listenFloatingElement() {
const formatQuickBarElement = this.formatBarElement;
assertExists(formatQuickBarElement, 'format quick bar should exist');
const listenFloatingElement = (
getElement: () => ReferenceElement | void
) => {
const initialElement = getElement();
if (!initialElement) {
return;
}
assertExists(this._floatDisposables);
HoverController.globalAbortController?.abort();
this._floatDisposables.add(
autoUpdate(
initialElement,
formatQuickBarElement,
() => {
const element = getElement();
if (!element) return;
computePosition(element, formatQuickBarElement, {
placement: this._placement,
middleware: [
offset(10),
inline(),
shift({
padding: 6,
}),
],
})
.then(({ x, y }) => {
formatQuickBarElement.style.display = 'flex';
formatQuickBarElement.style.top = `${y}px`;
formatQuickBarElement.style.left = `${x}px`;
})
.catch(console.error);
},
{
// follow edgeless viewport update
animationFrame: true,
}
)
);
};
const getReferenceElementFromBlock = () => {
const firstBlock = this._selectedBlocks[0];
let rect = firstBlock?.getBoundingClientRect();
if (!rect) return;
this._selectedBlocks.forEach(el => {
const elRect = el.getBoundingClientRect();
if (elRect.top < rect.top) {
rect = new DOMRect(rect.left, elRect.top, rect.width, rect.bottom);
}
if (elRect.bottom > rect.bottom) {
rect = new DOMRect(rect.left, rect.top, rect.width, elRect.bottom);
}
if (elRect.left < rect.left) {
rect = new DOMRect(elRect.left, rect.top, rect.right, rect.bottom);
}
if (elRect.right > rect.right) {
rect = new DOMRect(rect.left, rect.top, elRect.right, rect.bottom);
}
});
return {
getBoundingClientRect: () => rect,
getClientRects: () =>
this._selectedBlocks.map(el => el.getBoundingClientRect()),
};
};
const getReferenceElementFromText = () => {
const range = this.nativeRange;
if (!range) {
return;
}
return {
getBoundingClientRect: () => range.getBoundingClientRect(),
getClientRects: () => range.getClientRects(),
};
};
switch (this.displayType) {
case 'text':
case 'native':
return listenFloatingElement(getReferenceElementFromText);
case 'block':
return listenFloatingElement(getReferenceElementFromBlock);
default:
return;
}
}
private _selectionEqual(
target: BaseSelection | undefined,
current: BaseSelection | undefined
) {
if (target === current || (target && current && target.equals(current))) {
return true;
}
return false;
}
private _shouldDisplay() {
const readonly = this.doc.readonly;
const active = this.host.event.active;
if (readonly || !active) return false;
if (
this.displayType === 'block' &&
this._selectedBlocks?.[0]?.flavour === 'affine:surface-ref'
) {
return false;
}
if (this.displayType === 'block' && this._selectedBlocks.length === 1) {
const selectedBlock = this._selectedBlocks[0];
if (
!matchModels(selectedBlock.model, [
ParagraphBlockModel,
ListBlockModel,
CodeBlockModel,
ImageBlockModel,
])
) {
return false;
}
}
if (this.displayType === 'none' || this._dragging) {
return false;
}
// if the selection is on an embed (ex. linked page), we should not display the format bar
if (this.displayType === 'text' && this._selectedBlocks.length === 1) {
const isEmbed = () => {
const [element] = this._selectedBlocks;
const richText = element.querySelector<RichText>('rich-text');
const inline = richText?.inlineEditor;
if (!richText || !inline) {
return false;
}
const range = inline.getInlineRange();
if (!range || range.length > 1) {
return false;
}
const deltas = inline.getDeltasByInlineRange(range);
if (deltas.length > 2) {
return false;
}
const delta = deltas?.[1]?.[0];
if (!delta) {
return false;
}
return inline.isEmbed(delta);
};
if (isEmbed()) {
return false;
}
}
// todo: refactor later that ai panel & format bar should not depend on each other
// do not display if AI panel is open
const rootBlockId = this.host.doc.root?.id;
const aiPanel = rootBlockId
? this.host.view.getWidget('affine-ai-panel-widget', rootBlockId)
: null;
// @ts-expect-error FIXME: ts error
if (aiPanel && aiPanel?.state !== 'hidden') {
return false;
}
return true;
}
addBlockTypeSwitch(config: {
flavour: string;
icon: ParagraphActionConfigItem['icon'];
type?: string;
name?: string;
}) {
const { flavour, type, icon } = config;
return this.addParagraphAction({
id: `${flavour}/${type ?? ''}`,
icon,
flavour,
name: config.name ?? camelCaseToWords(type ?? flavour),
action: chain => {
chain
.pipe(updateBlockType, {
flavour,
props: type != null ? { type } : undefined,
})
.run();
},
});
}
addDivider() {
this.configItems.push({ type: 'divider' });
return this;
}
addHighlighterDropdown() {
this.configItems.push({ type: 'highlighter-dropdown' });
return this;
}
addInlineAction(config: Omit<InlineActionConfigItem, 'type'>) {
this.configItems.push({ ...config, type: 'inline-action' });
return this;
}
addParagraphAction(config: Omit<ParagraphActionConfigItem, 'type'>) {
this.configItems.push({ ...config, type: 'paragraph-action' });
return this;
}
addParagraphDropdown() {
this.configItems.push({ type: 'paragraph-dropdown' });
return this;
}
addRawConfigItems(configItems: FormatBarConfigItem[], index?: number) {
if (index === undefined) {
this.configItems.push(...configItems);
} else {
this.configItems.splice(index, 0, ...configItems);
}
return this;
}
addTextStyleToggle(config: {
icon: InlineActionConfigItem['icon'];
key: Exclude<
keyof AffineTextAttributes,
'color' | 'background' | 'reference'
>;
action: InlineActionConfigItem['action'];
}) {
const { key } = config;
return this.addInlineAction({
id: key,
name: camelCaseToWords(key),
icon: config.icon,
isActive: chain => {
const [result] = chain.pipe(isTextStyleActive, { key }).run();
return result;
},
action: config.action,
showWhen: chain => {
const [result] = isFormatSupported(chain).run();
return result;
},
});
}
clearConfig() {
this.configItems = [];
return this;
}
override connectedCallback() {
super.connectedCallback();
this._abortController = new AbortController();
const rootComponent = this.block;
assertExists(rootComponent);
const widgets = rootComponent.widgets;
// check if the host use the format bar widget
if (!Object.hasOwn(widgets, AFFINE_FORMAT_BAR_WIDGET)) {
return;
}
// check if format bar widget support the host
if (rootComponent.model.flavour !== 'affine:page') {
console.error(
`format bar not support rootComponent: ${rootComponent.constructor.name} but its widgets has format bar`
);
return;
}
this._calculatePlacement();
if (this.configItems.length === 0) {
toolbarDefaultConfig(this);
}
this.moreGroups = getMoreMenuConfig(this.std).configure(this.moreGroups);
}
override disconnectedCallback() {
super.disconnectedCallback();
this._abortController.abort();
this.reset();
this._lastCursor = undefined;
}
override render() {
if (!this._shouldDisplay()) {
return nothing;
}
const items = ConfigRenderer(this);
const moreButton = toolbarMoreButton(this);
return html`
<editor-toolbar class="${AFFINE_FORMAT_BAR_WIDGET}">
${items}
${moreButton
? html`
<editor-toolbar-separator></editor-toolbar-separator>
${moreButton}
`
: nothing}
</editor-toolbar>
`;
}
reset() {
this._displayType = 'none';
this._selectedBlocks = [];
}
override updated() {
if (this._floatDisposables) {
this._floatDisposables.dispose();
this._floatDisposables = null;
}
if (!this._shouldDisplay()) {
return;
}
this._floatDisposables = new DisposableGroup();
this._listenFloatingElement();
}
@state()
private accessor _displayType: 'text' | 'block' | 'native' | 'none' = 'none';
@state()
private accessor _dragging = false;
@state()
private accessor _selectedBlocks: BlockComponent[] = [];
@state()
accessor configItems: FormatBarConfigItem[] = [];
@query(`.${AFFINE_FORMAT_BAR_WIDGET}`)
accessor formatBarElement: HTMLElement | null = null;
}
function camelCaseToWords(s: string) {
const result = s.replace(/([A-Z])/g, ' $1');
return result.charAt(0).toUpperCase() + result.slice(1);
}
declare global {
interface HTMLElementTagNameMap {
[AFFINE_FORMAT_BAR_WIDGET]: AffineFormatBarWidget;
}
}
@@ -0,0 +1,2 @@
export * from './config.js';
export { AffineFormatBarWidget } from './format-bar.js';
@@ -0,0 +1,55 @@
import { scrollbarStyle } from '@blocksuite/affine-shared/styles';
import { css } from 'lit';
const paragraphButtonStyle = css`
.paragraph-button-icon > svg:nth-child(2) {
transition-duration: 0.3s;
}
.paragraph-button-icon:is(:hover, :focus-visible, :active)
> svg:nth-child(2) {
transform: rotate(180deg);
}
.highlight-icon > svg:nth-child(2) {
transition-duration: 0.3s;
}
.highlight-icon:is(:hover, :focus-visible, :active) > svg:nth-child(2) {
transform: rotate(180deg);
}
.highlight-panel {
max-height: 380px;
}
.highligh-panel-heading {
display: flex;
color: var(--affine-text-secondary-color);
padding: 4px;
}
editor-menu-content {
display: none;
position: absolute;
padding: 0;
z-index: var(--affine-z-index-popover);
--packed-height: 6px;
}
editor-menu-content > div[data-orientation='vertical'] {
padding: 8px;
overflow-y: auto;
}
${scrollbarStyle('editor-menu-content > div[data-orientation="vertical"]')}
`;
export const formatBarStyle = css`
.affine-format-bar-widget {
position: absolute;
display: none;
z-index: var(--affine-z-index-popover);
user-select: none;
}
${paragraphButtonStyle}
`;
@@ -0,0 +1,142 @@
import { createLitPortal } from '@blocksuite/affine-components/portal';
import type {
EditorIconButton,
MenuItemGroup,
} from '@blocksuite/affine-components/toolbar';
import { renderGroups } from '@blocksuite/affine-components/toolbar';
import { assertExists, noop } from '@blocksuite/global/utils';
import { MoreVerticalIcon } from '@blocksuite/icons/lit';
import { flip, offset } from '@floating-ui/dom';
import { html, LitElement } from 'lit';
import { property, query, state } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import type { ImageToolbarContext } from '../context.js';
import { styles } from '../styles.js';
export class AffineImageToolbar extends LitElement {
static override styles = styles;
private _currentOpenMenu: AbortController | null = null;
private _popMenuAbortController: AbortController | null = null;
closeCurrentMenu = () => {
if (this._currentOpenMenu && !this._currentOpenMenu.signal.aborted) {
this._currentOpenMenu.abort();
this._currentOpenMenu = null;
}
};
private _clearPopMenu() {
if (this._popMenuAbortController) {
this._popMenuAbortController.abort();
this._popMenuAbortController = null;
}
}
private _toggleMoreMenu() {
// If the menu we're trying to open is already open, return
if (
this._currentOpenMenu &&
!this._currentOpenMenu.signal.aborted &&
this._currentOpenMenu === this._popMenuAbortController
) {
this.closeCurrentMenu();
this._moreMenuOpen = false;
return;
}
this.closeCurrentMenu();
this._popMenuAbortController = new AbortController();
this._popMenuAbortController.signal.addEventListener('abort', () => {
this._moreMenuOpen = false;
this.onActiveStatusChange(false);
});
this.onActiveStatusChange(true);
this._currentOpenMenu = this._popMenuAbortController;
assertExists(this._moreButton);
createLitPortal({
template: html`
<editor-menu-content
data-show
class="image-more-popup-menu"
style=${styleMap({
'--content-padding': '8px',
'--packed-height': '4px',
})}
>
<div data-size="large" data-orientation="vertical">
${renderGroups(this.moreGroups, this.context)}
</div>
</editor-menu-content>
`,
container: this.context.host,
// stacking-context(editor-host)
portalStyles: {
zIndex: 'var(--affine-z-index-popover)',
},
computePosition: {
referenceElement: this._moreButton,
placement: 'bottom-start',
middleware: [flip(), offset(4)],
autoUpdate: { animationFrame: true },
},
abortController: this._popMenuAbortController,
closeOnClickAway: true,
});
this._moreMenuOpen = true;
}
override disconnectedCallback() {
super.disconnectedCallback();
this.closeCurrentMenu();
this._clearPopMenu();
}
override render() {
return html`
<editor-toolbar class="affine-image-toolbar-container" data-without-bg>
${renderGroups(this.primaryGroups, this.context)}
<editor-icon-button
class="image-toolbar-button more"
aria-label="More"
.tooltip=${'More'}
.tooltipOffset=${4}
.showTooltip=${!this._moreMenuOpen}
.iconSize=${'20px'}
@click=${() => this._toggleMoreMenu()}
>
${MoreVerticalIcon()}
</editor-icon-button>
</editor-toolbar>
`;
}
@query('editor-icon-button.more')
private accessor _moreButton!: EditorIconButton;
@state()
private accessor _moreMenuOpen = false;
@property({ attribute: false })
accessor context!: ImageToolbarContext;
@property({ attribute: false })
accessor moreGroups!: MenuItemGroup<ImageToolbarContext>[];
@property({ attribute: false })
accessor onActiveStatusChange: (active: boolean) => void = noop;
@property({ attribute: false })
accessor primaryGroups!: MenuItemGroup<ImageToolbarContext>[];
}
declare global {
interface HTMLElementTagNameMap {
'affine-image-toolbar': AffineImageToolbar;
}
}
@@ -0,0 +1,144 @@
import {
CaptionIcon,
CopyIcon,
DeleteIcon,
DownloadIcon,
} from '@blocksuite/affine-components/icons';
import type { MenuItemGroup } from '@blocksuite/affine-components/toolbar';
import { BookmarkIcon, DuplicateIcon } from '@blocksuite/icons/lit';
import { html } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.js';
import type { ImageToolbarContext } from './context.js';
import { duplicate } from './utils.js';
export const PRIMARY_GROUPS: MenuItemGroup<ImageToolbarContext>[] = [
{
type: 'primary',
items: [
{
type: 'download',
label: 'Download',
icon: DownloadIcon,
generate: ({ blockComponent }) => {
return {
action: () => {
blockComponent.download();
},
render: item => html`
<editor-icon-button
class="image-toolbar-button download"
aria-label=${ifDefined(item.label)}
.tooltip=${item.label}
.tooltipOffset=${4}
@click=${(e: MouseEvent) => {
e.stopPropagation();
item.action();
}}
>
${item.icon}
</editor-icon-button>
`,
};
},
},
{
type: 'caption',
label: 'Caption',
icon: CaptionIcon,
when: ({ doc }) => !doc.readonly,
generate: ({ blockComponent }) => {
return {
action: () => {
blockComponent.captionEditor?.show();
},
render: item => html`
<editor-icon-button
class="image-toolbar-button caption"
aria-label=${ifDefined(item.label)}
.tooltip=${item.label}
.tooltipOffset=${4}
@click=${(e: MouseEvent) => {
e.stopPropagation();
item.action();
}}
>
${item.icon}
</editor-icon-button>
`,
};
},
},
],
},
];
// Clipboard Group
export const clipboardGroup: MenuItemGroup<ImageToolbarContext> = {
type: 'clipboard',
items: [
{
type: 'copy',
label: 'Copy',
icon: CopyIcon,
action: ({ blockComponent, close }) => {
blockComponent.copy();
close();
},
},
{
type: 'duplicate',
label: 'Duplicate',
icon: DuplicateIcon(),
when: ({ doc }) => !doc.readonly,
action: ({ blockComponent, abortController }) => {
duplicate(blockComponent, abortController);
},
},
],
};
// Conversions Group
export const conversionsGroup: MenuItemGroup<ImageToolbarContext> = {
type: 'conversions',
items: [
{
label: 'Turn into card view',
type: 'turn-into-card-view',
icon: BookmarkIcon(),
when: ({ doc, blockComponent }) => {
const supportAttachment =
doc.schema.flavourSchemaMap.has('affine:attachment');
const readonly = doc.readonly;
return supportAttachment && !readonly && !!blockComponent.blob;
},
action: ({ blockComponent, close }) => {
blockComponent.convertToCardView();
close();
},
},
],
};
// Delete Group
export const deleteGroup: MenuItemGroup<ImageToolbarContext> = {
type: 'delete',
items: [
{
type: 'delete',
label: 'Delete',
icon: DeleteIcon,
when: ({ doc }) => !doc.readonly,
action: ({ doc, blockComponent, close }) => {
doc.deleteBlock(blockComponent.model);
close();
},
},
],
};
export const MORE_GROUPS: MenuItemGroup<ImageToolbarContext>[] = [
clipboardGroup,
conversionsGroup,
deleteGroup,
];
@@ -0,0 +1,43 @@
import type { ImageBlockComponent } from '@blocksuite/affine-block-image';
import { MenuContext } from '@blocksuite/affine-components/toolbar';
export class ImageToolbarContext extends MenuContext {
override close = () => {
this.abortController.abort();
};
get doc() {
return this.blockComponent.doc;
}
get host() {
return this.blockComponent.host;
}
get selectedBlockModels() {
return [this.blockComponent.model];
}
get std() {
return this.blockComponent.std;
}
constructor(
public blockComponent: ImageBlockComponent,
public abortController: AbortController
) {
super();
}
isEmpty() {
return false;
}
isMultiple() {
return false;
}
isSingle() {
return true;
}
}
@@ -0,0 +1,173 @@
import type { ImageBlockComponent } from '@blocksuite/affine-block-image';
import { HoverController } from '@blocksuite/affine-components/hover';
import type {
AdvancedMenuItem,
MenuItemGroup,
} from '@blocksuite/affine-components/toolbar';
import {
cloneGroups,
getMoreMenuConfig,
} from '@blocksuite/affine-components/toolbar';
import type { ImageBlockModel } from '@blocksuite/affine-model';
import { PAGE_HEADER_HEIGHT } from '@blocksuite/affine-shared/consts';
import {
BlockSelection,
TextSelection,
WidgetComponent,
} from '@blocksuite/block-std';
import { limitShift, shift } from '@floating-ui/dom';
import { html } from 'lit';
import { MORE_GROUPS, PRIMARY_GROUPS } from './config.js';
import { ImageToolbarContext } from './context.js';
export const AFFINE_IMAGE_TOOLBAR_WIDGET = 'affine-image-toolbar-widget';
export class AffineImageToolbarWidget extends WidgetComponent<
ImageBlockModel,
ImageBlockComponent
> {
private _hoverController: HoverController | null = null;
private _isActivated = false;
private readonly _setHoverController = () => {
this._hoverController = null;
this._hoverController = new HoverController(
this,
({ abortController }) => {
const imageBlock = this.block;
const selection = this.host.selection;
const textSelection = selection.find(TextSelection);
if (
!!textSelection &&
(!!textSelection.to || !!textSelection.from.length)
) {
return null;
}
const blockSelections = selection.filter(BlockSelection);
if (
blockSelections.length > 1 ||
(blockSelections.length === 1 &&
blockSelections[0].blockId !== imageBlock.blockId)
) {
return null;
}
const imageContainer =
imageBlock.resizableImg ?? imageBlock.fallbackCard;
if (!imageContainer) {
return null;
}
const context = new ImageToolbarContext(imageBlock, abortController);
return {
template: html`<affine-image-toolbar
.context=${context}
.primaryGroups=${this.primaryGroups}
.moreGroups=${this.moreGroups}
.onActiveStatusChange=${(active: boolean) => {
this._isActivated = active;
if (!active && !this._hoverController?.isHovering) {
this._hoverController?.abort();
}
}}
></affine-image-toolbar>`,
container: this.block,
// stacking-context(editor-host)
portalStyles: {
zIndex: 'var(--affine-z-index-popover)',
},
computePosition: {
referenceElement: imageContainer,
placement: 'right-start',
middleware: [
shift({
crossAxis: true,
padding: {
top: PAGE_HEADER_HEIGHT + 12,
bottom: 12,
right: 12,
},
limiter: limitShift(),
}),
],
autoUpdate: true,
},
};
},
{ allowMultiple: true }
);
const imageBlock = this.block;
this._hoverController.setReference(imageBlock);
this._hoverController.onAbort = () => {
// If the more menu is opened, don't close it.
if (this._isActivated) return;
this._hoverController?.abort();
return;
};
};
addMoreItems = (
items: AdvancedMenuItem<ImageToolbarContext>[],
index?: number,
type?: string
) => {
let group;
if (type) {
group = this.moreGroups.find(g => g.type === type);
}
if (!group) {
group = this.moreGroups[0];
}
if (index === undefined) {
group.items.push(...items);
return this;
}
group.items.splice(index, 0, ...items);
return this;
};
addPrimaryItems = (
items: AdvancedMenuItem<ImageToolbarContext>[],
index?: number
) => {
if (index === undefined) {
this.primaryGroups[0].items.push(...items);
return this;
}
this.primaryGroups[0].items.splice(index, 0, ...items);
return this;
};
/*
* Caches the more menu items.
* Currently only supports configuring more menu.
*/
moreGroups: MenuItemGroup<ImageToolbarContext>[] = cloneGroups(MORE_GROUPS);
primaryGroups: MenuItemGroup<ImageToolbarContext>[] =
cloneGroups(PRIMARY_GROUPS);
override firstUpdated() {
if (this.doc.getParent(this.model.id)?.flavour === 'affine:surface') {
return;
}
this.moreGroups = getMoreMenuConfig(this.std).configure(this.moreGroups);
this._setHoverController();
}
}
declare global {
interface HTMLElementTagNameMap {
[AFFINE_IMAGE_TOOLBAR_WIDGET]: AffineImageToolbarWidget;
}
}
@@ -0,0 +1,24 @@
import { css } from 'lit';
export const styles = css`
:host {
position: absolute;
top: 0;
right: 0;
z-index: var(--affine-z-index-popover);
}
.affine-image-toolbar-container {
height: 24px;
gap: 4px;
padding: 4px;
margin: 0;
}
.image-toolbar-button {
color: var(--affine-icon-color);
background-color: var(--affine-background-primary-color);
box-shadow: var(--affine-shadow-1);
border-radius: 4px;
}
`;
@@ -0,0 +1,54 @@
import type { ImageBlockComponent } from '@blocksuite/affine-block-image';
import {
getBlockProps,
isInsidePageEditor,
} from '@blocksuite/affine-shared/utils';
import { BlockSelection } from '@blocksuite/block-std';
import { assertExists } from '@blocksuite/global/utils';
export function duplicate(
block: ImageBlockComponent,
abortController?: AbortController
) {
const model = block.model;
const blockProps = getBlockProps(model);
const {
width: _width,
height: _height,
xywh: _xywh,
rotate: _rotate,
zIndex: _zIndex,
...duplicateProps
} = blockProps;
const { doc } = model;
const parent = doc.getParent(model);
assertExists(parent, 'Parent not found');
const index = parent?.children.indexOf(model);
const duplicateId = doc.addBlock(
model.flavour,
duplicateProps,
parent,
index + 1
);
abortController?.abort();
const editorHost = block.host;
editorHost.updateComplete
.then(() => {
const { selection } = editorHost;
selection.setGroup('note', [
selection.create(BlockSelection, {
blockId: duplicateId,
}),
]);
if (isInsidePageEditor(editorHost)) {
const duplicateElement = editorHost.view.getBlock(duplicateId);
if (duplicateElement) {
duplicateElement.scrollIntoView(true);
}
}
})
.catch(console.error);
}
@@ -0,0 +1,56 @@
export { EDGELESS_TOOLBAR_WIDGET } from '../edgeless/components/toolbar/edgeless-toolbar.js';
export {
AFFINE_AI_PANEL_WIDGET,
AffineAIPanelWidget,
} from './ai-panel/ai-panel.js';
export {
type AffineAIPanelState,
type AffineAIPanelWidgetConfig,
} from './ai-panel/type.js';
export {
AFFINE_EDGELESS_COPILOT_WIDGET,
EdgelessCopilotWidget,
} from './edgeless-copilot/index.js';
export { EdgelessCopilotToolbarEntry } from './edgeless-copilot-panel/toolbar-entry.js';
export { AffineEdgelessZoomToolbarWidget } from './edgeless-zoom-toolbar/index.js';
export {
EDGELESS_ELEMENT_TOOLBAR_WIDGET,
EdgelessElementToolbarWidget,
} from './element-toolbar/index.js';
export {
AFFINE_EMBED_CARD_TOOLBAR_WIDGET,
EmbedCardToolbar,
} from './embed-card-toolbar/embed-card-toolbar.js';
export { toolbarDefaultConfig } from './format-bar/config.js';
export {
AFFINE_FORMAT_BAR_WIDGET,
AffineFormatBarWidget,
} from './format-bar/format-bar.js';
export { AffineImageToolbarWidget } from './image-toolbar/index.js';
export { AffineInnerModalWidget } from './inner-modal/inner-modal.js';
export * from './keyboard-toolbar/index.js';
export {
type LinkedMenuAction,
type LinkedMenuGroup,
type LinkedMenuItem,
type LinkedWidgetConfig,
LinkedWidgetUtils,
} from './linked-doc/config.js';
export {
// It's used in the AFFiNE!
showImportModal,
} from './linked-doc/import-doc/index.js';
export { AffineLinkedDocWidget } from './linked-doc/index.js';
export { AffineModalWidget } from './modal/modal.js';
export { AffinePageDraggingAreaWidget } from './page-dragging-area/page-dragging-area.js';
export {
type AffineSlashMenuActionItem,
type AffineSlashMenuContext,
type AffineSlashMenuGroupDivider,
type AffineSlashMenuItem,
type AffineSlashMenuItemGenerator,
AffineSlashMenuWidget,
type AffineSlashSubMenu,
} from './slash-menu/index.js';
export { AffineSurfaceRefToolbar } from './surface-ref-toolbar/surface-ref-toolbar.js';
export { AffineFrameTitleWidget } from '@blocksuite/affine-widget-frame-title';
@@ -0,0 +1,64 @@
import { WidgetComponent } from '@blocksuite/block-std';
import {
autoUpdate,
computePosition,
type FloatingElement,
type ReferenceElement,
size,
} from '@floating-ui/dom';
import { nothing } from 'lit';
export const AFFINE_INNER_MODAL_WIDGET = 'affine-inner-modal-widget';
export class AffineInnerModalWidget extends WidgetComponent {
private _getTarget?: () => ReferenceElement;
get target(): ReferenceElement {
if (this._getTarget) {
return this._getTarget();
}
return document.body;
}
open(
modal: FloatingElement,
ops: { onClose?: () => void }
): { close(): void } {
const cancel = autoUpdate(this.target, modal, () => {
computePosition(this.target, modal, {
middleware: [
size({
apply: ({ rects }) => {
Object.assign(modal.style, {
left: `${rects.reference.x}px`,
top: `${rects.reference.y}px`,
width: `${rects.reference.width}px`,
height: `${rects.reference.height}px`,
});
},
}),
],
}).catch(console.error);
});
const close = () => {
modal.remove();
ops.onClose?.();
cancel();
};
return { close };
}
override render() {
return nothing;
}
setTarget(fn: () => ReferenceElement) {
this._getTarget = fn;
}
}
declare global {
interface HTMLElementTagNameMap {
[AFFINE_INNER_MODAL_WIDGET]: AffineInnerModalWidget;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,28 @@
import {
AFFINE_KEYBOARD_TOOLBAR_WIDGET,
AffineKeyboardToolbarWidget,
} from './index.js';
import {
AFFINE_KEYBOARD_TOOL_PANEL,
AffineKeyboardToolPanel,
} from './keyboard-tool-panel.js';
import {
AFFINE_KEYBOARD_TOOLBAR,
AffineKeyboardToolbar,
} from './keyboard-toolbar.js';
export function effects() {
customElements.define(
AFFINE_KEYBOARD_TOOLBAR_WIDGET,
AffineKeyboardToolbarWidget
);
customElements.define(AFFINE_KEYBOARD_TOOLBAR, AffineKeyboardToolbar);
customElements.define(AFFINE_KEYBOARD_TOOL_PANEL, AffineKeyboardToolPanel);
}
declare global {
interface HTMLElementTagNameMap {
[AFFINE_KEYBOARD_TOOLBAR]: AffineKeyboardToolbar;
[AFFINE_KEYBOARD_TOOL_PANEL]: AffineKeyboardToolPanel;
}
}
@@ -0,0 +1,138 @@
import {
Heading1Icon,
Heading2Icon,
Heading3Icon,
Heading4Icon,
Heading5Icon,
Heading6Icon,
} from '@blocksuite/icons/lit';
import { cssVarV2 } from '@toeverything/theme/v2';
import { html } from 'lit';
export function HeadingIcon(i: 1 | 2 | 3 | 4 | 5 | 6) {
switch (i) {
case 1:
return Heading1Icon();
case 2:
return Heading2Icon();
case 3:
return Heading3Icon();
case 4:
return Heading4Icon();
case 5:
return Heading5Icon();
case 6:
return Heading6Icon();
default:
return Heading1Icon();
}
}
export const HighLightDuotoneIcon = (color: string) =>
html`<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
>
<path
d="M5.8291 16.441L7.91757 18.5295L6.57811 19.8689C6.53119 19.9158 6.46406 19.9364 6.3989 19.9239L3.37036 19.3412C3.21285 19.3109 3.15331 19.1168 3.26673 19.0034L5.8291 16.441Z"
fill="${color}"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M19.0095 3.63759C17.9526 2.58067 16.26 2.516 15.1255 3.48919L7.32135 10.1837C6.35438 11.0132 6.05275 12.3823 6.58163 13.5414L6.73501 13.8775L5.67697 14.9356C5.30169 15.3108 5.30169 15.9193 5.67697 16.2946L8.06379 18.6814C8.43907 19.0567 9.04752 19.0567 9.4228 18.6814L10.4808 17.6234L10.8171 17.7768C11.9761 18.3057 13.3452 18.0041 14.1747 17.0371L20.8692 9.23294C21.8424 8.09846 21.7778 6.40588 20.7208 5.34896L19.0095 3.63759ZM16.1021 4.62769C16.6415 4.16498 17.4463 4.19572 17.9488 4.69825L19.6602 6.40962C20.1627 6.91215 20.1935 7.7169 19.7307 8.25631L14.6424 14.188L10.1704 9.71604L16.1021 4.62769ZM9.02857 10.6955L8.29798 11.3222C7.83822 11.7166 7.6948 12.3676 7.94627 12.9187L8.29785 13.6892C8.4348 13.9893 8.37947 14.3544 8.13372 14.6001L7.11878 15.6151L8.74329 17.2396L9.75812 16.2247C10.004 15.9789 10.3691 15.9236 10.6693 16.0606L11.4398 16.4122C11.9908 16.6636 12.6418 16.5202 13.0362 16.0605L13.6629 15.3299L9.02857 10.6955Z"
fill="${cssVarV2('icon/primary')}"
/>
</svg>`;
export const TextColorIcon = (color: string) =>
html`<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 32 32"
fill="none"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M14.0627 6.16255C14.385 5.30291 15.2068 4.7334 16.1249 4.7334C17.043 4.7334 17.8648 5.30291 18.1872 6.16255L23.7279 20.9378C23.9219 21.455 23.6599 22.0314 23.1427 22.2253C22.6256 22.4192 22.0492 22.1572 21.8553 21.6401L20.2289 17.3031H12.021L10.3946 21.6401C10.2007 22.1572 9.62428 22.4192 9.10716 22.2253C8.59004 22.0314 8.32803 21.455 8.52195 20.9378L14.0627 6.16255ZM12.771 15.3031H19.4789L16.3146 6.8648C16.2849 6.78576 16.2094 6.7334 16.1249 6.7334C16.0405 6.7334 15.965 6.78576 15.9353 6.8648L12.771 15.3031Z"
fill="${cssVarV2('icon/primary')}"
/>
<rect
x="5.45837"
y="24"
width="21.3333"
height="3.33333"
rx="1"
fill=${color}
/>
</svg>`;
export const TextBackgroundDuotoneIcon = (color: string) =>
html`<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 32 32"
fill="none"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M4.57507 7.33336C4.57507 5.60287 5.97791 4.20003 7.70841 4.20003H25.0417C26.7722 4.20003 28.1751 5.60287 28.1751 7.33336V24.6667C28.1751 26.3972 26.7722 27.8 25.0417 27.8H7.70841C5.97791 27.8 4.57507 26.3972 4.57507 24.6667V7.33336Z"
fill="${color}"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M4.57495 7.33333C4.57495 5.60284 5.97779 4.2 7.70828 4.2H25.0416C26.7721 4.2 28.175 5.60284 28.175 7.33333V24.6667C28.175 26.3972 26.7721 27.8 25.0416 27.8H7.70828C5.97779 27.8 4.57495 26.3972 4.57495 24.6667V7.33333ZM7.70828 5.13333C6.49326 5.13333 5.50828 6.1183 5.50828 7.33333V24.6667C5.50828 25.8817 6.49326 26.8667 7.70828 26.8667H25.0416C26.2566 26.8667 27.2416 25.8817 27.2416 24.6667V7.33333C27.2416 6.1183 26.2566 5.13333 25.0416 5.13333H7.70828Z"
fill="black"
fill-opacity="0.22"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M14.5379 10.0064C14.8251 9.24064 15.5571 8.73332 16.375 8.73332C17.1928 8.73332 17.9249 9.24064 18.2121 10.0064L22.6446 21.8266C22.8386 22.3438 22.5766 22.9202 22.0594 23.1141C21.5423 23.308 20.9659 23.046 20.772 22.5289L19.5196 19.1891H13.2304L11.978 22.5289C11.7841 23.046 11.2076 23.308 10.6905 23.1141C10.1734 22.9202 9.9114 22.3438 10.1053 21.8266L14.5379 10.0064ZM13.9804 17.1891H18.7696L16.375 10.8035L13.9804 17.1891Z"
fill="${cssVarV2('text/primary')}"
/>
</svg>`;
export const FigmaDuotoneIcon = html`<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g id="Figma_Duotone">
<path
id="Vector"
d="M8.41842 22.5027C10.3047 22.5027 11.8356 20.9719 11.8356 19.0856V15.6685H8.41842C6.53216 15.6685 5.00128 17.1993 5.00128 19.0856C5.00128 20.9719 6.53216 22.5027 8.41842 22.5027Z"
fill="#0ACF83"
/>
<path
id="Vector_2"
d="M5.00128 12.2514C5.00128 10.3651 6.53216 8.83423 8.41842 8.83423H11.8356V15.6685H8.41842C6.53216 15.6685 5.00128 14.1376 5.00128 12.2514Z"
fill="#A259FF"
/>
<path
id="Vector_3"
d="M5.00146 5.41714C5.00146 3.53088 6.53234 2 8.4186 2H11.8357V8.83428H8.4186C6.53234 8.83428 5.00146 7.3034 5.00146 5.41714Z"
fill="#F24E1E"
/>
<path
id="Vector_4"
d="M11.8356 2H15.2527C17.139 2 18.6699 3.53088 18.6699 5.41714C18.6699 7.3034 17.139 8.83428 15.2527 8.83428H11.8356V2Z"
fill="#FF7262"
/>
<path
id="Vector_5"
d="M18.6699 12.2514C18.6699 14.1376 17.139 15.6685 15.2527 15.6685C13.3665 15.6685 11.8356 14.1376 11.8356 12.2514C11.8356 10.3651 13.3665 8.83423 15.2527 8.83423C17.139 8.83423 18.6699 10.3651 18.6699 12.2514Z"
fill="#1ABCFE"
/>
</g>
</svg> `;
@@ -0,0 +1,102 @@
import type { RootBlockModel } from '@blocksuite/affine-model';
import { FeatureFlagService } from '@blocksuite/affine-shared/services';
import { WidgetComponent } from '@blocksuite/block-std';
import { IS_MOBILE } from '@blocksuite/global/env';
import { assertType } from '@blocksuite/global/utils';
import { signal } from '@preact/signals-core';
import { html, nothing } from 'lit';
import type { PageRootBlockComponent } from '../../page/page-root-block.js';
import { RootBlockConfigExtension } from '../../root-config.js';
import { defaultKeyboardToolbarConfig } from './config.js';
export * from './config.js';
export const AFFINE_KEYBOARD_TOOLBAR_WIDGET = 'affine-keyboard-toolbar-widget';
export class AffineKeyboardToolbarWidget extends WidgetComponent<
RootBlockModel,
PageRootBlockComponent
> {
private readonly _close = (blur: boolean) => {
if (blur) {
if (document.activeElement === this._docTitle) {
this._docTitle?.blur();
} else if (document.activeElement === this.block.rootComponent) {
this.block.rootComponent?.blur();
}
}
this._show$.value = false;
};
private readonly _show$ = signal(false);
private get _docTitle(): HTMLDivElement | null {
const docTitle = this.std.host
.closest('.affine-page-viewport')
?.querySelector('doc-title rich-text .inline-editor');
assertType<HTMLDivElement | null>(docTitle);
return docTitle;
}
get config() {
return {
...defaultKeyboardToolbarConfig,
...this.std.getOptional(RootBlockConfigExtension.identifier)
?.keyboardToolbar,
};
}
override connectedCallback(): void {
super.connectedCallback();
const { rootComponent } = this.block;
if (rootComponent) {
this.disposables.addFromEvent(rootComponent, 'focus', () => {
this._show$.value = true;
});
this.disposables.addFromEvent(rootComponent, 'blur', () => {
this._show$.value = false;
});
}
if (this._docTitle) {
this.disposables.addFromEvent(this._docTitle, 'focus', () => {
this._show$.value = true;
});
this.disposables.addFromEvent(this._docTitle, 'blur', () => {
this._show$.value = false;
});
}
}
override render() {
if (
this.doc.readonly ||
!IS_MOBILE ||
!this.doc
.get(FeatureFlagService)
.getFlag('enable_mobile_keyboard_toolbar')
)
return nothing;
if (!this._show$.value) return nothing;
if (!this.block.rootComponent) return nothing;
return html`<blocksuite-portal
.shadowDom=${false}
.template=${html`<affine-keyboard-toolbar
.config=${this.config}
.rootComponent=${this.block.rootComponent}
.close=${this._close}
></affine-keyboard-toolbar> `}
></blocksuite-portal>`;
}
}
declare global {
interface HTMLElementTagNameMap {
[AFFINE_KEYBOARD_TOOLBAR_WIDGET]: AffineKeyboardToolbarWidget;
}
}
@@ -0,0 +1,100 @@
import {
PropTypes,
requiredProperties,
ShadowlessElement,
} from '@blocksuite/block-std';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import { html, nothing, type PropertyValues } from 'lit';
import { property } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import type {
KeyboardIconType,
KeyboardToolbarActionItem,
KeyboardToolbarContext,
KeyboardToolPanelConfig,
KeyboardToolPanelGroup,
} from './config.js';
import { keyboardToolPanelStyles } from './styles.js';
export const AFFINE_KEYBOARD_TOOL_PANEL = 'affine-keyboard-tool-panel';
@requiredProperties({
context: PropTypes.object,
})
export class AffineKeyboardToolPanel extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
static override styles = keyboardToolPanelStyles;
private readonly _handleItemClick = (item: KeyboardToolbarActionItem) => {
if (item.disableWhen && item.disableWhen(this.context)) return;
if (item.action) {
Promise.resolve(item.action(this.context)).catch(console.error);
}
};
private _renderGroup(group: KeyboardToolPanelGroup) {
const items = group.items.filter(
item => item.showWhen?.(this.context) ?? true
);
return html`<div class="keyboard-tool-panel-group">
<div class="keyboard-tool-panel-group-header">${group.name}</div>
<div class="keyboard-tool-panel-group-item-container">
${repeat(
items,
item => item.name,
item => this._renderItem(item)
)}
</div>
</div>`;
}
private _renderIcon(icon: KeyboardIconType) {
return typeof icon === 'function' ? icon(this.context) : icon;
}
private _renderItem(item: KeyboardToolbarActionItem) {
return html`<div class="keyboard-tool-panel-item">
<button @click=${() => this._handleItemClick(item)}>
${this._renderIcon(item.icon)}
</button>
<span>${item.name}</span>
</div>`;
}
override render() {
if (!this.config) return nothing;
const groups = this.config.groups
.map(group => (typeof group === 'function' ? group(this.context) : group))
.filter((group): group is KeyboardToolPanelGroup => group !== null);
return repeat(
groups,
group => group.name,
group => this._renderGroup(group)
);
}
protected override willUpdate(changedProperties: PropertyValues<this>) {
if (changedProperties.has('height')) {
this.style.height = `${this.height}px`;
if (this.height === 0) {
this.style.padding = '0';
} else {
this.style.padding = '';
}
}
}
@property({ attribute: false })
accessor config: KeyboardToolPanelConfig | null = null;
@property({ attribute: false })
accessor context!: KeyboardToolbarContext;
@property({ type: Number })
accessor height = 0;
}
@@ -0,0 +1,371 @@
import {
VirtualKeyboardController,
type VirtualKeyboardControllerConfig,
} from '@blocksuite/affine-components/virtual-keyboard';
import { getSelectedModelsCommand } from '@blocksuite/affine-shared/commands';
import {
PropTypes,
requiredProperties,
ShadowlessElement,
} from '@blocksuite/block-std';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import { ArrowLeftBigIcon, KeyboardIcon } from '@blocksuite/icons/lit';
import { effect, type Signal, signal } from '@preact/signals-core';
import { html } from 'lit';
import { property } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js';
import { when } from 'lit/directives/when.js';
import { PageRootBlockComponent } from '../../page/page-root-block.js';
import type {
KeyboardIconType,
KeyboardToolbarConfig,
KeyboardToolbarContext,
KeyboardToolbarItem,
KeyboardToolPanelConfig,
} from './config.js';
import { keyboardToolbarStyles, TOOLBAR_HEIGHT } from './styles.js';
import {
isKeyboardSubToolBarConfig,
isKeyboardToolBarActionItem,
isKeyboardToolPanelConfig,
} from './utils.js';
export const AFFINE_KEYBOARD_TOOLBAR = 'affine-keyboard-toolbar';
@requiredProperties({
config: PropTypes.object,
rootComponent: PropTypes.instanceOf(PageRootBlockComponent),
})
export class AffineKeyboardToolbar extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
static override styles = keyboardToolbarStyles;
private readonly _closeToolPanel = () => {
if (!this._isPanelOpened) return;
this._currentPanelIndex$.value = -1;
this._keyboardController.show();
};
private readonly _currentPanelIndex$ = signal(-1);
private readonly _goPrevToolbar = () => {
if (!this._isSubToolbarOpened) return;
if (this._isPanelOpened) this._closeToolPanel();
this._path$.value = this._path$.value.slice(0, -1);
};
private readonly _handleItemClick = (
item: KeyboardToolbarItem,
index: number
) => {
if (isKeyboardToolBarActionItem(item)) {
item.action &&
Promise.resolve(item.action(this._context)).catch(console.error);
} else if (isKeyboardSubToolBarConfig(item)) {
this._closeToolPanel();
this._path$.value = [...this._path$.value, index];
} else if (isKeyboardToolPanelConfig(item)) {
if (this._currentPanelIndex$.value === index) {
this._closeToolPanel();
} else {
this._currentPanelIndex$.value = index;
this._keyboardController.hide();
this.scrollCurrentBlockIntoView();
}
}
this._lastActiveItem$.value = item;
};
private readonly _keyboardController = new VirtualKeyboardController(this);
private readonly _lastActiveItem$ = signal<KeyboardToolbarItem | null>(null);
/** This field records the panel static height, which dose not aim to control the panel opening */
private readonly _panelHeight$ = signal(0);
private readonly _path$ = signal<number[]>([]);
private readonly scrollCurrentBlockIntoView = () => {
const { std } = this.rootComponent;
std.command
.chain()
.pipe(getSelectedModelsCommand)
.pipe(({ selectedModels }) => {
if (!selectedModels?.length) return;
const block = std.view.getBlock(selectedModels[0].id);
if (!block) return;
const { y: y1 } = this.getBoundingClientRect();
const { bottom: y2 } = block.getBoundingClientRect();
const gap = 8;
if (y2 < y1 + gap) return;
scrollTo({
top: window.scrollY + y2 - y1 + gap,
behavior: 'instant',
});
})
.run();
};
private get _context(): KeyboardToolbarContext {
return {
std: this.rootComponent.std,
rootComponent: this.rootComponent,
closeToolbar: (blur = false) => {
this.close(blur);
},
closeToolPanel: () => {
this._closeToolPanel();
},
};
}
private get _currentPanelConfig(): KeyboardToolPanelConfig | null {
if (!this._isPanelOpened) return null;
const result = this._currentToolbarItems[this._currentPanelIndex$.value];
return isKeyboardToolPanelConfig(result) ? result : null;
}
private get _currentToolbarItems(): KeyboardToolbarItem[] {
let items = this.config.items;
// eslint-disable-next-line @typescript-eslint/prefer-for-of
for (let i = 0; i < this._path$.value.length; i++) {
const index = this._path$.value[i];
if (isKeyboardSubToolBarConfig(items[index])) {
items = items[index].items;
} else {
break;
}
}
return items.filter(item =>
isKeyboardToolBarActionItem(item)
? (item.showWhen?.(this._context) ?? true)
: true
);
}
private get _isPanelOpened() {
return this._currentPanelIndex$.value !== -1;
}
private get _isSubToolbarOpened() {
return this._path$.value.length > 0;
}
get virtualKeyboardControllerConfig(): VirtualKeyboardControllerConfig {
return {
useScreenHeight: this.config.useScreenHeight ?? false,
inputElement: this.rootComponent,
};
}
private _renderIcon(icon: KeyboardIconType) {
return typeof icon === 'function' ? icon(this._context) : icon;
}
private _renderItem(item: KeyboardToolbarItem, index: number) {
let icon = item.icon;
let style = styleMap({});
const disabled =
('disableWhen' in item && item.disableWhen?.(this._context)) ?? false;
if (isKeyboardToolBarActionItem(item)) {
const background =
typeof item.background === 'function'
? item.background(this._context)
: item.background;
if (background)
style = styleMap({
background: background,
});
} else if (isKeyboardToolPanelConfig(item)) {
const { activeIcon, activeBackground } = item;
const active = this._currentPanelIndex$.value === index;
if (active && activeIcon) icon = activeIcon;
if (active && activeBackground)
style = styleMap({ background: activeBackground });
}
return html`<icon-button
size="36px"
style=${style}
?disabled=${disabled}
@click=${() => {
this._handleItemClick(item, index);
}}
>
${this._renderIcon(icon)}
</icon-button>`;
}
private _renderItems() {
if (document.activeElement !== this.rootComponent)
return html`<div class="item-container"></div>`;
const goPrevToolbarAction = when(
this._isSubToolbarOpened,
() =>
html`<icon-button size="36px" @click=${this._goPrevToolbar}>
${ArrowLeftBigIcon()}
</icon-button>`
);
return html`<div class="item-container">
${goPrevToolbarAction}
${repeat(this._currentToolbarItems, (item, index) =>
this._renderItem(item, index)
)}
</div>`;
}
private _renderKeyboardButton() {
return html`<div class="keyboard-container">
<icon-button
size="36px"
@click=${() => {
this.close(true);
}}
>
${KeyboardIcon()}
</icon-button>
</div>`;
}
override connectedCallback() {
super.connectedCallback();
// prevent editor blur when click item in toolbar
this.disposables.addFromEvent(this, 'pointerdown', e => {
e.preventDefault();
});
this.disposables.add(
effect(() => {
if (this._keyboardController.opened) {
this._panelHeight$.value = this._keyboardController.keyboardHeight;
} else if (this._isPanelOpened && this._panelHeight$.peek() === 0) {
this._panelHeight$.value = 260;
}
})
);
this.disposables.add(
effect(() => {
if (this._keyboardController.opened && !this.config.useScreenHeight) {
document.body.style.paddingBottom = `${this._keyboardController.keyboardHeight + TOOLBAR_HEIGHT}px`;
} else if (this._isPanelOpened) {
document.body.style.paddingBottom = `${this._panelHeight$.value + TOOLBAR_HEIGHT}px`;
} else {
document.body.style.paddingBottom = '';
}
})
);
this.disposables.add(
effect(() => {
const std = this.rootComponent.std;
std.selection.value;
// wait cursor updated
requestAnimationFrame(() => {
this.scrollCurrentBlockIntoView();
});
})
);
this._watchAutoShow();
}
private _watchAutoShow() {
const autoShowSubToolbars: { path: number[]; signal: Signal<boolean> }[] =
[];
const traverse = (item: KeyboardToolbarItem, path: number[]) => {
if (isKeyboardSubToolBarConfig(item) && item.autoShow) {
autoShowSubToolbars.push({
path,
signal: item.autoShow(this._context),
});
item.items.forEach((subItem, index) => {
traverse(subItem, [...path, index]);
});
}
};
this.config.items.forEach((item, index) => {
traverse(item, [index]);
});
const samePath = (a: number[], b: number[]) =>
a.length === b.length && a.every((v, i) => v === b[i]);
let prevPath = this._path$.peek();
this.disposables.add(
effect(() => {
autoShowSubToolbars.forEach(({ path, signal }) => {
if (signal.value) {
if (samePath(this._path$.peek(), path)) return;
prevPath = this._path$.peek();
this._path$.value = path;
} else {
this._path$.value = prevPath;
}
});
})
);
}
override disconnectedCallback() {
super.disconnectedCallback();
document.body.style.paddingBottom = '';
}
override firstUpdated() {
// workaround for the virtual keyboard showing transition animation
setTimeout(() => {
this.scrollCurrentBlockIntoView();
}, 700);
}
override render() {
this.style.bottom =
this.config.useScreenHeight && this._keyboardController.opened
? `${-this._panelHeight$.value}px`
: '0px';
return html`
<div class="keyboard-toolbar">
${this._renderItems()}
<div class="divider"></div>
${this._renderKeyboardButton()}
</div>
<affine-keyboard-tool-panel
.config=${this._currentPanelConfig}
.context=${this._context}
height=${this._panelHeight$.value}
></affine-keyboard-tool-panel>
`;
}
@property({ attribute: false })
accessor close: (blur: boolean) => void = () => {};
@property({ attribute: false })
accessor config!: KeyboardToolbarConfig;
@property({ attribute: false })
accessor rootComponent!: PageRootBlockComponent;
}
@@ -0,0 +1,147 @@
import { scrollbarStyle } from '@blocksuite/affine-shared/styles';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { css } from 'lit';
export const TOOLBAR_HEIGHT = 46;
export const keyboardToolbarStyles = css`
affine-keyboard-toolbar {
position: fixed;
display: block;
width: 100vw;
}
.keyboard-toolbar {
width: 100%;
height: ${TOOLBAR_HEIGHT}px;
display: inline-flex;
align-items: center;
padding: 0px 8px;
box-sizing: border-box;
gap: 8px;
z-index: var(--affine-z-index-popover);
background-color: ${unsafeCSSVarV2('layer/background/primary')};
border-top: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
box-shadow: 0px -4px 10px 0px rgba(0, 0, 0, 0.05);
> div {
padding-top: 4px;
}
> div:not(.item-container) {
padding-bottom: 4px;
}
icon-button svg {
width: 24px;
height: 24px;
}
}
.item-container {
flex: 1;
display: flex;
overflow-x: auto;
gap: 8px;
padding-bottom: 0px;
icon-button {
flex: 0 0 auto;
}
}
.item-container::-webkit-scrollbar {
display: none;
}
.divider {
height: 24px;
border: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
}
`;
export const keyboardToolPanelStyles = css`
affine-keyboard-tool-panel {
display: flex;
flex-direction: column;
gap: 24px;
width: 100%;
padding: 16px 4px 8px 8px;
overflow-y: auto;
box-sizing: border-box;
background-color: ${unsafeCSSVarV2('layer/background/primary')};
}
${scrollbarStyle('affine-keyboard-tool-panel')}
.keyboard-tool-panel-group {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
align-self: stretch;
}
.keyboard-tool-panel-group-header {
color: ${unsafeCSSVarV2('text/secondary')};
/* Footnote/Emphasized */
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
font-size: 13px;
font-style: normal;
font-weight: 590;
line-height: 18px; /* 138.462% */
}
.keyboard-tool-panel-group-item-container {
width: 100%;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
column-gap: 12px;
row-gap: 12px;
}
.keyboard-tool-panel-item {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 4px;
button {
display: flex;
padding: 16px;
justify-content: center;
align-items: center;
gap: 10px;
align-self: stretch;
border: none;
border-radius: 4px;
color: ${unsafeCSSVarV2('icon/primary')};
background: ${unsafeCSSVarV2('layer/background/secondary')};
}
button:active {
background: #00000012;
}
button svg {
width: 32px;
height: 32px;
}
span {
width: 100%;
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
font-size: 13px;
font-weight: 400;
line-height: 18px;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
overflow-x: hidden;
color: ${unsafeCSSVarV2('text/secondary')};
}
}
`;
@@ -0,0 +1,24 @@
import type {
KeyboardSubToolbarConfig,
KeyboardToolbarActionItem,
KeyboardToolbarItem,
KeyboardToolPanelConfig,
} from './config.js';
export function isKeyboardToolBarActionItem(
item: KeyboardToolbarItem
): item is KeyboardToolbarActionItem {
return 'action' in item;
}
export function isKeyboardSubToolBarConfig(
item: KeyboardToolbarItem
): item is KeyboardSubToolbarConfig {
return 'items' in item;
}
export function isKeyboardToolPanelConfig(
item: KeyboardToolbarItem
): item is KeyboardToolPanelConfig {
return 'groups' in item;
}
@@ -0,0 +1,263 @@
import {
ImportIcon,
LinkedDocIcon,
LinkedEdgelessIcon,
NewDocIcon,
} from '@blocksuite/affine-components/icons';
import {
type AffineInlineEditor,
insertLinkedNode,
} from '@blocksuite/affine-components/rich-text';
import { toast } from '@blocksuite/affine-components/toast';
import {
DocModeProvider,
TelemetryProvider,
} from '@blocksuite/affine-shared/services';
import {
createDefaultDoc,
isFuzzyMatch,
type Signal,
} from '@blocksuite/affine-shared/utils';
import type { BlockStdScope, EditorHost } from '@blocksuite/block-std';
import type { InlineRange } from '@blocksuite/inline';
import type { TemplateResult } from 'lit';
import { showImportModal } from './import-doc/index.js';
export interface LinkedWidgetConfig {
/**
* The first item of the trigger keys will be the primary key
* e.g. @, [[
*/
triggerKeys: [string, ...string[]];
/**
* Convert trigger key to primary key (the first item of the trigger keys)
* [[ -> @
*/
convertTriggerKey: boolean;
ignoreBlockTypes: string[];
ignoreSelector: string;
getMenus: (
query: string,
abort: () => void,
editorHost: EditorHost,
inlineEditor: AffineInlineEditor,
abortSignal: AbortSignal
) => Promise<LinkedMenuGroup[]> | LinkedMenuGroup[];
/**
* Auto focused item
*
* Will be called when the menu is
* - opened
* - query changed
* - menu group or its items changed
*
* If the return value is not null, no action will be taken.
*/
autoFocusedItem?: (
menus: LinkedMenuGroup[],
query: string,
editorHost: EditorHost,
inlineEditor: AffineInlineEditor
) => LinkedMenuItem | null;
mobile: {
useScreenHeight?: boolean;
/**
* The linked doc menu widget will scroll the container to make sure the input cursor is visible in viewport.
* It accepts a selector string, HTMLElement or Window
*
* @default getViewportElement(editorHost) this is the scrollable container in playground
*/
scrollContainer?: string | HTMLElement | Window;
/**
* The offset between the top of viewport and the input cursor
*
* @default 46 The height of header in playground
*/
scrollTopOffset?: number | (() => number);
};
}
export type LinkedMenuItem = {
key: string;
name: string | TemplateResult<1>;
icon: TemplateResult<1>;
suffix?: string | TemplateResult<1>;
// disabled?: boolean;
action: LinkedMenuAction;
};
export type LinkedMenuAction = () => Promise<void> | void;
export type LinkedMenuGroup = {
name: string;
items: LinkedMenuItem[] | Signal<LinkedMenuItem[]>;
styles?: string;
// maximum quantity displayed by default
maxDisplay?: number;
// if the menu is loading
loading?: boolean | Signal<boolean>;
// copywriting when display quantity exceeds
overflowText?: string | Signal<string>;
// loading text
loadingText?: string | Signal<string>;
};
export type LinkedDocContext = {
std: BlockStdScope;
inlineEditor: AffineInlineEditor;
startRange: InlineRange;
triggerKey: string;
config: LinkedWidgetConfig;
close: () => void;
};
const DEFAULT_DOC_NAME = 'Untitled';
const DISPLAY_NAME_LENGTH = 8;
export function createLinkedDocMenuGroup(
query: string,
abort: () => void,
editorHost: EditorHost,
inlineEditor: AffineInlineEditor
) {
const doc = editorHost.doc;
const { docMetas } = doc.workspace.meta;
const filteredDocList = docMetas
.filter(({ id }) => id !== doc.id)
.filter(({ title }) => isFuzzyMatch(title, query));
const MAX_DOCS = 6;
return {
name: 'Link to Doc',
items: filteredDocList.map(doc => ({
key: doc.id,
name: doc.title || DEFAULT_DOC_NAME,
icon:
editorHost.std.get(DocModeProvider).getPrimaryMode(doc.id) ===
'edgeless'
? LinkedEdgelessIcon
: LinkedDocIcon,
action: () => {
abort();
insertLinkedNode({
inlineEditor,
docId: doc.id,
});
editorHost.std
.getOptional(TelemetryProvider)
?.track('LinkedDocCreated', {
control: 'linked doc',
module: 'inline @',
type: 'doc',
other: 'existing doc',
});
},
})),
maxDisplay: MAX_DOCS,
overflowText: `${filteredDocList.length - MAX_DOCS} more docs`,
};
}
export function createNewDocMenuGroup(
query: string,
abort: () => void,
editorHost: EditorHost,
inlineEditor: AffineInlineEditor
): LinkedMenuGroup {
const doc = editorHost.doc;
const docName = query || DEFAULT_DOC_NAME;
const displayDocName =
docName.slice(0, DISPLAY_NAME_LENGTH) +
(docName.length > DISPLAY_NAME_LENGTH ? '..' : '');
return {
name: 'New Doc',
items: [
{
key: 'create',
name: `Create "${displayDocName}" doc`,
icon: NewDocIcon,
action: () => {
abort();
const docName = query;
const newDoc = createDefaultDoc(doc.workspace, {
title: docName,
});
insertLinkedNode({
inlineEditor,
docId: newDoc.id,
});
const telemetryService =
editorHost.std.getOptional(TelemetryProvider);
telemetryService?.track('LinkedDocCreated', {
control: 'new doc',
module: 'inline @',
type: 'doc',
other: 'new doc',
});
telemetryService?.track('DocCreated', {
control: 'new doc',
module: 'inline @',
type: 'doc',
});
},
},
{
key: 'import',
name: 'Import',
icon: ImportIcon,
action: () => {
abort();
const onSuccess = (
docIds: string[],
options: {
importedCount: number;
}
) => {
toast(
editorHost,
`Successfully imported ${options.importedCount} Doc${options.importedCount > 1 ? 's' : ''}.`
);
for (const docId of docIds) {
insertLinkedNode({
inlineEditor,
docId,
});
}
};
const onFail = (message: string) => {
toast(editorHost, message);
};
showImportModal({
collection: doc.workspace,
onSuccess,
onFail,
});
},
},
],
};
}
export function getMenus(
query: string,
abort: () => void,
editorHost: EditorHost,
inlineEditor: AffineInlineEditor
): Promise<LinkedMenuGroup[]> {
return Promise.resolve([
createLinkedDocMenuGroup(query, abort, editorHost, inlineEditor),
createNewDocMenuGroup(query, abort, editorHost, inlineEditor),
]);
}
export const LinkedWidgetUtils = {
createLinkedDocMenuGroup,
createNewDocMenuGroup,
insertLinkedNode,
};
export const AFFINE_LINKED_DOC_WIDGET = 'affine-linked-doc-widget';
@@ -0,0 +1,16 @@
import { AFFINE_LINKED_DOC_WIDGET } from './config.js';
import { ImportDoc } from './import-doc/import-doc.js';
import { AffineLinkedDocWidget } from './index.js';
import { LinkedDocPopover } from './linked-doc-popover.js';
import { AffineMobileLinkedDocMenu } from './mobile-linked-doc-menu.js';
export function effects() {
customElements.define('affine-linked-doc-popover', LinkedDocPopover);
customElements.define(AFFINE_LINKED_DOC_WIDGET, AffineLinkedDocWidget);
customElements.define('import-doc', ImportDoc);
customElements.define(
'affine-mobile-linked-doc-menu',
AffineMobileLinkedDocMenu
);
}
@@ -0,0 +1,289 @@
import {
CloseIcon,
ExportToHTMLIcon,
ExportToMarkdownIcon,
HelpIcon,
NewIcon,
NotionIcon,
} from '@blocksuite/affine-components/icons';
import { openFileOrFiles } from '@blocksuite/affine-shared/utils';
import { WithDisposable } from '@blocksuite/global/utils';
import type { Workspace } from '@blocksuite/store';
import { html, LitElement, type PropertyValues } from 'lit';
import { query, state } from 'lit/decorators.js';
import { HtmlTransformer } from '../../../transformers/html.js';
import { MarkdownTransformer } from '../../../transformers/markdown.js';
import { NotionHtmlTransformer } from '../../../transformers/notion-html.js';
import { styles } from './styles.js';
export type OnSuccessHandler = (
pageIds: string[],
options: { isWorkspaceFile: boolean; importedCount: number }
) => void;
export type OnFailHandler = (message: string) => void;
const SHOW_LOADING_SIZE = 1024 * 200;
export class ImportDoc extends WithDisposable(LitElement) {
static override styles = styles;
constructor(
private readonly collection: Workspace,
private readonly onSuccess?: OnSuccessHandler,
private readonly onFail?: OnFailHandler,
private readonly abortController = new AbortController()
) {
super();
this._loading = false;
this.x = 0;
this.y = 0;
this._startX = 0;
this._startY = 0;
this._onMouseMove = this._onMouseMove.bind(this);
}
private async _importHtml() {
const files = await openFileOrFiles({ acceptType: 'Html', multiple: true });
if (!files) return;
const pageIds: string[] = [];
for (const file of files) {
const text = await file.text();
const needLoading = file.size > SHOW_LOADING_SIZE;
const fileName = file.name.split('.').slice(0, -1).join('.');
if (needLoading) {
this.hidden = false;
this._loading = true;
} else {
this.abortController.abort();
}
const pageId = await HtmlTransformer.importHTMLToDoc({
collection: this.collection,
html: text,
fileName,
});
needLoading && this.abortController.abort();
if (pageId) {
pageIds.push(pageId);
}
}
this._onImportSuccess(pageIds);
}
private async _importMarkDown() {
const files = await openFileOrFiles({
acceptType: 'Markdown',
multiple: true,
});
if (!files) return;
const pageIds: string[] = [];
for (const file of files) {
const text = await file.text();
const fileName = file.name.split('.').slice(0, -1).join('.');
const needLoading = file.size > SHOW_LOADING_SIZE;
if (needLoading) {
this.hidden = false;
this._loading = true;
} else {
this.abortController.abort();
}
const pageId = await MarkdownTransformer.importMarkdownToDoc({
collection: this.collection,
markdown: text,
fileName,
});
needLoading && this.abortController.abort();
if (pageId) {
pageIds.push(pageId);
}
}
this._onImportSuccess(pageIds);
}
private async _importNotion() {
const file = await openFileOrFiles({ acceptType: 'Zip' });
if (!file) return;
const needLoading = file.size > SHOW_LOADING_SIZE;
if (needLoading) {
this.hidden = false;
this._loading = true;
} else {
this.abortController.abort();
}
const { entryId, pageIds, isWorkspaceFile, hasMarkdown } =
await NotionHtmlTransformer.importNotionZip({
collection: this.collection,
imported: file,
});
needLoading && this.abortController.abort();
if (hasMarkdown) {
this._onFail(
'Importing markdown files from Notion is deprecated. Please export your Notion pages as HTML.'
);
return;
}
this._onImportSuccess([entryId], {
isWorkspaceFile,
importedCount: pageIds.length,
});
}
private _onCloseClick(event: MouseEvent) {
event.stopPropagation();
this.abortController.abort();
}
private _onFail(message: string) {
this.onFail?.(message);
}
private _onImportSuccess(
pageIds: string[],
options: { isWorkspaceFile?: boolean; importedCount?: number } = {}
) {
const {
isWorkspaceFile = false,
importedCount: pagesImportedCount = pageIds.length,
} = options;
this.onSuccess?.(pageIds, {
isWorkspaceFile,
importedCount: pagesImportedCount,
});
}
private _onMouseDown(event: MouseEvent) {
this._startX = event.clientX - this.x;
this._startY = event.clientY - this.y;
window.addEventListener('mousemove', this._onMouseMove);
}
private _onMouseMove(event: MouseEvent) {
this.x = event.clientX - this._startX;
this.y = event.clientY - this._startY;
}
private _onMouseUp() {
window.removeEventListener('mousemove', this._onMouseMove);
}
private _openLearnImportLink(event: MouseEvent) {
event.stopPropagation();
window.open(
'https://affine.pro/blog/import-your-data-from-notion-into-affine',
'_blank'
);
}
override render() {
if (this._loading) {
return html`
<div class="overlay-mask"></div>
<div class="container">
<header
class="loading-header"
@mousedown="${this._onMouseDown}"
@mouseup="${this._onMouseUp}"
>
<div>Import</div>
<loader-element .width=${'50px'}></loader-element>
</header>
<div>
Importing the file may take some time. It depends on document size
and complexity.
</div>
</div>
`;
}
return html`
<div
class="overlay-mask"
@click="${() => this.abortController.abort()}"
></div>
<div class="container">
<header @mousedown="${this._onMouseDown}" @mouseup="${this._onMouseUp}">
<icon-button height="28px" @click="${this._onCloseClick}">
${CloseIcon}
</icon-button>
<div>Import</div>
</header>
<div>
AFFiNE will gradually support more file formats for import.
<a
href="https://community.affine.pro/c/feature-requests/import-export"
target="_blank"
>Provide feedback.</a
>
</div>
<div class="button-container">
<icon-button
class="button-item"
text="Markdown"
@click="${this._importMarkDown}"
>
${ExportToMarkdownIcon}
</icon-button>
<icon-button
class="button-item"
text="HTML"
@click="${this._importHtml}"
>
${ExportToHTMLIcon}
</icon-button>
</div>
<div class="button-container">
<icon-button
class="button-item"
text="Notion"
@click="${this._importNotion}"
>
${NotionIcon}
<div
slot="suffix"
class="button-suffix"
@click="${this._openLearnImportLink}"
>
${HelpIcon}
<affine-tooltip>
Learn how to Import your Notion pages into AFFiNE.
</affine-tooltip>
</div>
</icon-button>
<icon-button class="button-item" text="Coming soon..." disabled>
${NewIcon}
</icon-button>
</div>
<!-- <div class="footer">
<div>Migrate from other versions of AFFiNE?</div>
</div> -->
</div>
`;
}
override updated(changedProps: PropertyValues) {
if (changedProps.has('x') || changedProps.has('y')) {
this.containerEl.style.transform = `translate(${this.x}px, ${this.y}px)`;
}
}
@state()
accessor _loading = false;
@state()
accessor _startX = 0;
@state()
accessor _startY = 0;
@query('.container')
accessor containerEl!: HTMLElement;
@state()
accessor x = 0;
@state()
accessor y = 0;
}
@@ -0,0 +1,32 @@
import type { Workspace } from '@blocksuite/store';
import {
ImportDoc,
type OnFailHandler,
type OnSuccessHandler,
} from './import-doc.js';
export function showImportModal({
collection,
onSuccess,
onFail,
container = document.body,
abortController = new AbortController(),
}: {
collection: Workspace;
onSuccess?: OnSuccessHandler;
onFail?: OnFailHandler;
multiple?: boolean;
container?: HTMLElement;
abortController?: AbortController;
}) {
const importDoc = new ImportDoc(
collection,
onSuccess,
onFail,
abortController
);
container.append(importDoc);
abortController.signal.addEventListener('abort', () => importDoc.remove());
return importDoc;
}
@@ -0,0 +1,103 @@
import { BLOCK_ID_ATTR } from '@blocksuite/block-std';
import type { BlockModel } from '@blocksuite/store';
import { css, html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
export class Loader extends LitElement {
static override styles = css`
.load-container {
margin: 10px auto;
width: var(--loader-width);
text-align: center;
}
.load-container .load {
width: 8px;
height: 8px;
background-color: var(--affine-text-primary-color);
border-radius: 100%;
display: inline-block;
-webkit-animation: bouncedelay 1.4s infinite ease-in-out;
animation: bouncedelay 1.4s infinite ease-in-out;
/* Prevent first note from flickering when animation starts */
-webkit-animation-fill-mode: both;
animation-fill-mode: both;
}
.load-container .load1 {
-webkit-animation-delay: -0.32s;
animation-delay: -0.32s;
}
.load-container .load2 {
-webkit-animation-delay: -0.16s;
animation-delay: -0.16s;
}
@-webkit-keyframes bouncedelay {
0%,
80%,
100% {
-webkit-transform: scale(0.625);
}
40% {
-webkit-transform: scale(1);
}
}
@keyframes bouncedelay {
0%,
80%,
100% {
transform: scale(0);
-webkit-transform: scale(0.625);
}
40% {
transform: scale(1);
-webkit-transform: scale(1);
}
}
`;
constructor() {
super();
}
override connectedCallback() {
super.connectedCallback();
if (this.hostModel) {
this.setAttribute(BLOCK_ID_ATTR, this.hostModel.id);
this.dataset.serviceLoading = 'true';
}
const width = this.width;
this.style.setProperty(
'--loader-width',
typeof width === 'string' ? width : `${width}px`
);
}
override render() {
return html`
<div class="load-container">
<div class="load load1"></div>
<div class="load load2"></div>
<div class="load"></div>
</div>
`;
}
@property({ attribute: false })
accessor hostModel: BlockModel | null = null;
@property({ attribute: false })
accessor radius: string | number = '8px';
@property({ attribute: false })
accessor width: string | number = '150px';
}
declare global {
interface HTMLElementTagNameMap {
'loader-element': Loader;
}
}
@@ -0,0 +1,88 @@
import { baseTheme } from '@toeverything/theme';
import { css, unsafeCSS } from 'lit';
export const styles = css`
.container {
position: absolute;
width: 480px;
left: calc(50% - 480px / 2);
top: calc(50% - 270px / 2);
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
font-size: var(--affine-font-base);
line-height: var(--affine-line-height);
padding: 12px 40px 36px;
gap: 20px;
display: flex;
flex-direction: column;
background: var(--affine-background-primary-color);
box-shadow: var(--affine-shadow-2);
border-radius: 16px;
z-index: var(--affine-z-index-popover);
}
.container[hidden] {
display: none;
}
header {
cursor: move;
user-select: none;
font-size: var(--affine-font-h-6);
font-weight: 600;
}
a {
white-space: nowrap;
word-break: break-word;
color: var(--affine-link-color);
fill: var(--affine-link-color);
text-decoration: none;
cursor: pointer;
}
header icon-button {
margin-left: auto;
position: relative;
left: 24px;
}
.button-container {
display: flex;
justify-content: space-between;
}
.button-container icon-button {
padding: 8px 12px;
justify-content: flex-start;
gap: 12px;
width: 190px;
height: 40px;
box-shadow: var(--affine-shadow-1);
border-radius: 10px;
}
.footer {
display: flex;
align-items: center;
color: var(--affine-text-secondary-color);
}
.loading-header {
display: flex;
align-items: center;
}
.button-suffix {
display: flex;
margin-left: auto;
}
.overlay-mask {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: var(--affine-z-index-popover);
}
`;
@@ -0,0 +1,326 @@
import type { RootBlockModel } from '@blocksuite/affine-model';
import {
getRangeRects,
type SelectionRect,
} from '@blocksuite/affine-shared/commands';
import { FeatureFlagService } from '@blocksuite/affine-shared/services';
import { getViewportElement } from '@blocksuite/affine-shared/utils';
import type { BlockComponent } from '@blocksuite/block-std';
import { BLOCK_ID_ATTR, WidgetComponent } from '@blocksuite/block-std';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import { IS_MOBILE } from '@blocksuite/global/env';
import {
INLINE_ROOT_ATTR,
type InlineEditor,
type InlineRootElement,
} from '@blocksuite/inline';
import { signal } from '@preact/signals-core';
import { html, nothing } from 'lit';
import { choose } from 'lit/directives/choose.js';
import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js';
import {
type PageRootBlockComponent,
RootBlockConfigExtension,
} from '../../index.js';
import {
type AFFINE_LINKED_DOC_WIDGET,
getMenus,
type LinkedDocContext,
type LinkedWidgetConfig,
} from './config.js';
import { linkedDocWidgetStyles } from './styles.js';
export { type LinkedWidgetConfig } from './config.js';
export class AffineLinkedDocWidget extends WidgetComponent<
RootBlockModel,
PageRootBlockComponent
> {
static override styles = linkedDocWidgetStyles;
private _context: LinkedDocContext | null = null;
private readonly _inputRects$ = signal<SelectionRect[]>([]);
private readonly _mode$ = signal<'desktop' | 'mobile' | 'none'>('none');
private _updateInputRects() {
if (!this._context) return;
const { inlineEditor, startRange, triggerKey } = this._context;
const currentInlineRange = inlineEditor.getInlineRange();
if (!currentInlineRange) return;
const startIndex = startRange.index - triggerKey.length;
const range = inlineEditor.toDomRange({
index: startIndex,
length: currentInlineRange.index - startIndex,
});
if (!range) return;
this._inputRects$.value = getRangeRects(
range,
getViewportElement(this.host)
);
}
private get _isCursorAtEnd() {
if (!this._context) return false;
const { inlineEditor } = this._context;
const currentInlineRange = inlineEditor.getInlineRange();
if (!currentInlineRange) return false;
return currentInlineRange.index === inlineEditor.yTextLength;
}
private readonly _renderLinkedDocMenu = () => {
if (!this.block.rootComponent) return nothing;
return html`<affine-mobile-linked-doc-menu
.context=${this._context}
.rootComponent=${this.block.rootComponent}
></affine-mobile-linked-doc-menu>`;
};
private readonly _renderLinkedDocPopover = () => {
return html`<affine-linked-doc-popover
.context=${this._context}
></affine-linked-doc-popover>`;
};
private _renderInputMask() {
return html`${repeat(
this._inputRects$.value,
({ top, left, width, height }, index) => {
const last =
index === this._inputRects$.value.length - 1 && this._isCursorAtEnd;
const padding = 2;
return html`<div
class="input-mask"
style=${styleMap({
top: `${top - padding}px`,
left: `${left}px`,
width: `${width + (last ? 10 : 0)}px`,
height: `${height + 2 * padding}px`,
})}
></div>`;
}
)}`;
}
private _watchInput() {
this.handleEvent('beforeInput', ctx => {
if (this._mode$.peek() !== 'none') return;
const event = ctx.get('defaultState').event;
if (!(event instanceof InputEvent)) return;
if (event.data === null) return;
const host = this.std.host;
const range = host.range.value;
if (!range || !range.collapsed) return;
const containerElement =
range.commonAncestorContainer instanceof Element
? range.commonAncestorContainer
: range.commonAncestorContainer.parentElement;
if (!containerElement) return;
if (containerElement.closest(this.config.ignoreSelector)) return;
const block = containerElement.closest<BlockComponent>(
`[${BLOCK_ID_ATTR}]`
);
if (!block || this.config.ignoreBlockTypes.includes(block.flavour))
return;
const inlineRoot = containerElement.closest<InlineRootElement>(
`[${INLINE_ROOT_ATTR}]`
);
if (!inlineRoot) return;
const inlineEditor = inlineRoot.inlineEditor;
const inlineRange = inlineEditor.getInlineRange();
if (!inlineRange) return;
const triggerKeys = this.config.triggerKeys;
const primaryTriggerKey = triggerKeys[0];
const convertTriggerKey = this.config.convertTriggerKey;
if (primaryTriggerKey.length > inlineRange.index) return;
const matchedText = inlineEditor.yTextString.slice(
inlineRange.index - primaryTriggerKey.length,
inlineRange.index
);
let converted = false;
if (matchedText !== primaryTriggerKey && convertTriggerKey) {
for (const key of triggerKeys.slice(1)) {
if (key.length > inlineRange.index) continue;
const matchedText = inlineEditor.yTextString.slice(
inlineRange.index - key.length,
inlineRange.index
);
if (matchedText === key) {
const startIdxBeforeMatchKey = inlineRange.index - key.length;
inlineEditor.deleteText({
index: startIdxBeforeMatchKey,
length: key.length,
});
inlineEditor.insertText(
{ index: startIdxBeforeMatchKey, length: 0 },
primaryTriggerKey
);
inlineEditor.setInlineRange({
index: startIdxBeforeMatchKey + primaryTriggerKey.length,
length: 0,
});
converted = true;
break;
}
}
}
if (matchedText !== primaryTriggerKey && !converted) return;
inlineEditor
.waitForUpdate()
.then(() => {
this.show({
inlineEditor,
primaryTriggerKey,
mode: IS_MOBILE ? 'mobile' : 'desktop',
});
})
.catch(console.error);
});
}
private _watchViewportChange() {
const gfx = this.std.get(GfxControllerIdentifier);
this.disposables.add(
gfx.viewport.viewportUpdated.on(() => {
this._updateInputRects();
})
);
}
get config(): LinkedWidgetConfig {
return {
triggerKeys: ['@', '[[', '【【'],
ignoreBlockTypes: ['affine:code'],
ignoreSelector:
'edgeless-text-editor, edgeless-shape-text-editor, edgeless-group-title-editor, edgeless-frame-title-editor, edgeless-connector-label-editor',
convertTriggerKey: true,
getMenus,
mobile: {
useScreenHeight: false,
scrollContainer: getViewportElement(this.std.host) ?? window,
scrollTopOffset: 46,
},
...this.std.getOptional(RootBlockConfigExtension.identifier)
?.linkedWidget,
};
}
override connectedCallback() {
super.connectedCallback();
this._watchInput();
this._watchViewportChange();
}
show(props?: {
inlineEditor?: InlineEditor;
primaryTriggerKey?: string;
mode?: 'desktop' | 'mobile';
addTriggerKey?: boolean;
}) {
const host = this.host;
const {
primaryTriggerKey = '@',
mode = 'desktop',
addTriggerKey = false,
} = props ?? {};
let inlineEditor: InlineEditor;
if (!props?.inlineEditor) {
const range = host.range.value;
if (!range || !range.collapsed) return;
const containerElement =
range.commonAncestorContainer instanceof Element
? range.commonAncestorContainer
: range.commonAncestorContainer.parentElement;
if (!containerElement) return;
const inlineRoot = containerElement.closest<InlineRootElement>(
`[${INLINE_ROOT_ATTR}]`
);
if (!inlineRoot) return;
inlineEditor = inlineRoot.inlineEditor;
} else {
inlineEditor = props.inlineEditor;
}
const inlineRange = inlineEditor.getInlineRange();
if (!inlineRange) return;
if (addTriggerKey) {
inlineEditor.insertText(
{ index: inlineRange.index, length: 0 },
primaryTriggerKey
);
inlineEditor.setInlineRange({
index: inlineRange.index + primaryTriggerKey.length,
length: 0,
});
}
const disposable = inlineEditor.slots.renderComplete.on(() => {
this._updateInputRects();
});
this._context = {
std: this.std,
inlineEditor,
startRange: inlineRange,
triggerKey: primaryTriggerKey,
config: this.config,
close: () => {
disposable.dispose();
this._inputRects$.value = [];
this._mode$.value = 'none';
this._context = null;
},
};
this._updateInputRects();
const enableMobile = this.doc
.get(FeatureFlagService)
.getFlag('enable_mobile_linked_doc_menu');
this._mode$.value = enableMobile ? mode : 'desktop';
}
override render() {
if (this._mode$.value === 'none') return nothing;
return html`${this._renderInputMask()}
<blocksuite-portal
.shadowDom=${false}
.template=${choose(
this._mode$.value,
[
['desktop', this._renderLinkedDocPopover],
['mobile', this._renderLinkedDocMenu],
],
() => html`${nothing}`
)}
></blocksuite-portal>`;
}
}
declare global {
interface HTMLElementTagNameMap {
[AFFINE_LINKED_DOC_WIDGET]: AffineLinkedDocWidget;
}
}
@@ -0,0 +1,375 @@
import { LoadingIcon } from '@blocksuite/affine-block-image';
import type { IconButton } from '@blocksuite/affine-components/icon-button';
import {
cleanSpecifiedTail,
getTextContentFromInlineRange,
} from '@blocksuite/affine-components/rich-text';
import { unsafeCSSVar } from '@blocksuite/affine-shared/theme';
import {
createKeydownObserver,
getCurrentNativeRange,
getViewportElement,
} from '@blocksuite/affine-shared/utils';
import { PropTypes, requiredProperties } from '@blocksuite/block-std';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import {
SignalWatcher,
throttle,
WithDisposable,
} from '@blocksuite/global/utils';
import { MoreHorizontalIcon } from '@blocksuite/icons/lit';
import { effect } from '@preact/signals-core';
import { css, html, LitElement, nothing } from 'lit';
import { property, query, queryAll, state } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import { getPopperPosition } from '../../utils/position.js';
import type { LinkedDocContext, LinkedMenuGroup } from './config.js';
import { linkedDocPopoverStyles } from './styles.js';
import { resolveSignal } from './utils.js';
@requiredProperties({
context: PropTypes.object,
})
export class LinkedDocPopover extends SignalWatcher(
WithDisposable(LitElement)
) {
static override styles = linkedDocPopoverStyles;
private readonly _abort = () => {
// remove popover dom
this.context.close();
// clear input query
cleanSpecifiedTail(
this.context.std.host,
this.context.inlineEditor,
this.context.triggerKey + (this._query || '')
);
};
private readonly _expanded = new Map<string, boolean>();
private _menusItemsEffectCleanup: () => void = () => {};
private readonly _updateLinkedDocGroup = async () => {
const query = this._query;
if (this._updateLinkedDocGroupAbortController) {
this._updateLinkedDocGroupAbortController.abort();
}
this._updateLinkedDocGroupAbortController = new AbortController();
if (query === null) {
this.context.close();
return;
}
this._linkedDocGroup = await this.context.config.getMenus(
query,
this._abort,
this.context.std.host,
this.context.inlineEditor,
this._updateLinkedDocGroupAbortController.signal
);
this._menusItemsEffectCleanup();
// need to rebind the effect because this._linkedDocGroup has changed.
this._menusItemsEffectCleanup = effect(() => {
this._updateAutoFocusedItem();
});
};
private readonly _updateAutoFocusedItem = () => {
if (!this._query) {
return;
}
const autoFocusedItem = this.context.config.autoFocusedItem?.(
this._linkedDocGroup,
this._query,
this.context.std.host,
this.context.inlineEditor
);
if (autoFocusedItem) {
this._activatedItemIndex = this._flattenActionList.findIndex(
item => item.key === autoFocusedItem.key
);
}
};
private _updateLinkedDocGroupAbortController: AbortController | null = null;
private get _actionGroup() {
return this._linkedDocGroup.map(group => {
return {
...group,
items: this._getActionItems(group),
};
});
}
private get _flattenActionList() {
return this._actionGroup
.map(group =>
group.items.map(item => ({ ...item, groupName: group.name }))
)
.flat();
}
private get _query() {
return getTextContentFromInlineRange(
this.context.inlineEditor,
this.context.startRange
);
}
private _getActionItems(group: LinkedMenuGroup) {
const isExpanded = !!this._expanded.get(group.name);
let items = resolveSignal(group.items);
const isOverflow = !!group.maxDisplay && items.length > group.maxDisplay;
const isLoading = resolveSignal(group.loading);
items = isExpanded ? items : items.slice(0, group.maxDisplay);
if (isLoading) {
items = items.concat({
key: 'loading',
name: resolveSignal(group.loadingText) || 'loading',
icon: LoadingIcon,
action: () => {},
});
}
if (isOverflow && !isExpanded && group.maxDisplay) {
items = items.concat({
key: `${group.name} More`,
name: resolveSignal(group.overflowText) || 'more',
icon: MoreHorizontalIcon({ width: '24px', height: '24px' }),
action: () => {
this._expanded.set(group.name, true);
this.requestUpdate();
},
});
}
return items;
}
private _isTextOverflowing(element: HTMLElement) {
return element.scrollWidth > element.clientWidth;
}
override connectedCallback() {
super.connectedCallback();
// init
this._updateLinkedDocGroup().catch(console.error);
this._disposables.addFromEvent(this, 'mousedown', e => {
// Prevent input from losing focus
e.preventDefault();
});
this._disposables.addFromEvent(window, 'mousedown', e => {
if (e.target === this) return;
// We don't clear the query when clicking outside the popover
this.context.close();
});
const keydownObserverAbortController = new AbortController();
this._disposables.add(() => keydownObserverAbortController.abort());
const { eventSource } = this.context.inlineEditor;
if (!eventSource) return;
createKeydownObserver({
target: eventSource,
signal: keydownObserverAbortController.signal,
onInput: isComposition => {
this._activatedItemIndex = 0;
if (isComposition) {
this._updateLinkedDocGroup().catch(console.error);
} else {
this.context.inlineEditor.slots.renderComplete.once(
this._updateLinkedDocGroup
);
}
},
onPaste: () => {
this._activatedItemIndex = 0;
setTimeout(() => {
this._updateLinkedDocGroup().catch(console.error);
}, 50);
},
onDelete: () => {
const curRange = this.context.inlineEditor.getInlineRange();
if (!this.context.startRange || !curRange) {
return;
}
if (curRange.index < this.context.startRange.index) {
this.context.close();
}
this._activatedItemIndex = 0;
this.context.inlineEditor.slots.renderComplete.once(
this._updateLinkedDocGroup
);
},
onMove: step => {
const itemLen = this._flattenActionList.length;
this._activatedItemIndex =
(itemLen + this._activatedItemIndex + step) % itemLen;
// Scroll to the active item
const item = this._flattenActionList[this._activatedItemIndex];
const shadowRoot = this.shadowRoot;
if (!shadowRoot) {
console.warn('Failed to find the shadow root!', this);
return;
}
const ele = shadowRoot.querySelector(
`icon-button[data-id="${item.key}"]`
);
if (!ele) {
console.warn('Failed to find the active item!', item);
return;
}
ele.scrollIntoView({
block: 'nearest',
});
},
onConfirm: () => {
this._flattenActionList[this._activatedItemIndex]
.action()
?.catch(console.error);
},
onAbort: () => {
this.context.close();
},
});
}
override disconnectedCallback() {
super.disconnectedCallback();
this._menusItemsEffectCleanup();
}
override render() {
const MAX_HEIGHT = 380;
const style = this._position
? styleMap({
transform: `translate(${this._position.x}, ${this._position.y})`,
maxHeight: `${Math.min(this._position.height, MAX_HEIGHT)}px`,
})
: styleMap({
visibility: 'hidden',
});
// XXX This is a side effect
let accIdx = 0;
return html`<div class="linked-doc-popover" style="${style}">
${this._actionGroup
.filter(group => group.items.length)
.map((group, idx) => {
return html`
<div class="divider" ?hidden=${idx === 0}></div>
<div class="group-title">${group.name}</div>
<div class="group" style=${group.styles ?? ''}>
${group.items.map(({ key, name, icon, action }) => {
accIdx++;
const curIdx = accIdx - 1;
const tooltip = this._showTooltip
? html`<affine-tooltip
tip-position=${'right'}
.tooltipStyle=${css`
* {
color: ${unsafeCSSVar('white')} !important;
}
`}
>${name}</affine-tooltip
>`
: nothing;
return html`<icon-button
width="280px"
height="30px"
data-id=${key}
.text=${name}
hover=${this._activatedItemIndex === curIdx}
@click=${() => {
action()?.catch(console.error);
}}
@mousemove=${() => {
// Use `mousemove` instead of `mouseover` to avoid navigate conflict with keyboard
this._activatedItemIndex = curIdx;
// show tooltip whether text length overflows
for (const button of this.iconButtons.values()) {
if (button.dataset.id == key && button.textElement) {
const isOverflowing = this._isTextOverflowing(
button.textElement
);
this._showTooltip = isOverflowing;
break;
}
}
}}
>
${icon} ${tooltip}
</icon-button>`;
})}
</div>
`;
})}
</div>`;
}
override willUpdate() {
if (!this.hasUpdated) {
const curRange = getCurrentNativeRange();
if (!curRange) return;
const updatePosition = throttle(() => {
this._position = getPopperPosition(this, curRange);
}, 10);
this.disposables.addFromEvent(window, 'resize', updatePosition);
const scrollContainer = getViewportElement(this.context.std.host);
if (scrollContainer) {
// Note: in edgeless mode, the scroll container is not exist!
this.disposables.addFromEvent(
scrollContainer,
'scroll',
updatePosition,
{
passive: true,
}
);
}
const gfx = this.context.std.get(GfxControllerIdentifier);
this.disposables.add(gfx.viewport.viewportUpdated.on(updatePosition));
updatePosition();
}
}
@state()
private accessor _activatedItemIndex = 0;
@state()
private accessor _linkedDocGroup: LinkedMenuGroup[] = [];
@state()
private accessor _position: {
height: number;
x: string;
y: string;
} | null = null;
@state()
private accessor _showTooltip = false;
@property({ attribute: false })
accessor context!: LinkedDocContext;
@queryAll('icon-button')
accessor iconButtons!: NodeListOf<IconButton>;
@query('.linked-doc-popover')
accessor linkedDocElement: Element | null = null;
}
@@ -0,0 +1,263 @@
import {
cleanSpecifiedTail,
getTextContentFromInlineRange,
} from '@blocksuite/affine-components/rich-text';
import {
VirtualKeyboardController,
type VirtualKeyboardControllerConfig,
} from '@blocksuite/affine-components/virtual-keyboard';
import {
createKeydownObserver,
getViewportElement,
} from '@blocksuite/affine-shared/utils';
import { PropTypes, requiredProperties } from '@blocksuite/block-std';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import { MoreHorizontalIcon } from '@blocksuite/icons/lit';
import { signal } from '@preact/signals-core';
import { html, LitElement, nothing } from 'lit';
import { property } from 'lit/decorators.js';
import { join } from 'lit/directives/join.js';
import { repeat } from 'lit/directives/repeat.js';
import { PageRootBlockComponent } from '../../index.js';
import type {
LinkedDocContext,
LinkedMenuGroup,
LinkedMenuItem,
} from './config.js';
import { mobileLinkedDocMenuStyles } from './styles.js';
import { resolveSignal } from './utils.js';
export const AFFINE_MOBILE_LINKED_DOC_MENU = 'affine-mobile-linked-doc-menu';
@requiredProperties({
context: PropTypes.object,
rootComponent: PropTypes.instanceOf(PageRootBlockComponent),
})
export class AffineMobileLinkedDocMenu extends SignalWatcher(
WithDisposable(LitElement)
) {
static override styles = mobileLinkedDocMenuStyles;
private readonly _expand = new Set<string>();
private _firstActionItem: LinkedMenuItem | null = null;
private readonly _keyboardController = new VirtualKeyboardController(this);
private readonly _linkedDocGroup$ = signal<LinkedMenuGroup[]>([]);
private readonly _renderGroup = (group: LinkedMenuGroup) => {
let items = resolveSignal(group.items);
const isOverflow = !!group.maxDisplay && items.length > group.maxDisplay;
const expanded = this._expand.has(group.name);
let moreItem = null;
if (!expanded && isOverflow) {
items = items.slice(0, group.maxDisplay);
moreItem = html`<div
class="mobile-linked-doc-menu-item"
@click=${() => {
this._expand.add(group.name);
this.requestUpdate();
}}
>
${MoreHorizontalIcon()}
<div class="text">${group.overflowText || 'more'}</div>
</div>`;
}
return html`
${repeat(items, item => item.key, this._renderItem)} ${moreItem}
`;
};
private readonly _renderItem = ({
key,
name,
icon,
action,
}: LinkedMenuItem) => {
return html`<button
class="mobile-linked-doc-menu-item"
data-id=${key}
@pointerup=${() => {
action()?.catch(console.error);
}}
>
${icon}
<div class="text">${name}</div>
</button>`;
};
private readonly _scrollInputToTop = () => {
const { inlineEditor } = this.context;
const { scrollContainer, scrollTopOffset } = this.context.config.mobile;
let container = null;
let containerScrollTop = 0;
if (typeof scrollContainer === 'string') {
container = document.querySelector(scrollContainer);
containerScrollTop = container?.scrollTop ?? 0;
} else if (scrollContainer instanceof HTMLElement) {
container = scrollContainer;
containerScrollTop = scrollContainer.scrollTop;
} else if (scrollContainer === window) {
container = window;
containerScrollTop = scrollContainer.scrollY;
} else {
container = getViewportElement(this.context.std.host);
containerScrollTop = container?.scrollTop ?? 0;
}
let offset = 0;
if (typeof scrollTopOffset === 'function') {
offset = scrollTopOffset();
} else {
offset = scrollTopOffset ?? 0;
}
if (!inlineEditor.rootElement || !container) return;
container.scrollTo({
top:
inlineEditor.rootElement.getBoundingClientRect().top +
containerScrollTop -
offset,
behavior: 'smooth',
});
};
private readonly _updateLinkedDocGroup = async () => {
if (this._updateLinkedDocGroupAbortController) {
this._updateLinkedDocGroupAbortController.abort();
}
this._updateLinkedDocGroupAbortController = new AbortController();
this._linkedDocGroup$.value = await this.context.config.getMenus(
this._query ?? '',
() => {
this.context.close();
cleanSpecifiedTail(
this.context.std.host,
this.context.inlineEditor,
this.context.triggerKey + (this._query ?? '')
);
},
this.context.std.host,
this.context.inlineEditor,
this._updateLinkedDocGroupAbortController.signal
);
};
private _updateLinkedDocGroupAbortController: AbortController | null = null;
private get _query() {
return getTextContentFromInlineRange(
this.context.inlineEditor,
this.context.startRange
);
}
get virtualKeyboardControllerConfig(): VirtualKeyboardControllerConfig {
return {
useScreenHeight: this.context.config.mobile.useScreenHeight ?? false,
inputElement: this.rootComponent,
};
}
override connectedCallback() {
super.connectedCallback();
const { inlineEditor, close } = this.context;
this._updateLinkedDocGroup().catch(console.error);
// prevent editor blur when click menu
this._disposables.addFromEvent(this, 'pointerdown', e => {
e.preventDefault();
});
// close menu when click outside
this.disposables.addFromEvent(
window,
'pointerdown',
e => {
if (e.target === this) return;
close();
},
true
);
// bind some key events
{
const { eventSource } = inlineEditor;
if (!eventSource) return;
const keydownObserverAbortController = new AbortController();
this._disposables.add(() => keydownObserverAbortController.abort());
createKeydownObserver({
target: eventSource,
signal: keydownObserverAbortController.signal,
onInput: isComposition => {
if (isComposition) {
this._updateLinkedDocGroup().catch(console.error);
} else {
inlineEditor.slots.renderComplete.once(this._updateLinkedDocGroup);
}
},
onDelete: () => {
inlineEditor.slots.renderComplete.once(() => {
const curRange = inlineEditor.getInlineRange();
if (!this.context.startRange || !curRange) return;
if (curRange.index < this.context.startRange.index) {
this.context.close();
}
this._updateLinkedDocGroup().catch(console.error);
});
},
onConfirm: () => {
this._firstActionItem?.action()?.catch(console.error);
},
onAbort: () => {
this.context.close();
},
});
}
}
override firstUpdated() {
if (!this._keyboardController.opened) {
this._keyboardController.show();
}
this._scrollInputToTop();
}
override render() {
const groups = this._linkedDocGroup$.value;
if (groups.length === 0) {
return nothing;
}
this._firstActionItem = resolveSignal(groups[0].items)[0];
this.style.bottom =
this.context.config.mobile.useScreenHeight &&
this._keyboardController.opened
? '0px'
: `max(0px, ${this._keyboardController.keyboardHeight}px)`;
return html`
${join(groups.map(this._renderGroup), html`<div class="divider"></div>`)}
`;
}
@property({ attribute: false })
accessor context!: LinkedDocContext;
@property({ attribute: false })
accessor rootComponent!: PageRootBlockComponent;
}
@@ -0,0 +1,144 @@
import { scrollbarStyle } from '@blocksuite/affine-shared/styles';
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { baseTheme } from '@toeverything/theme';
import { css, unsafeCSS } from 'lit';
export const linkedDocWidgetStyles = css`
.input-mask {
position: absolute;
pointer-events: none;
background: rgba(0, 0, 0, 0.1);
border-radius: 4px;
}
`;
export const linkedDocPopoverStyles = css`
:host {
position: absolute;
}
.linked-doc-popover {
position: fixed;
left: 0;
top: 0;
box-sizing: border-box;
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
font-size: var(--affine-font-base);
padding: 8px;
display: flex;
flex-direction: column;
overflow-y: auto;
gap: 4px;
background: ${unsafeCSSVarV2('layer/background/primary')};
box-shadow: ${unsafeCSSVar('overlayPanelShadow')};
border-radius: 4px;
z-index: var(--affine-z-index-popover);
}
.linked-doc-popover icon-button {
justify-content: flex-start;
gap: 12px;
padding: 0 8px;
}
.linked-doc-popover .group-title {
color: var(--affine-text-secondary-color);
padding: 0 8px;
height: 30px;
font-size: var(--affine-font-xs);
display: flex;
align-items: center;
flex-shrink: 0;
font-weight: 500;
}
.linked-doc-popover .divider {
border-top: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
}
.group icon-button svg {
width: 20px;
height: 20px;
}
.linked-doc-popover .group {
display: flex;
flex-direction: column;
gap: 4px;
}
${scrollbarStyle('.linked-doc-popover')}
`;
export const mobileLinkedDocMenuStyles = css`
:host {
height: 220px;
width: 100%;
position: fixed;
overflow-y: auto;
display: flex;
flex-direction: column;
align-items: flex-start;
flex-shrink: 0;
--border-style: 1px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
border-radius: 12px 12px 0px 0px;
border-top: var(--border-style);
border-right: var(--border-style);
border-left: var(--border-style);
background: ${unsafeCSSVarV2('layer/background/primary')};
box-shadow: 0px -3px 10px 0px rgba(0, 0, 0, 0.07);
}
:host::-webkit-scrollbar {
display: none;
}
.divider {
width: 100%;
border-top: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
}
.mobile-linked-doc-menu-item {
display: flex;
width: 100%;
height: 44px;
flex-shrink: 0;
padding: 11px 20px;
justify-content: flex-start;
align-items: center;
gap: 8px;
flex-shrink: 0;
box-sizing: border-box;
border: none;
background: inherit;
> svg {
width: 20px;
height: 20px;
color: ${unsafeCSSVarV2('icon/primary')};
}
.text {
overflow: hidden;
color: ${unsafeCSSVarV2('text/primary')};
text-align: justify;
text-overflow: ellipsis;
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
font-size: 17px;
font-style: normal;
font-weight: 400;
line-height: 22px;
letter-spacing: -0.43px;
}
}
.mobile-linked-doc-menu-item:active {
background: ${unsafeCSSVarV2('layer/background/hoverOverlay')};
}
`;
@@ -0,0 +1,5 @@
import { Signal } from '@preact/signals-core';
export function resolveSignal<T>(data: T | Signal<T>): T {
return data instanceof Signal ? data.value : data;
}
@@ -0,0 +1,144 @@
import { css, html, LitElement, nothing } from 'lit';
import { ref } from 'lit/directives/ref.js';
import { repeat } from 'lit/directives/repeat.js';
type ModalButton = {
text: string;
type?: 'primary';
onClick: () => Promise<void> | void;
};
type ModalOptions = {
footer: null | ModalButton[];
};
export class AffineCustomModal extends LitElement {
static override styles = css`
:host {
z-index: calc(var(--affine-z-index-modal) + 3);
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
}
.modal-background {
width: 100%;
height: 100%;
box-sizing: border-box;
align-items: center;
background-color: var(--affine-background-modal-color);
justify-content: center;
display: flex;
}
.modal-window {
width: 70%;
min-width: 500px;
height: 80%;
overflow-y: scroll;
background-color: var(--affine-background-overlay-panel-color);
border-radius: 12px;
box-shadow: var(--affine-shadow-3);
position: relative;
}
.modal-main {
height: 100%;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 20px;
padding: 24px;
position: absolute;
box-sizing: border-box;
bottom: 0;
right: 0;
}
.modal-footer .button {
align-items: center;
background: var(--affine-white);
border: 1px solid;
border-color: var(--affine-border-color);
border-radius: 8px;
color: var(--affine-text-primary-color);
cursor: pointer;
display: inline-flex;
font-size: var(--affine-font-sm);
font-weight: 500;
justify-content: center;
outline: 0;
padding: 12px 18px;
touch-action: manipulation;
transition: all 0.3s;
user-select: none;
}
.modal-footer .primary {
background: var(--affine-primary-color);
border-color: var(--affine-black-10);
box-shadow: var(--affine-button-inner-shadow);
color: var(--affine-pure-white);
}
`;
onOpen!: (div: HTMLDivElement) => void;
options!: ModalOptions;
close() {
this.remove();
}
modalRef(modal: Element | undefined) {
if (modal) this.onOpen?.(modal as HTMLDivElement);
}
override render() {
const { options } = this;
return html`<div class="modal-background">
<div class="modal-window">
<div class="modal-main" ${ref(this.modalRef)}></div>
<div class="modal-footer">
${options.footer
? repeat(
options.footer,
button => button.text,
button => html`
<button
class="button ${button.type ?? ''}"
@click=${button.onClick}
>
${button.text}
</button>
`
)
: nothing}
</div>
</div>
</div>`;
}
}
type CreateModalOption = ModalOptions & {
entry: (div: HTMLDivElement) => void;
};
export function createCustomModal(
options: CreateModalOption,
container: HTMLElement = document.body
) {
const modal = new AffineCustomModal();
modal.onOpen = options.entry;
modal.options = options;
container.append(modal);
return modal;
}
@@ -0,0 +1,22 @@
import { WidgetComponent } from '@blocksuite/block-std';
import { nothing } from 'lit';
import { createCustomModal } from './custom-modal.js';
export const AFFINE_MODAL_WIDGET = 'affine-modal-widget';
export class AffineModalWidget extends WidgetComponent {
open(options: Parameters<typeof createCustomModal>[0]) {
return createCustomModal(options, this.ownerDocument.body);
}
override render() {
return nothing;
}
}
declare global {
interface HTMLElementTagNameMap {
[AFFINE_MODAL_WIDGET]: AffineModalWidget;
}
}
@@ -0,0 +1,455 @@
import { NoteBlockModel, RootBlockModel } from '@blocksuite/affine-model';
import {
autoScroll,
getScrollContainer,
matchModels,
} from '@blocksuite/affine-shared/utils';
import {
BLOCK_ID_ATTR,
BlockComponent,
BlockSelection,
type PointerEventState,
WidgetComponent,
} from '@blocksuite/block-std';
import { assertInstanceOf } from '@blocksuite/global/utils';
import { html, nothing } from 'lit';
import { state } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import type { PageRootBlockComponent } from '../../index.js';
type Rect = {
left: number;
top: number;
width: number;
height: number;
};
type BlockInfo = {
element: BlockComponent;
rect: Rect;
};
export const AFFINE_PAGE_DRAGGING_AREA_WIDGET =
'affine-page-dragging-area-widget';
export class AffinePageDraggingAreaWidget extends WidgetComponent<
RootBlockModel,
PageRootBlockComponent
> {
static excludeFlavours: string[] = ['affine:note', 'affine:surface'];
private _dragging = false;
private _initialContainerOffset: {
x: number;
y: number;
} = {
x: 0,
y: 0,
};
private _initialScrollOffset: {
top: number;
left: number;
} = {
top: 0,
left: 0,
};
private _lastPointerState: PointerEventState | null = null;
private _rafID = 0;
private readonly _updateDraggingArea = (
state: PointerEventState,
shouldAutoScroll: boolean
) => {
const { x, y } = state;
const { x: startX, y: startY } = state.start;
const { left: initScrollX, top: initScrollY } = this._initialScrollOffset;
if (!this._viewport) {
return;
}
const { scrollLeft, scrollTop, scrollWidth, scrollHeight } = this._viewport;
const { x: initConX, y: initConY } = this._initialContainerOffset;
const { x: conX, y: conY } = state.containerOffset;
const { left: viewportLeft, top: viewportTop } = this._viewport;
let left = Math.min(
startX + initScrollX + initConX - viewportLeft,
x + scrollLeft + conX - viewportLeft
);
let right = Math.max(
startX + initScrollX + initConX - viewportLeft,
x + scrollLeft + conX - viewportLeft
);
let top = Math.min(
startY + initScrollY + initConY - viewportTop,
y + scrollTop + conY - viewportTop
);
let bottom = Math.max(
startY + initScrollY + initConY - viewportTop,
y + scrollTop + conY - viewportTop
);
left = Math.max(left, conX - viewportLeft);
right = Math.min(right, scrollWidth);
top = Math.max(top, conY - viewportTop);
bottom = Math.min(bottom, scrollHeight);
const userRect = {
left,
top,
width: right - left,
height: bottom - top,
};
this.rect = userRect;
this._selectBlocksByRect({
left: userRect.left + viewportLeft,
top: userRect.top + viewportTop,
width: userRect.width,
height: userRect.height,
});
this._lastPointerState = state;
if (shouldAutoScroll) {
const rect = this.scrollContainer.getBoundingClientRect();
const result = autoScroll(this.scrollContainer, state.raw.y - rect.top);
if (!result) {
this._clearRaf();
return;
}
}
};
private get _allBlocksWithRect(): BlockInfo[] {
if (!this._viewport) {
return [];
}
const { scrollLeft, scrollTop } = this._viewport;
const getAllNodeFromTree = (): BlockComponent[] => {
const blocks: BlockComponent[] = [];
this.host.view.walkThrough(node => {
const view = node;
if (!(view instanceof BlockComponent)) {
return true;
}
if (
view.model.role !== 'root' &&
!AffinePageDraggingAreaWidget.excludeFlavours.includes(
view.model.flavour
)
) {
blocks.push(view);
}
return;
});
return blocks;
};
const elements = getAllNodeFromTree();
return elements.map(element => {
const bounding = element.getBoundingClientRect();
return {
element,
rect: {
left: bounding.left + scrollLeft,
top: bounding.top + scrollTop,
width: bounding.width,
height: bounding.height,
},
};
});
}
private get _viewport() {
const rootComponent = this.block;
if (!rootComponent) return;
return rootComponent.viewport;
}
private get scrollContainer() {
return getScrollContainer(this.block);
}
private _clearRaf() {
if (this._rafID) {
cancelAnimationFrame(this._rafID);
this._rafID = 0;
}
}
private _selectBlocksByRect(userRect: Rect) {
const selections = getSelectingBlockPaths(
this._allBlocksWithRect,
userRect
).map(blockPath => {
return this.host.selection.create(BlockSelection, {
blockId: blockPath,
});
});
this.host.selection.setGroup('note', selections);
}
override connectedCallback() {
super.connectedCallback();
this.handleEvent(
'dragStart',
ctx => {
const state = ctx.get('pointerState');
const { button } = state.raw;
if (button !== 0) return;
if (isDragArea(state)) {
if (!this._viewport) {
return;
}
this._dragging = true;
const { scrollLeft, scrollTop } = this._viewport;
this._initialScrollOffset = {
left: scrollLeft,
top: scrollTop,
};
this._initialContainerOffset = {
x: state.containerOffset.x,
y: state.containerOffset.y,
};
return true;
}
return;
},
{ global: true }
);
this.handleEvent(
'dragMove',
ctx => {
this._clearRaf();
if (!this._dragging) {
return;
}
const state = ctx.get('pointerState');
// TODO(@L-Sun) support drag area for touch device
if (state.raw.pointerType === 'touch') return;
ctx.get('defaultState').event.preventDefault();
this._rafID = requestAnimationFrame(() => {
this._updateDraggingArea(state, true);
});
return true;
},
{ global: true }
);
this.handleEvent(
'dragEnd',
() => {
this._clearRaf();
this._dragging = false;
this.rect = null;
this._initialScrollOffset = {
top: 0,
left: 0,
};
this._initialContainerOffset = {
x: 0,
y: 0,
};
this._lastPointerState = null;
},
{
global: true,
}
);
this.handleEvent(
'pointerMove',
ctx => {
if (this._dragging) {
const state = ctx.get('pointerState');
state.raw.preventDefault();
}
},
{
global: true,
}
);
}
override disconnectedCallback() {
this._clearRaf();
this._disposables.dispose();
super.disconnectedCallback();
}
override firstUpdated() {
this._disposables.addFromEvent(this.scrollContainer, 'scroll', () => {
if (!this._dragging || !this._lastPointerState) return;
const state = this._lastPointerState;
this._rafID = requestAnimationFrame(() => {
this._updateDraggingArea(state, false);
});
});
}
override render() {
const rect = this.rect;
if (!rect) return nothing;
const style = {
left: rect.left + 'px',
top: rect.top + 'px',
width: rect.width + 'px',
height: rect.height + 'px',
};
return html`
<style>
.affine-page-dragging-area {
position: absolute;
background: var(--affine-hover-color);
z-index: 1;
pointer-events: none;
}
</style>
<div class="affine-page-dragging-area" style=${styleMap(style)}></div>
`;
}
@state()
accessor rect: Rect | null = null;
}
function rectIntersects(a: Rect, b: Rect) {
return (
a.left < b.left + b.width &&
a.left + a.width > b.left &&
a.top < b.top + b.height &&
a.top + a.height > b.top
);
}
function rectIncludesTopAndBottom(a: Rect, b: Rect) {
return a.top <= b.top && a.top + a.height >= b.top + b.height;
}
function filterBlockInfos(blockInfos: BlockInfo[], userRect: Rect) {
const results: BlockInfo[] = [];
for (const blockInfo of blockInfos) {
const rect = blockInfo.rect;
if (userRect.top + userRect.height < rect.top) break;
results.push(blockInfo);
}
return results;
}
function filterBlockInfosByParent(
parentInfos: BlockInfo,
userRect: Rect,
filteredBlockInfos: BlockInfo[]
) {
const targetBlock = parentInfos.element;
let results = [parentInfos];
if (targetBlock.childElementCount > 0) {
const childBlockInfos = targetBlock.childBlocks
.map(el =>
filteredBlockInfos.find(
blockInfo => blockInfo.element.model.id === el.model.id
)
)
.filter(block => block) as BlockInfo[];
const firstIndex = childBlockInfos.findIndex(
bl => rectIntersects(bl.rect, userRect) && bl.rect.top < userRect.top
);
const lastIndex = childBlockInfos.findIndex(
bl =>
rectIntersects(bl.rect, userRect) &&
bl.rect.top + bl.rect.height > userRect.top + userRect.height
);
if (firstIndex !== -1 && lastIndex !== -1) {
results = childBlockInfos.slice(firstIndex, lastIndex + 1);
}
}
return results;
}
function getSelectingBlockPaths(blockInfos: BlockInfo[], userRect: Rect) {
const filteredBlockInfos = filterBlockInfos(blockInfos, userRect);
const len = filteredBlockInfos.length;
const blockPaths: string[] = [];
let singleTargetParentBlock: BlockInfo | null = null;
let blocks: BlockInfo[] = [];
if (len === 0) return blockPaths;
// To get the single target parent block info
for (const block of filteredBlockInfos) {
const rect = block.rect;
if (
rectIntersects(userRect, rect) &&
rectIncludesTopAndBottom(rect, userRect)
) {
singleTargetParentBlock = block;
}
}
if (singleTargetParentBlock) {
blocks = filterBlockInfosByParent(
singleTargetParentBlock,
userRect,
filteredBlockInfos
);
} else {
// If there is no block contains the top and bottom of the userRect
// Then get all the blocks that intersect with the userRect
for (const block of filteredBlockInfos) {
if (rectIntersects(userRect, block.rect)) {
blocks.push(block);
}
}
}
// Filter out the blocks which parent is in the blocks
// eslint-disable-next-line @typescript-eslint/prefer-for-of
for (let i = 0; i < blocks.length; i++) {
const block = blocks[i];
const parent = blocks[i].element.doc.getParent(block.element.model);
const parentId = parent?.id;
if (parentId) {
const isParentInBlocks = blocks.some(
block => block.element.model.id === parentId
);
if (!isParentInBlocks) {
blockPaths.push(blocks[i].element.blockId);
}
}
}
return blockPaths;
}
function isDragArea(e: PointerEventState) {
const el = e.raw.target;
assertInstanceOf(el, Element);
const block = el.closest<BlockComponent>(`[${BLOCK_ID_ATTR}]`);
return block && matchModels(block.model, [RootBlockModel, NoteBlockModel]);
}
declare global {
interface HTMLElementTagNameMap {
[AFFINE_PAGE_DRAGGING_AREA_WIDGET]: AffinePageDraggingAreaWidget;
}
}
@@ -0,0 +1,795 @@
import { addSiblingAttachmentBlocks } from '@blocksuite/affine-block-attachment';
import type { DataViewBlockComponent } from '@blocksuite/affine-block-data-view';
import { insertDatabaseBlockCommand } from '@blocksuite/affine-block-database';
import {
FigmaIcon,
GithubIcon,
LoomIcon,
YoutubeIcon,
} from '@blocksuite/affine-block-embed';
import { insertImagesCommand } from '@blocksuite/affine-block-image';
import { insertLatexBlockCommand } from '@blocksuite/affine-block-latex';
import { getSurfaceBlock } from '@blocksuite/affine-block-surface';
import { insertSurfaceRefBlockCommand } from '@blocksuite/affine-block-surface-ref';
import { insertTableBlockCommand } from '@blocksuite/affine-block-table';
import { toggleEmbedCardCreateModal } from '@blocksuite/affine-components/embed-card-modal';
import {
ArrowDownBigIcon,
ArrowUpBigIcon,
CopyIcon,
DatabaseKanbanViewIcon20,
DatabaseTableViewIcon20,
DeleteIcon,
FileIcon,
HeadingIcon,
LinkedDocIcon,
LinkIcon,
NewDocIcon,
NowIcon,
TodayIcon,
TomorrowIcon,
YesterdayIcon,
} from '@blocksuite/affine-components/icons';
import {
getInlineEditorByModel,
insertContent,
insertInlineLatex,
textConversionConfigs,
textFormatConfigs,
} from '@blocksuite/affine-components/rich-text';
import { toast } from '@blocksuite/affine-components/toast';
import type {
FrameBlockModel,
ParagraphBlockModel,
} from '@blocksuite/affine-model';
import {
getSelectedModelsCommand,
getTextSelectionCommand,
} from '@blocksuite/affine-shared/commands';
import { REFERENCE_NODE } from '@blocksuite/affine-shared/consts';
import {
FeatureFlagService,
FileSizeLimitService,
TelemetryProvider,
} from '@blocksuite/affine-shared/services';
import {
createDefaultDoc,
openFileOrFiles,
} from '@blocksuite/affine-shared/utils';
import { viewPresets } from '@blocksuite/data-view/view-presets';
import { assertType } from '@blocksuite/global/utils';
import {
DualLinkIcon,
ExportToPdfIcon,
FrameIcon,
GroupingIcon,
ImageIcon,
TableIcon,
TeXIcon,
} from '@blocksuite/icons/lit';
import type { DeltaInsert } from '@blocksuite/inline';
import type { BlockModel } from '@blocksuite/store';
import { Slice, Text } from '@blocksuite/store';
import type { TemplateResult } from 'lit';
import type { RootBlockComponent } from '../../types.js';
import { formatDate, formatTime } from '../../utils/misc.js';
import type { AffineLinkedDocWidget } from '../linked-doc/index.js';
import { type SlashMenuTooltip, slashMenuToolTips } from './tooltips/index.js';
import {
createConversionItem,
createTextFormatItem,
insideEdgelessText,
tryRemoveEmptyLine,
} from './utils.js';
export type SlashMenuConfig = {
triggerKeys: string[];
ignoreBlockTypes: string[];
items: SlashMenuItem[];
maxHeight: number;
tooltipTimeout: number;
};
export type SlashMenuStaticConfig = Omit<SlashMenuConfig, 'items'> & {
items: SlashMenuStaticItem[];
};
export type SlashMenuItem = SlashMenuStaticItem | SlashMenuItemGenerator;
export type SlashMenuStaticItem =
| SlashMenuGroupDivider
| SlashMenuActionItem
| SlashSubMenu;
export type SlashMenuGroupDivider = {
groupName: string;
showWhen?: (ctx: SlashMenuContext) => boolean;
};
export type SlashMenuActionItem = {
name: string;
description?: string;
icon?: TemplateResult;
tooltip?: SlashMenuTooltip;
alias?: string[];
showWhen?: (ctx: SlashMenuContext) => boolean;
action: (ctx: SlashMenuContext) => void | Promise<void>;
customTemplate?: TemplateResult<1>;
};
export type SlashSubMenu = {
name: string;
description?: string;
icon?: TemplateResult;
alias?: string[];
showWhen?: (ctx: SlashMenuContext) => boolean;
subMenu: SlashMenuStaticItem[];
};
export type SlashMenuItemGenerator = (
ctx: SlashMenuContext
) => (SlashMenuGroupDivider | SlashMenuActionItem | SlashSubMenu)[];
export type SlashMenuContext = {
rootComponent: RootBlockComponent;
model: BlockModel;
};
export const defaultSlashMenuConfig: SlashMenuConfig = {
triggerKeys: ['/'],
ignoreBlockTypes: ['affine:code'],
maxHeight: 344,
tooltipTimeout: 800,
items: [
// ---------------------------------------------------------
{ groupName: 'Basic' },
...textConversionConfigs
.filter(i => i.type && ['h1', 'h2', 'h3', 'text'].includes(i.type))
.map(createConversionItem),
{
name: 'Other Headings',
icon: HeadingIcon,
subMenu: [
{ groupName: 'Headings' },
...textConversionConfigs
.filter(i => i.type && ['h4', 'h5', 'h6'].includes(i.type))
.map<SlashMenuActionItem>(createConversionItem),
],
},
...textConversionConfigs
.filter(i => i.flavour === 'affine:code')
.map<SlashMenuActionItem>(createConversionItem),
...textConversionConfigs
.filter(i => i.type && ['divider', 'quote'].includes(i.type))
.map<SlashMenuActionItem>(config => ({
...createConversionItem(config),
showWhen: ({ model }) =>
model.doc.schema.flavourSchemaMap.has(config.flavour) &&
!insideEdgelessText(model),
})),
{
name: 'Inline equation',
description: 'Create a equation block.',
icon: TeXIcon(),
alias: ['inlineMath, inlineEquation', 'inlineLatex'],
action: ({ rootComponent }) => {
rootComponent.std.command
.chain()
.pipe(getTextSelectionCommand)
.pipe(insertInlineLatex)
.run();
},
},
// ---------------------------------------------------------
{ groupName: 'List' },
...textConversionConfigs
.filter(i => i.flavour === 'affine:list')
.map(createConversionItem),
// ---------------------------------------------------------
{ groupName: 'Style' },
...textFormatConfigs
.filter(i => !['Code', 'Link'].includes(i.name))
.map<SlashMenuActionItem>(createTextFormatItem),
// ---------------------------------------------------------
{
groupName: 'Page',
showWhen: ({ model }) =>
model.doc.schema.flavourSchemaMap.has('affine:embed-linked-doc'),
},
{
name: 'New Doc',
description: 'Start a new document.',
icon: NewDocIcon,
tooltip: slashMenuToolTips['New Doc'],
showWhen: ({ model }) =>
model.doc.schema.flavourSchemaMap.has('affine:embed-linked-doc'),
action: ({ rootComponent, model }) => {
const newDoc = createDefaultDoc(rootComponent.doc.workspace);
insertContent(rootComponent.host, model, REFERENCE_NODE, {
reference: {
type: 'LinkedPage',
pageId: newDoc.id,
},
});
},
},
{
name: 'Linked Doc',
description: 'Link to another document.',
icon: LinkedDocIcon,
tooltip: slashMenuToolTips['Linked Doc'],
alias: ['dual link'],
showWhen: ({ rootComponent, model }) => {
const { std } = rootComponent;
const linkedDocWidget = std.view.getWidget(
'affine-linked-doc-widget',
rootComponent.model.id
);
if (!linkedDocWidget) return false;
return model.doc.schema.flavourSchemaMap.has('affine:embed-linked-doc');
},
action: ({ model, rootComponent }) => {
const { std } = rootComponent;
const linkedDocWidget = std.view.getWidget(
'affine-linked-doc-widget',
rootComponent.model.id
);
if (!linkedDocWidget) return;
assertType<AffineLinkedDocWidget>(linkedDocWidget);
const triggerKey = linkedDocWidget.config.triggerKeys[0];
insertContent(rootComponent.host, model, triggerKey);
const inlineEditor = getInlineEditorByModel(rootComponent.host, model);
// Wait for range to be updated
inlineEditor?.slots.inlineRangeSync.once(() => {
linkedDocWidget.show({ addTriggerKey: true });
});
},
},
// ---------------------------------------------------------
{ groupName: 'Content & Media' },
{
name: 'Table',
description: 'Create a simple table.',
icon: TableIcon(),
tooltip: slashMenuToolTips['Table View'],
showWhen: ({ model }) => !insideEdgelessText(model),
action: ({ rootComponent }) => {
rootComponent.std.command
.chain()
.pipe(getSelectedModelsCommand)
.pipe(insertTableBlockCommand, {
place: 'after',
removeEmptyLine: true,
})
.pipe(({ insertedTableBlockId }) => {
if (insertedTableBlockId) {
const telemetry =
rootComponent.std.getOptional(TelemetryProvider);
telemetry?.track('BlockCreated', {
blockType: 'affine:table',
});
}
})
.run();
},
},
{
name: 'Image',
description: 'Insert an image.',
icon: ImageIcon(),
tooltip: slashMenuToolTips['Image'],
showWhen: ({ model }) =>
model.doc.schema.flavourSchemaMap.has('affine:image'),
action: async ({ rootComponent }) => {
const [success, ctx] = rootComponent.std.command
.chain()
.pipe(getSelectedModelsCommand)
.pipe(insertImagesCommand, { removeEmptyLine: true })
.run();
if (success) await ctx.insertedImageIds;
},
},
{
name: 'Link',
description: 'Add a bookmark for reference.',
icon: LinkIcon,
tooltip: slashMenuToolTips['Link'],
showWhen: ({ model }) =>
model.doc.schema.flavourSchemaMap.has('affine:bookmark'),
action: async ({ rootComponent, model }) => {
const parentModel = rootComponent.doc.getParent(model);
if (!parentModel) {
return;
}
const index = parentModel.children.indexOf(model) + 1;
await toggleEmbedCardCreateModal(
rootComponent.host,
'Links',
'The added link will be displayed as a card view.',
{ mode: 'page', parentModel, index }
);
tryRemoveEmptyLine(model);
},
},
{
name: 'Attachment',
description: 'Attach a file to document.',
icon: FileIcon,
tooltip: slashMenuToolTips['Attachment'],
alias: ['file'],
showWhen: ({ model }) =>
model.doc.schema.flavourSchemaMap.has('affine:attachment'),
action: async ({ rootComponent, model }) => {
const file = await openFileOrFiles();
if (!file) return;
const maxFileSize =
rootComponent.std.store.get(FileSizeLimitService).maxFileSize;
await addSiblingAttachmentBlocks(
rootComponent.host,
[file],
maxFileSize,
model
);
tryRemoveEmptyLine(model);
},
},
{
name: 'PDF',
description: 'Upload a PDF to document.',
icon: ExportToPdfIcon({ width: '20', height: '20' }),
tooltip: slashMenuToolTips['PDF'],
showWhen: ({ model }) =>
model.doc.schema.flavourSchemaMap.has('affine:attachment'),
action: async ({ rootComponent, model }) => {
const file = await openFileOrFiles();
if (!file) return;
const maxFileSize =
rootComponent.std.store.get(FileSizeLimitService).maxFileSize;
await addSiblingAttachmentBlocks(
rootComponent.host,
[file],
maxFileSize,
model,
'after',
true
);
tryRemoveEmptyLine(model);
},
},
{
name: 'YouTube',
description: 'Embed a YouTube video.',
icon: YoutubeIcon,
tooltip: slashMenuToolTips['YouTube'],
showWhen: ({ model }) =>
model.doc.schema.flavourSchemaMap.has('affine:embed-youtube'),
action: async ({ rootComponent, model }) => {
const parentModel = rootComponent.doc.getParent(model);
if (!parentModel) {
return;
}
const index = parentModel.children.indexOf(model) + 1;
await toggleEmbedCardCreateModal(
rootComponent.host,
'YouTube',
'The added YouTube video link will be displayed as an embed view.',
{ mode: 'page', parentModel, index }
);
tryRemoveEmptyLine(model);
},
},
{
name: 'GitHub',
description: 'Link to a GitHub repository.',
icon: GithubIcon,
tooltip: slashMenuToolTips['Github'],
showWhen: ({ model }) =>
model.doc.schema.flavourSchemaMap.has('affine:embed-github'),
action: async ({ rootComponent, model }) => {
const parentModel = rootComponent.doc.getParent(model);
if (!parentModel) {
return;
}
const index = parentModel.children.indexOf(model) + 1;
await toggleEmbedCardCreateModal(
rootComponent.host,
'GitHub',
'The added GitHub issue or pull request link will be displayed as a card view.',
{ mode: 'page', parentModel, index }
);
tryRemoveEmptyLine(model);
},
},
// TODO: X Twitter
{
name: 'Figma',
description: 'Embed a Figma document.',
icon: FigmaIcon,
tooltip: slashMenuToolTips['Figma'],
showWhen: ({ model }) =>
model.doc.schema.flavourSchemaMap.has('affine:embed-figma'),
action: async ({ rootComponent, model }) => {
const parentModel = rootComponent.doc.getParent(model);
if (!parentModel) {
return;
}
const index = parentModel.children.indexOf(model) + 1;
await toggleEmbedCardCreateModal(
rootComponent.host,
'Figma',
'The added Figma link will be displayed as an embed view.',
{ mode: 'page', parentModel, index }
);
tryRemoveEmptyLine(model);
},
},
{
name: 'Loom',
icon: LoomIcon,
showWhen: ({ model }) =>
model.doc.schema.flavourSchemaMap.has('affine:embed-loom'),
action: async ({ rootComponent, model }) => {
const parentModel = rootComponent.doc.getParent(model);
if (!parentModel) {
return;
}
const index = parentModel.children.indexOf(model) + 1;
await toggleEmbedCardCreateModal(
rootComponent.host,
'Loom',
'The added Loom video link will be displayed as an embed view.',
{ mode: 'page', parentModel, index }
);
tryRemoveEmptyLine(model);
},
},
{
name: 'Equation',
description: 'Create a equation block.',
icon: TeXIcon(),
alias: ['mathBlock, equationBlock', 'latexBlock'],
action: ({ rootComponent }) => {
rootComponent.std.command
.chain()
.pipe(getSelectedModelsCommand)
.pipe(insertLatexBlockCommand, {
place: 'after',
removeEmptyLine: true,
})
.run();
},
},
// TODO(@L-Sun): Linear
// ---------------------------------------------------------
({ model, rootComponent }) => {
const { doc } = rootComponent;
const surfaceModel = getSurfaceBlock(doc);
if (!surfaceModel) return [];
const parent = doc.getParent(model);
if (!parent) return [];
const frameModels = doc
.getBlocksByFlavour('affine:frame')
.map(block => block.model as FrameBlockModel);
const frameItems = frameModels.map<SlashMenuActionItem>(frameModel => ({
name: 'Frame: ' + frameModel.title,
icon: FrameIcon(),
action: ({ rootComponent }) => {
rootComponent.std.command
.chain()
.pipe(getSelectedModelsCommand)
.pipe(insertSurfaceRefBlockCommand, {
reference: frameModel.id,
place: 'after',
removeEmptyLine: true,
})
.run();
},
}));
const groupElements = surfaceModel.getElementsByType('group');
const groupItems = groupElements.map(group => ({
name: 'Group: ' + group.title.toString(),
icon: GroupingIcon(),
action: () => {
rootComponent.std.command
.chain()
.pipe(getSelectedModelsCommand)
.pipe(insertSurfaceRefBlockCommand, {
reference: group.id,
place: 'after',
removeEmptyLine: true,
})
.run();
},
}));
const items = [...frameItems, ...groupItems];
if (items.length !== 0) {
return [
{
groupName: 'Document Group & Frame',
},
...items,
];
} else {
return [];
}
},
// ---------------------------------------------------------
{ groupName: 'Date' },
() => {
const now = new Date();
const tomorrow = new Date();
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
tomorrow.setDate(tomorrow.getDate() + 1);
return [
{
name: 'Today',
icon: TodayIcon,
tooltip: slashMenuToolTips['Today'],
description: formatDate(now),
action: ({ rootComponent, model }) => {
insertContent(rootComponent.host, model, formatDate(now));
},
},
{
name: 'Tomorrow',
icon: TomorrowIcon,
tooltip: slashMenuToolTips['Tomorrow'],
description: formatDate(tomorrow),
action: ({ rootComponent, model }) => {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
insertContent(rootComponent.host, model, formatDate(tomorrow));
},
},
{
name: 'Yesterday',
icon: YesterdayIcon,
tooltip: slashMenuToolTips['Yesterday'],
description: formatDate(yesterday),
action: ({ rootComponent, model }) => {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
insertContent(rootComponent.host, model, formatDate(yesterday));
},
},
{
name: 'Now',
icon: NowIcon,
tooltip: slashMenuToolTips['Now'],
description: formatTime(now),
action: ({ rootComponent, model }) => {
insertContent(rootComponent.host, model, formatTime(now));
},
},
];
},
// ---------------------------------------------------------
{ groupName: 'Database' },
{
name: 'Table View',
description: 'Display items in a table format.',
alias: ['database'],
icon: DatabaseTableViewIcon20,
tooltip: slashMenuToolTips['Table View'],
showWhen: ({ model }) =>
model.doc.schema.flavourSchemaMap.has('affine:database') &&
!insideEdgelessText(model),
action: ({ rootComponent }) => {
rootComponent.std.command
.chain()
.pipe(getSelectedModelsCommand)
.pipe(insertDatabaseBlockCommand, {
viewType: viewPresets.tableViewMeta.type,
place: 'after',
removeEmptyLine: true,
})
.pipe(({ insertedDatabaseBlockId }) => {
if (insertedDatabaseBlockId) {
const telemetry =
rootComponent.std.getOptional(TelemetryProvider);
telemetry?.track('BlockCreated', {
blockType: 'affine:database',
});
}
})
.run();
},
},
{
name: 'Todo',
alias: ['todo view'],
icon: DatabaseTableViewIcon20,
tooltip: slashMenuToolTips['Todo'],
showWhen: ({ model }) =>
model.doc.schema.flavourSchemaMap.has('affine:database') &&
!insideEdgelessText(model) &&
!!model.doc.get(FeatureFlagService).getFlag('enable_block_query'),
action: ({ model, rootComponent }) => {
const parent = rootComponent.doc.getParent(model);
if (!parent) return;
const index = parent.children.indexOf(model);
const id = rootComponent.doc.addBlock(
'affine:data-view',
{},
rootComponent.doc.getParent(model),
index + 1
);
const dataViewModel = rootComponent.doc.getBlock(id)!;
Promise.resolve()
.then(() => {
const dataView = rootComponent.std.view.getBlock(
dataViewModel.id
) as DataViewBlockComponent | null;
dataView?.dataSource.viewManager.viewAdd('table');
})
.catch(console.error);
tryRemoveEmptyLine(model);
},
},
{
name: 'Kanban View',
description: 'Visualize data in a dashboard.',
alias: ['database'],
icon: DatabaseKanbanViewIcon20,
tooltip: slashMenuToolTips['Kanban View'],
showWhen: ({ model }) =>
model.doc.schema.flavourSchemaMap.has('affine:database') &&
!insideEdgelessText(model),
action: ({ rootComponent }) => {
rootComponent.std.command
.chain()
.pipe(getSelectedModelsCommand)
.pipe(insertDatabaseBlockCommand, {
viewType: viewPresets.kanbanViewMeta.type,
place: 'after',
removeEmptyLine: true,
})
.pipe(({ insertedDatabaseBlockId }) => {
if (insertedDatabaseBlockId) {
const telemetry =
rootComponent.std.getOptional(TelemetryProvider);
telemetry?.track('BlockCreated', {
blockType: 'affine:database',
});
}
})
.run();
},
},
// ---------------------------------------------------------
{ groupName: 'Actions' },
{
name: 'Move Up',
description: 'Shift this line up.',
icon: ArrowUpBigIcon,
tooltip: slashMenuToolTips['Move Up'],
action: ({ rootComponent, model }) => {
const doc = rootComponent.doc;
const previousSiblingModel = doc.getPrev(model);
if (!previousSiblingModel) return;
const parentModel = doc.getParent(previousSiblingModel);
if (!parentModel) return;
doc.moveBlocks([model], parentModel, previousSiblingModel, true);
},
},
{
name: 'Move Down',
description: 'Shift this line down.',
icon: ArrowDownBigIcon,
tooltip: slashMenuToolTips['Move Down'],
action: ({ rootComponent, model }) => {
const doc = rootComponent.doc;
const nextSiblingModel = doc.getNext(model);
if (!nextSiblingModel) return;
const parentModel = doc.getParent(nextSiblingModel);
if (!parentModel) return;
doc.moveBlocks([model], parentModel, nextSiblingModel, false);
},
},
{
name: 'Copy',
description: 'Copy this line to clipboard.',
icon: CopyIcon,
tooltip: slashMenuToolTips['Copy'],
action: ({ rootComponent, model }) => {
const slice = Slice.fromModels(rootComponent.std.store, [model]);
rootComponent.std.clipboard
.copy(slice)
.then(() => {
toast(rootComponent.host, 'Copied to clipboard');
})
.catch(e => {
console.error(e);
});
},
},
{
name: 'Duplicate',
description: 'Create a duplicate of this line.',
icon: DualLinkIcon(),
tooltip: slashMenuToolTips['Copy'],
action: ({ rootComponent, model }) => {
if (!model.text || !(model.text instanceof Text)) {
console.error("Can't duplicate a block without text");
return;
}
const parent = rootComponent.doc.getParent(model);
if (!parent) {
console.error(
'Failed to duplicate block! Parent not found: ' +
model.id +
'|' +
model.flavour
);
return;
}
const index = parent.children.indexOf(model);
// TODO add clone model util
rootComponent.doc.addBlock(
model.flavour as never,
{
type: (model as ParagraphBlockModel).type,
text: new Text(model.text.toDelta() as DeltaInsert[]),
// @ts-expect-error FIXME: ts error
checked: model.checked,
},
rootComponent.doc.getParent(model),
index
);
},
},
{
name: 'Delete',
description: 'Remove this line permanently.',
alias: ['remove'],
icon: DeleteIcon,
tooltip: slashMenuToolTips['Delete'],
action: ({ rootComponent, model }) => {
rootComponent.doc.deleteBlock(model);
},
},
],
};
@@ -0,0 +1,231 @@
import {
type AffineInlineEditor,
getInlineEditorByModel,
} from '@blocksuite/affine-components/rich-text';
import type { UIEventStateContext } from '@blocksuite/block-std';
import { TextSelection, WidgetComponent } from '@blocksuite/block-std';
import {
assertType,
debounce,
DisposableGroup,
} from '@blocksuite/global/utils';
import { InlineEditor } from '@blocksuite/inline';
import type { RootBlockComponent } from '../../types.js';
import {
defaultSlashMenuConfig,
type SlashMenuActionItem,
type SlashMenuContext,
type SlashMenuGroupDivider,
type SlashMenuItem,
type SlashMenuItemGenerator,
type SlashMenuStaticConfig,
type SlashSubMenu,
} from './config.js';
import { SlashMenu } from './slash-menu-popover.js';
import { filterEnabledSlashMenuItems } from './utils.js';
export type AffineSlashMenuContext = SlashMenuContext;
export type AffineSlashMenuItem = SlashMenuItem;
export type AffineSlashMenuActionItem = SlashMenuActionItem;
export type AffineSlashMenuItemGenerator = SlashMenuItemGenerator;
export type AffineSlashSubMenu = SlashSubMenu;
export type AffineSlashMenuGroupDivider = SlashMenuGroupDivider;
let globalAbortController = new AbortController();
function closeSlashMenu() {
globalAbortController.abort();
}
const showSlashMenu = debounce(
({
context,
container = document.body,
abortController = new AbortController(),
config,
triggerKey,
}: {
context: SlashMenuContext;
container?: HTMLElement;
abortController?: AbortController;
config: SlashMenuStaticConfig;
triggerKey: string;
}) => {
globalAbortController = abortController;
const disposables = new DisposableGroup();
abortController.signal.addEventListener('abort', () =>
disposables.dispose()
);
const inlineEditor = getInlineEditorByModel(
context.rootComponent.host,
context.model
);
if (!inlineEditor) return;
const slashMenu = new SlashMenu(inlineEditor, abortController);
disposables.add(() => slashMenu.remove());
slashMenu.context = context;
slashMenu.config = config;
slashMenu.triggerKey = triggerKey;
// FIXME(Flrande): It is not a best practice,
// but merely a temporary measure for reusing previous components.
// Mount
container.append(slashMenu);
return slashMenu;
},
100
);
export const AFFINE_SLASH_MENU_WIDGET = 'affine-slash-menu-widget';
export class AffineSlashMenuWidget extends WidgetComponent {
static DEFAULT_CONFIG = defaultSlashMenuConfig;
private readonly _getInlineEditor = (
evt: KeyboardEvent | CompositionEvent
) => {
if (evt.target instanceof HTMLElement) {
const editor = (
evt.target.closest('.inline-editor') as {
inlineEditor?: AffineInlineEditor;
}
)?.inlineEditor;
if (editor instanceof InlineEditor) {
return editor;
}
}
const textSelection = this.host.selection.find(TextSelection);
if (!textSelection) return;
const model = this.host.doc.getBlock(textSelection.blockId)?.model;
if (!model) return;
return getInlineEditorByModel(this.host, model);
};
private readonly _handleInput = (
inlineEditor: InlineEditor,
isCompositionEnd: boolean
) => {
const inlineRangeApplyCallback = (callback: () => void) => {
// the inline ranged updated in compositionEnd event before this event callback
if (isCompositionEnd) callback();
else inlineEditor.slots.inlineRangeSync.once(callback);
};
const rootComponent = this.block;
if (rootComponent.model.flavour !== 'affine:page') {
console.error('SlashMenuWidget should be used in RootBlock');
return;
}
assertType<RootBlockComponent>(rootComponent);
inlineRangeApplyCallback(() => {
const textSelection = this.host.selection.find(TextSelection);
if (!textSelection) return;
const model = this.host.doc.getBlock(textSelection.blockId)?.model;
if (!model) return;
if (this.config.ignoreBlockTypes.includes(model.flavour)) return;
const inlineRange = inlineEditor.getInlineRange();
if (!inlineRange) return;
const textPoint = inlineEditor.getTextPoint(inlineRange.index);
if (!textPoint) return;
const [leafStart, offsetStart] = textPoint;
const text = leafStart.textContent
? leafStart.textContent.slice(0, offsetStart)
: '';
const matchedKey = this.config.triggerKeys.find(triggerKey =>
text.endsWith(triggerKey)
);
if (!matchedKey) return;
const config: SlashMenuStaticConfig = {
...this.config,
items: filterEnabledSlashMenuItems(this.config.items, {
model,
rootComponent,
}),
};
closeSlashMenu();
showSlashMenu({
context: {
model,
rootComponent,
},
triggerKey: matchedKey,
config,
});
});
};
private readonly _onCompositionEnd = (ctx: UIEventStateContext) => {
const event = ctx.get('defaultState').event as CompositionEvent;
if (
!this.config.triggerKeys.some(triggerKey =>
triggerKey.includes(event.data)
)
)
return;
const inlineEditor = this._getInlineEditor(event);
if (!inlineEditor) return;
this._handleInput(inlineEditor, true);
};
private readonly _onKeyDown = (ctx: UIEventStateContext) => {
const eventState = ctx.get('keyboardState');
const event = eventState.raw;
const key = event.key;
// check event is not composing
if (
key === undefined || // in mac os, the key may be undefined
key === 'Process' ||
event.isComposing
)
return;
if (!this.config.triggerKeys.some(triggerKey => triggerKey.includes(key)))
return;
const inlineEditor = this._getInlineEditor(event);
if (!inlineEditor) return;
this._handleInput(inlineEditor, false);
};
config = AffineSlashMenuWidget.DEFAULT_CONFIG;
override connectedCallback() {
super.connectedCallback();
if (this.config.triggerKeys.some(key => key.length === 0)) {
console.error('Trigger key of slash menu should not be empty string');
return;
}
// this.handleEvent('beforeInput', this._onBeforeInput);
this.handleEvent('keyDown', this._onKeyDown);
this.handleEvent('compositionEnd', this._onCompositionEnd);
}
}
declare global {
interface HTMLElementTagNameMap {
[AFFINE_SLASH_MENU_WIDGET]: AffineSlashMenuWidget;
}
}
@@ -0,0 +1,620 @@
import { ArrowDownIcon } from '@blocksuite/affine-components/icons';
import { createLitPortal } from '@blocksuite/affine-components/portal';
import type { AffineInlineEditor } from '@blocksuite/affine-components/rich-text';
import {
cleanSpecifiedTail,
getInlineEditorByModel,
getTextContentFromInlineRange,
} from '@blocksuite/affine-components/rich-text';
import {
createKeydownObserver,
getCurrentNativeRange,
isControlledKeyboardEvent,
isFuzzyMatch,
substringMatchScore,
} from '@blocksuite/affine-shared/utils';
import {
assertExists,
throttle,
WithDisposable,
} from '@blocksuite/global/utils';
import { autoPlacement, offset } from '@floating-ui/dom';
import { html, LitElement, nothing, type PropertyValues } from 'lit';
import { property, state } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { styleMap } from 'lit/directives/style-map.js';
import { getPopperPosition } from '../../utils/position.js';
import type {
SlashMenuActionItem,
SlashMenuContext,
SlashMenuGroupDivider,
SlashMenuItem,
SlashMenuStaticConfig,
SlashMenuStaticItem,
SlashSubMenu,
} from './config.js';
import { slashItemToolTipStyle, styles } from './styles.js';
import {
getFirstNotDividerItem,
isActionItem,
isGroupDivider,
isSubMenuItem,
notGroupDivider,
slashItemClassName,
} from './utils.js';
type InnerSlashMenuContext = SlashMenuContext & {
tooltipTimeout: number;
onClickItem: (item: SlashMenuActionItem) => void;
};
export class SlashMenu extends WithDisposable(LitElement) {
static override styles = styles;
private readonly _handleClickItem = (item: SlashMenuActionItem) => {
// Need to remove the search string
// We must to do clean the slash string before we do the action
// Otherwise, the action may change the model and cause the slash string to be changed
cleanSpecifiedTail(
this.host,
this.context.model,
this.triggerKey + (this._query || '')
);
this.inlineEditor
.waitForUpdate()
.then(() => {
item.action(this.context)?.catch(console.error);
this.abortController.abort();
})
.catch(console.error);
};
private readonly _initItemPathMap = () => {
const traverse = (item: SlashMenuStaticItem, path: number[]) => {
this._itemPathMap.set(item, [...path]);
if (isSubMenuItem(item)) {
item.subMenu.forEach((subItem, index) =>
traverse(subItem, [...path, index])
);
}
};
this.config.items.forEach((item, index) => traverse(item, [index]));
};
private _innerSlashMenuContext!: InnerSlashMenuContext;
private readonly _itemPathMap = new Map<SlashMenuItem, number[]>();
private _queryState: 'off' | 'on' | 'no_result' = 'off';
private readonly _startRange = this.inlineEditor.getInlineRange();
private readonly _updateFilteredItems = () => {
const query = this._query;
if (query === null) {
this.abortController.abort();
return;
}
this._filteredItems = [];
const searchStr = query.toLowerCase();
if (searchStr === '' || searchStr.endsWith(' ')) {
this._queryState = searchStr === '' ? 'off' : 'no_result';
return;
}
// Layer order traversal
let depth = 0;
let queue = this.config.items.filter(notGroupDivider);
while (queue.length !== 0) {
// remove the sub menu item from the previous layer result
this._filteredItems = this._filteredItems.filter(
item => !isSubMenuItem(item)
);
this._filteredItems = this._filteredItems.concat(
queue.filter(({ name, alias = [] }) =>
[name, ...alias].some(str => isFuzzyMatch(str, searchStr))
)
);
// We search first and second layer
if (this._filteredItems.length !== 0 && depth >= 1) break;
queue = queue
.map<typeof queue>(item => {
if (isSubMenuItem(item)) {
return item.subMenu.filter(notGroupDivider);
} else {
return [];
}
})
.flat();
depth++;
}
this._filteredItems = this._filteredItems.sort((a, b) => {
return -(
substringMatchScore(a.name, searchStr) -
substringMatchScore(b.name, searchStr)
);
});
this._queryState = this._filteredItems.length === 0 ? 'no_result' : 'on';
};
private get _query() {
return getTextContentFromInlineRange(this.inlineEditor, this._startRange);
}
get host() {
return this.context.rootComponent.host;
}
constructor(
private readonly inlineEditor: AffineInlineEditor,
private readonly abortController = new AbortController()
) {
super();
}
override connectedCallback() {
super.connectedCallback();
this._innerSlashMenuContext = {
...this.context,
onClickItem: this._handleClickItem,
tooltipTimeout: this.config.tooltipTimeout,
};
this._initItemPathMap();
this._disposables.addFromEvent(this, 'mousedown', e => {
// Prevent input from losing focus
e.preventDefault();
});
const inlineEditor = this.inlineEditor;
if (!inlineEditor || !inlineEditor.eventSource) {
console.error('inlineEditor or eventSource is not found');
return;
}
/**
* Handle arrow key
*
* The slash menu will be closed in the following keyboard cases:
* - Press the space key
* - Press the backspace key and the search string is empty
* - Press the escape key
* - When the search item is empty, the slash menu will be hidden temporarily,
* and if the following key is not the backspace key, the slash menu will be closed
*/
createKeydownObserver({
target: inlineEditor.eventSource,
signal: this.abortController.signal,
interceptor: (event, next) => {
const { key, isComposing, code } = event;
if (key === this.triggerKey) {
// Can not stopPropagation here,
// otherwise the rich text will not be able to trigger a new the slash menu
return;
}
if (key === 'Process' && !isComposing && code === 'Slash') {
// The IME case of above
return;
}
if (key !== 'Backspace' && this._queryState === 'no_result') {
// if the following key is not the backspace key,
// the slash menu will be closed
this.abortController.abort();
return;
}
if (key === 'ArrowRight' || key === 'ArrowLeft' || key === 'Escape') {
return;
}
next();
},
onInput: isComposition => {
if (isComposition) {
this._updateFilteredItems();
} else {
this.inlineEditor.slots.renderComplete.once(
this._updateFilteredItems
);
}
},
onPaste: () => {
setTimeout(() => {
this._updateFilteredItems();
}, 50);
},
onDelete: () => {
const curRange = this.inlineEditor.getInlineRange();
if (!this._startRange || !curRange) {
return;
}
if (curRange.index < this._startRange.index) {
this.abortController.abort();
}
this.inlineEditor.slots.renderComplete.once(this._updateFilteredItems);
},
onAbort: () => this.abortController.abort(),
});
}
protected override willUpdate() {
if (!this.hasUpdated) {
const currRage = getCurrentNativeRange();
if (!currRage) {
this.abortController.abort();
return;
}
// Handle position
const updatePosition = throttle(() => {
this._position = getPopperPosition(this, currRage);
}, 10);
this.disposables.addFromEvent(window, 'resize', updatePosition);
updatePosition();
}
}
override render() {
const slashMenuStyles = this._position
? {
transform: `translate(${this._position.x}, ${this._position.y})`,
maxHeight: `${Math.min(this._position.height, this.config.maxHeight)}px`,
}
: {
visibility: 'hidden',
};
return html`${this._queryState !== 'no_result'
? html` <div
class="overlay-mask"
@click="${() => this.abortController.abort()}"
></div>`
: nothing}
<inner-slash-menu
.context=${this._innerSlashMenuContext}
.menu=${this._queryState === 'off'
? this.config.items
: this._filteredItems}
.onClickItem=${this._handleClickItem}
.mainMenuStyle=${slashMenuStyles}
.abortController=${this.abortController}
>
</inner-slash-menu>`;
}
@state()
private accessor _filteredItems: (SlashMenuActionItem | SlashSubMenu)[] = [];
@state()
private accessor _position: {
x: string;
y: string;
height: number;
} | null = null;
@property({ attribute: false })
accessor config!: SlashMenuStaticConfig;
@property({ attribute: false })
accessor context!: SlashMenuContext;
@property({ attribute: false })
accessor triggerKey!: string;
}
export class InnerSlashMenu extends WithDisposable(LitElement) {
static override styles = styles;
private readonly _closeSubMenu = () => {
this._subMenuAbortController?.abort();
this._subMenuAbortController = null;
this._currentSubMenu = null;
};
private _currentSubMenu: SlashSubMenu | null = null;
private readonly _openSubMenu = (item: SlashSubMenu) => {
if (item === this._currentSubMenu) return;
const itemElement = this.shadowRoot?.querySelector(
`.${slashItemClassName(item)}`
);
if (!itemElement) return;
this._closeSubMenu();
this._currentSubMenu = item;
this._subMenuAbortController = new AbortController();
this._subMenuAbortController.signal.addEventListener('abort', () => {
this._closeSubMenu();
});
const subMenuElement = createLitPortal({
shadowDom: false,
template: html`<inner-slash-menu
.context=${this.context}
.menu=${item.subMenu}
.depth=${this.depth + 1}
.abortController=${this._subMenuAbortController}
>
${item.subMenu.map(this._renderItem)}
</inner-slash-menu>`,
computePosition: {
referenceElement: itemElement,
autoUpdate: true,
middleware: [
offset(12),
autoPlacement({
allowedPlacements: ['right-start', 'right-end'],
}),
],
},
abortController: this._subMenuAbortController,
});
subMenuElement.style.zIndex = `calc(var(--affine-z-index-popover) + ${this.depth})`;
subMenuElement.focus();
};
private readonly _renderActionItem = (item: SlashMenuActionItem) => {
const { name, icon, description, tooltip, customTemplate } = item;
const hover = item === this._activeItem;
return html`<icon-button
class="slash-menu-item ${slashItemClassName(item)}"
width="100%"
height="44px"
text=${customTemplate ?? name}
subText=${ifDefined(description)}
data-testid="${name}"
hover=${hover}
@mousemove=${() => {
this._activeItem = item;
this._closeSubMenu();
}}
@click=${() => this.context.onClickItem(item)}
>
${icon && html`<div class="slash-menu-item-icon">${icon}</div>`}
${tooltip &&
html`<affine-tooltip
tip-position="right"
.offset=${22}
.tooltipStyle=${slashItemToolTipStyle}
.hoverOptions=${{
enterDelay: this.context.tooltipTimeout,
allowMultiple: false,
}}
>
<div class="tooltip-figure">${tooltip.figure}</div>
<div class="tooltip-caption">${tooltip.caption}</div>
</affine-tooltip>`}
</icon-button>`;
};
private readonly _renderGroupItem = (item: SlashMenuGroupDivider) => {
return html`<div class="slash-menu-group-name">${item.groupName}</div>`;
};
private readonly _renderItem = (item: SlashMenuStaticItem) => {
if (isGroupDivider(item)) return this._renderGroupItem(item);
else if (isActionItem(item)) return this._renderActionItem(item);
else if (isSubMenuItem(item)) return this._renderSubMenuItem(item);
else {
console.error('Unknown item type for slash menu');
console.error(item);
return nothing;
}
};
private readonly _renderSubMenuItem = (item: SlashSubMenu) => {
const { name, icon, description } = item;
const hover = item === this._activeItem;
return html`<icon-button
class="slash-menu-item ${slashItemClassName(item)}"
width="100%"
height="44px"
text=${name}
subText=${ifDefined(description)}
data-testid="${name}"
hover=${hover}
@mousemove=${() => {
this._activeItem = item;
this._openSubMenu(item);
}}
@touchstart=${() => {
isSubMenuItem(item) &&
(this._currentSubMenu === item
? this._closeSubMenu()
: this._openSubMenu(item));
}}
>
${icon && html`<div class="slash-menu-item-icon">${icon}</div>`}
<div slot="suffix" style="transform: rotate(-90deg);">
${ArrowDownIcon}
</div>
</icon-button>`;
};
private _subMenuAbortController: AbortController | null = null;
private _scrollToItem(item: SlashMenuStaticItem) {
const shadowRoot = this.shadowRoot;
if (!shadowRoot) {
return;
}
const text = isGroupDivider(item) ? item.groupName : item.name;
const ele = shadowRoot.querySelector(`icon-button[text="${text}"]`);
if (!ele) {
return;
}
ele.scrollIntoView({
block: 'nearest',
});
}
override connectedCallback() {
super.connectedCallback();
// close all sub menus
this.abortController?.signal?.addEventListener('abort', () => {
this._subMenuAbortController?.abort();
});
this.addEventListener('wheel', event => {
if (this._currentSubMenu) {
event.preventDefault();
}
});
const inlineEditor = getInlineEditorByModel(
this.context.rootComponent.host,
this.context.model
);
if (!inlineEditor || !inlineEditor.eventSource) {
console.error('inlineEditor or eventSource is not found');
return;
}
inlineEditor.eventSource.addEventListener(
'keydown',
event => {
if (this._currentSubMenu) return;
if (event.isComposing) return;
const { key, ctrlKey, metaKey, altKey, shiftKey } = event;
const onlyCmd = (ctrlKey || metaKey) && !altKey && !shiftKey;
const onlyShift = shiftKey && !isControlledKeyboardEvent(event);
const notControlShift = !(ctrlKey || metaKey || altKey || shiftKey);
let moveStep = 0;
if (
(key === 'ArrowUp' && notControlShift) ||
(key === 'Tab' && onlyShift) ||
(key === 'P' && onlyCmd) ||
(key === 'p' && onlyCmd)
) {
moveStep = -1;
}
if (
(key === 'ArrowDown' && notControlShift) ||
(key === 'Tab' && notControlShift) ||
(key === 'n' && onlyCmd) ||
(key === 'N' && onlyCmd)
) {
moveStep = 1;
}
if (moveStep !== 0) {
let itemIndex = this.menu.indexOf(this._activeItem);
do {
itemIndex =
(itemIndex + moveStep + this.menu.length) % this.menu.length;
} while (isGroupDivider(this.menu[itemIndex]));
this._activeItem = this.menu[itemIndex] as typeof this._activeItem;
this._scrollToItem(this._activeItem);
event.preventDefault();
event.stopPropagation();
}
if (key === 'ArrowRight' && notControlShift) {
if (isSubMenuItem(this._activeItem)) {
this._openSubMenu(this._activeItem);
}
event.preventDefault();
event.stopPropagation();
}
if ((key === 'ArrowLeft' || key === 'Escape') && notControlShift) {
this.abortController.abort();
event.preventDefault();
event.stopPropagation();
}
if (key === 'Enter' && notControlShift) {
if (isSubMenuItem(this._activeItem)) {
this._openSubMenu(this._activeItem);
} else if (isActionItem(this._activeItem)) {
this.context.onClickItem(this._activeItem);
}
event.preventDefault();
event.stopPropagation();
}
},
{
capture: true,
signal: this.abortController.signal,
}
);
}
override disconnectedCallback() {
this.abortController.abort();
}
override render() {
if (this.menu.length === 0) return nothing;
const style = styleMap(this.mainMenuStyle ?? { position: 'relative' });
return html`<div
class="slash-menu"
style=${style}
data-testid=${`sub-menu-${this.depth}`}
>
${this.menu.map(this._renderItem)}
</div>`;
}
override willUpdate(changedProperties: PropertyValues<this>) {
if (changedProperties.has('menu') && this.menu.length !== 0) {
const firstItem = getFirstNotDividerItem(this.menu);
assertExists(firstItem);
this._activeItem = firstItem;
// this case happen on query updated
this._subMenuAbortController?.abort();
}
}
@state()
private accessor _activeItem!: SlashMenuActionItem | SlashSubMenu;
@property({ attribute: false })
accessor abortController!: AbortController;
@property({ attribute: false })
accessor context!: InnerSlashMenuContext;
@property({ attribute: false })
accessor depth: number = 0;
@property({ attribute: false })
accessor mainMenuStyle: Parameters<typeof styleMap>[0] | null = null;
@property({ attribute: false })
accessor menu!: SlashMenuStaticItem[];
}
@@ -0,0 +1,109 @@
import { scrollbarStyle } from '@blocksuite/affine-shared/styles';
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { baseTheme } from '@toeverything/theme';
import { css, unsafeCSS } from 'lit';
export const styles = css`
.overlay-mask {
pointer-events: auto;
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: var(--affine-z-index-popover);
}
.slash-menu {
position: fixed;
left: 0;
top: 0;
box-sizing: border-box;
padding: 8px 4px 8px 8px;
width: 258px;
overflow-y: auto;
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
background: ${unsafeCSSVarV2('layer/background/overlayPanel')};
box-shadow: ${unsafeCSSVar('overlayPanelShadow')};
border-radius: 8px;
z-index: var(--affine-z-index-popover);
user-select: none;
/* transition: max-height 0.2s ease-in-out; */
}
${scrollbarStyle('.slash-menu')}
.slash-menu-group-name {
box-sizing: border-box;
padding: 2px 8px;
font-size: var(--affine-font-xs);
font-weight: 500;
line-height: var(--affine-line-height);
text-align: left;
color: var(
--light-textColor-textSecondaryColor,
var(--textColor-textSecondaryColor, #8e8d91)
);
}
.slash-menu-item {
padding: 2px 8px 2px 8px;
justify-content: flex-start;
gap: 10px;
}
.slash-menu-item-icon {
box-sizing: border-box;
width: 28px;
height: 28px;
padding: 4px;
border: 1px solid var(--affine-border-color, #e3e2e4);
border-radius: 4px;
color: var(--affine-icon-color);
background: ${unsafeCSSVarV2('layer/background/overlayPanel')};
display: flex;
justify-content: center;
align-items: center;
}
.slash-menu-item-icon svg {
display: block;
width: 100%;
height: 100%;
}
.slash-menu-item.ask-ai {
color: var(--affine-brand-color);
}
.slash-menu-item.github .github-icon {
color: var(--affine-black);
}
`;
export const slashItemToolTipStyle = css`
.affine-tooltip {
display: flex;
padding: 4px 4px 2px 4px;
flex-direction: column;
align-items: flex-start;
gap: 3px;
}
.tooltip-figure svg {
display: block;
}
.tooltip-caption {
padding-left: 4px;
color: var(
--light-textColor-textSecondaryColor,
var(--textColor-textSecondaryColor, #8e8d91)
);
font-family: var(--affine-font-family);
font-size: var(--affine-font-xs);
line-height: var(--affine-line-height);
}
`;
@@ -0,0 +1,41 @@
import { html } from 'lit';
// prettier-ignore
export const AttachmentTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="170" height="68" rx="2" fill="white"/>
<mask id="mask0_16460_1013" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68">
<rect width="170" height="68" rx="2" fill="white"/>
</mask>
<g mask="url(#mask0_16460_1013)">
<rect x="8.5" y="28.5" width="169" height="67" rx="3.5" fill="white" stroke="#E3E2E4"/>
<mask id="mask1_16460_1013" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="8" y="28" width="170" height="68">
<rect x="8" y="28" width="170" height="68" rx="4" fill="white"/>
</mask>
<g mask="url(#mask1_16460_1013)">
<g filter="url(#filter0_d_16460_1013)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M149.686 42.516C149.796 41.7313 149.251 41.0057 148.47 40.8954L130.084 38.2995C128.913 38.1341 127.829 38.9542 127.665 40.1313L122.298 78.494C122.133 79.671 122.95 80.7593 124.121 80.9248L157.357 85.6174C158.529 85.7828 159.612 84.9627 159.777 83.7856L163.057 60.3418C163.166 59.5571 162.622 58.8315 161.841 58.7212L151.941 57.3234C149.598 56.9926 147.965 54.816 148.294 52.4619L149.686 42.516ZM133.567 59.8003C133.649 59.2118 134.191 58.8017 134.776 58.8844L152.455 61.3805C153.041 61.4632 153.449 62.0074 153.367 62.5959C153.284 63.1844 152.743 63.5945 152.157 63.5118L134.478 61.0157C133.892 60.933 133.484 60.3888 133.567 59.8003ZM133.932 64.923C133.346 64.8403 132.804 65.2504 132.722 65.8389C132.64 66.4274 133.048 66.9716 133.634 67.0543L151.312 69.5504C151.898 69.6331 152.44 69.223 152.522 68.6345C152.604 68.046 152.196 67.5018 151.61 67.4191L133.932 64.923Z" fill="white"/>
<path d="M153.295 43.1135C152.819 42.4792 151.818 42.7394 151.708 43.5259L150.416 52.7614C150.251 53.9385 151.067 55.0268 152.239 55.1922L161.432 56.4901C162.215 56.6007 162.74 55.7051 162.264 55.0708L153.295 43.1135Z" fill="white"/>
</g>
<g clip-path="url(#clip0_16460_1013)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.1088 36.804L17.0887 39.8241C16.1371 40.7509 16.1371 42.2492 17.0886 43.1759C18.0457 44.108 19.6014 44.108 20.5584 43.1759L23.5034 40.3144C23.6519 40.1701 23.8893 40.1735 24.0337 40.322C24.178 40.4706 24.1746 40.708 24.026 40.8523L21.0817 43.7132C21.0817 43.7133 21.0818 43.7131 21.0817 43.7132C19.8334 44.9288 17.8136 44.9289 16.5653 43.7132C15.3122 42.4926 15.3116 40.5099 16.5635 39.2886L19.5838 36.2683C20.4645 35.4105 21.8884 35.4106 22.7691 36.2683C23.6548 37.1309 23.6554 38.5332 22.771 39.3965L19.7507 42.4169C19.2376 42.9167 18.4095 42.9166 17.8964 42.4168C17.3777 41.9116 17.3777 41.0884 17.8964 40.5832L20.9956 37.5647C21.1439 37.4202 21.3813 37.4233 21.5259 37.5717C21.6704 37.7201 21.6672 37.9575 21.5189 38.102L18.4197 41.1205C18.2033 41.3312 18.2033 41.6688 18.4197 41.8796C18.6411 42.0952 19.0038 42.0957 19.2259 41.881L22.2458 38.861C22.8298 38.2923 22.8298 37.3744 22.2459 36.8056C21.6569 36.232 20.6984 36.2315 20.1088 36.804Z" fill="#77757D"/>
</g>
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" font-weight="600" letter-spacing="0em"><tspan x="30" y="43.6364">Rickroll.mp3</tspan></text>
</g>
<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="10" y="18.6364">Attach a file.</tspan></text>
</g>
<defs>
<filter id="filter0_d_16460_1013" x="118.277" y="34.2783" width="48.7936" height="55.3602" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="2"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_16460_1013"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_16460_1013" result="shape"/>
</filter>
<clipPath id="clip0_16460_1013">
<rect width="12" height="12" fill="white" transform="translate(14 34)"/>
</clipPath>
</defs>
</svg>
`;
@@ -0,0 +1,13 @@
import { html } from 'lit';
// prettier-ignore
export const BoldTextTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="170" height="68" rx="2" fill="white"/>
<mask id="mask0_16460_971" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68">
<rect width="170" height="68" rx="2" fill="white"/>
</mask>
<g mask="url(#mask0_16460_971)">
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="43.6364">Any user may have a different perspective on what data they </tspan><tspan x="8" y="55.6364">either have, choose to share, or accept.&#10;</tspan><tspan x="8" y="71.6364">For example, one user&#x2019;s edits to a document might be on </tspan><tspan x="8" y="83.6364">their laptop on an airplane; when the plane lands and the </tspan><tspan x="8" y="95.6364">computer reconnects, those changes are distributed to </tspan><tspan x="8" y="107.636">other users.&#10;</tspan><tspan x="8" y="123.636">Other users might choose to accept all, some, or none of </tspan><tspan x="8" y="135.636">those changes to their version of the document.</tspan></text>
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" font-weight="bold" letter-spacing="0px"><tspan x="8" y="15.6364">In a decentralized system, we can have a kaleidoscopic </tspan><tspan x="8" y="27.6364">complexity to our data.&#10;</tspan></text>
</g>
</svg>
`;
@@ -0,0 +1,15 @@
import { html } from 'lit';
// prettier-ignore
export const BulletedListTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="170" height="68" rx="2" fill="white"/>
<mask id="mask0_16460_934" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68">
<rect width="170" height="68" rx="2" fill="white"/>
</mask>
<g mask="url(#mask0_16460_934)">
<circle cx="14" cy="26" r="1.5" fill="#1C81D9"/>
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="22" y="29.6364">Here&#39;s an example of a bulleted list.</tspan></text>
<circle cx="14" cy="42" r="1.5" fill="#1C81D9"/>
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="22" y="45.6364">You can list your plans such as this</tspan></text>
</g>
</svg>
`;
@@ -0,0 +1,17 @@
import { html } from 'lit';
// prettier-ignore
export const CodeBlockTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="170" height="68" rx="2" fill="white"/>
<mask id="mask0_16460_915" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68">
<rect width="170" height="68" rx="2" fill="white"/>
</mask>
<g mask="url(#mask0_16460_915)">
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="var(--affine-font-code-family)" font-size="11" letter-spacing="0em"><tspan x="47.5742" y="17.46"> </tspan><tspan x="126.723" y="17.46">: </tspan><tspan x="166.297" y="17.46"> {&#10;</tspan><tspan x="8" y="32.46"> </tspan><tspan x="100.34" y="32.46"> helloTo </tspan><tspan x="166.297" y="32.46"> &#34;World&#34;&#10;</tspan><tspan x="8" y="47.46"> </tspan><tspan x="54.1699" y="47.46"> body: </tspan><tspan x="126.723" y="47.46"> </tspan><tspan x="159.701" y="47.46"> {&#10;</tspan><tspan x="8" y="62.46"> </tspan><tspan x="87.1484" y="62.46">(</tspan><tspan x="219.062" y="62.46">)&#10;</tspan><tspan x="8" y="77.46">}&#10;</tspan><tspan x="8" y="92.46">}</tspan></text>
<text fill="#0782A0" xml:space="preserve" style="white-space: pre" font-family="var(--affine-font-code-family)" font-size="11" letter-spacing="0em"><tspan x="8" y="17.46">struct</tspan><tspan x="73.957" y="32.46"> var</tspan><tspan x="159.701" y="32.46">=</tspan><tspan x="34.3828" y="47.46">var</tspan><tspan x="100.34" y="47.46">some</tspan><tspan x="139.914" y="62.46">\(</tspan></text>
<text fill="#842ED3" xml:space="preserve" style="white-space: pre" font-family="var(--affine-font-code-family)" font-size="11" letter-spacing="0em"><tspan x="54.1699" y="17.46">ContentView</tspan></text>
<text fill="#C62222" xml:space="preserve" style="white-space: pre" font-family="var(--affine-font-code-family)" font-size="11" letter-spacing="0em"><tspan x="139.914" y="17.46">View</tspan><tspan x="34.3828" y="32.46">@State</tspan><tspan x="133.318" y="47.46">View</tspan></text>
<text fill="#2159D3" xml:space="preserve" style="white-space: pre" font-family="var(--affine-font-code-family)" font-size="11" letter-spacing="0em"><tspan x="60.7656" y="62.46">Text</tspan></text>
<text fill="#D34F0B" xml:space="preserve" style="white-space: pre" font-family="var(--affine-font-code-family)" font-size="11" letter-spacing="0em"><tspan x="93.7441" y="62.46">&#34;Hello </tspan><tspan x="153.105" y="62.46">helloTo)!&#34;</tspan></text>
</g>
</svg>
`;
@@ -0,0 +1,13 @@
import { html } from 'lit';
// prettier-ignore
export const CopyTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="170" height="68" rx="2" fill="white"/>
<mask id="mask0_16460_1240" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68">
<rect width="170" height="68" rx="2" fill="white"/>
</mask>
<g mask="url(#mask0_16460_1240)">
<path d="M4 7C4 5.89543 4.89543 5 6 5H172V32H6C4.89543 32 4 31.1046 4 30V7Z" fill="#F4F4F5"/>
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="15.6364">In a decentralized system, we can have a kaleidoscopic </tspan><tspan x="8" y="27.6364">complexity to our data.&#10;</tspan><tspan x="8" y="43.6364">Any user may have a different perspective on what data they </tspan><tspan x="8" y="55.6364">either have, choose to share, or accept.&#10;</tspan><tspan x="8" y="71.6364">For example, one user&#x2019;s edits to a document might be on </tspan><tspan x="8" y="83.6364">their laptop on an airplane; when the plane lands and the </tspan><tspan x="8" y="95.6364">computer reconnects, those changes are distributed to </tspan><tspan x="8" y="107.636">other users.&#10;</tspan><tspan x="8" y="123.636">Other users might choose to accept all, some, or none of </tspan><tspan x="8" y="135.636">those changes to their version of the document.</tspan></text>
</g>
</svg>
`;
@@ -0,0 +1,14 @@
import { html } from 'lit';
// prettier-ignore
export const DeleteTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="170" height="68" rx="2" fill="white"/>
<mask id="mask0_16460_1246" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68">
<rect width="170" height="68" rx="2" fill="white"/>
</mask>
<g mask="url(#mask0_16460_1246)">
<path d="M4 7C4 5.89543 4.89543 5 6 5H172V32H6C4.89543 32 4 31.1046 4 30V7Z" fill="#FDECEB"/>
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="43.6364">Any user may have a different perspective on what data they </tspan><tspan x="8" y="55.6364">either have, choose to share, or accept.&#10;</tspan><tspan x="8" y="71.6364">For example, one user&#x2019;s edits to a document might be on </tspan><tspan x="8" y="83.6364">their laptop on an airplane; when the plane lands and the </tspan><tspan x="8" y="95.6364">computer reconnects, those changes are distributed to </tspan><tspan x="8" y="107.636">other users.&#10;</tspan><tspan x="8" y="123.636">Other users might choose to accept all, some, or none of </tspan><tspan x="8" y="135.636">those changes to their version of the document.</tspan></text>
<text fill="#EB4335" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="15.6364">In a decentralized system, we can have a kaleidoscopic </tspan><tspan x="8" y="27.6364">complexity to our data.&#10;</tspan></text>
</g>
</svg>
`;
@@ -0,0 +1,13 @@
import { html } from 'lit';
// prettier-ignore
export const DividerTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="170" height="68" rx="2" fill="white"/>
<mask id="mask0_16460_928" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68">
<rect width="170" height="68" rx="2" fill="white"/>
</mask>
<g mask="url(#mask0_16460_928)">
<text fill="black" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="16.6364">In a decentralized system, we can have a </tspan><tspan x="8" y="30.6364">kaleidoscopic complexity to our data.&#10;</tspan><tspan x="8" y="54.6364">Any user may have a different perspective </tspan><tspan x="8" y="68.6364">on what data they either have, choose to </tspan><tspan x="8" y="82.6364">share, or accept.&#10;</tspan><tspan x="8" y="106.636">For example, one user&#x2019;s edits to a </tspan><tspan x="8" y="120.636">document might be on their laptop on an </tspan><tspan x="8" y="134.636">airplane; when the plane lands and the </tspan><tspan x="8" y="148.636">computer reconnects, those changes are </tspan><tspan x="8" y="162.636">distributed to other users.&#10;</tspan><tspan x="8" y="186.636">Other users might choose to accept all, </tspan><tspan x="8" y="200.636">some, or none of those changes to their </tspan><tspan x="8" y="214.636">version of the document.</tspan></text>
<line x1="8.25" y1="40.75" x2="169.75" y2="40.75" stroke="#E3E2E4" stroke-width="0.5" stroke-linecap="round"/>
</g>
</svg>
`;
@@ -0,0 +1,28 @@
import { html } from 'lit';
// prettier-ignore
export const EdgelessTooltip = html`<svg width="170" height="106" viewBox="0 0 170 106" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="170" height="106" rx="2" fill="white"/>
<mask id="mask0_16460_1252" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="106">
<rect width="170" height="106" rx="2" fill="white"/>
</mask>
<g mask="url(#mask0_16460_1252)">
<rect x="100.5" y="42.6565" width="141" height="51" stroke="#1E96EB" stroke-width="3" stroke-dasharray="5 5"/>
<circle cx="101.5" cy="43.5" r="6" fill="white" stroke="#1E96EB" stroke-width="3"/>
<rect x="105" y="8" width="59" height="26" rx="10" fill="black" fill-opacity="0.1"/>
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="12" letter-spacing="0em"><tspan x="117" y="25.3636">Group</tspan></text>
<mask id="mask1_16460_1252" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="98" height="106">
<path d="M0 1.5C0 0.947717 0.447715 0.5 1 0.5H96.2527C96.8927 0.5 97.368 1.09278 97.2288 1.71742L74.1743 105.217C74.0725 105.675 73.6667 106 73.1982 106H0.999999C0.447715 106 0 105.552 0 105V1.5Z" fill="#F4F4F5"/>
</mask>
<g mask="url(#mask1_16460_1252)">
<path d="M0 1.5C0 0.947717 0.447715 0.5 1 0.5H96.2527C96.8927 0.5 97.368 1.09278 97.2288 1.71742L74.1743 105.217C74.0725 105.675 73.6667 106 73.1982 106H0.999999C0.447715 106 0 105.552 0 105V1.5Z" fill="#F4F4F5"/>
<rect x="23" y="41.6565" width="142" height="52" rx="9" stroke="black" stroke-opacity="0.5" stroke-width="2"/>
<path d="M23 12.4244C23 10.0964 24.8829 8.20923 27.2056 8.20923H71.7846C74.1073 8.20923 75.9902 10.0964 75.9902 12.4244V29.2849C75.9902 31.6128 74.1073 33.5 71.7846 33.5H27.2056C24.8829 33.5 23 31.6128 23 29.2849V12.4244Z" fill="black" fill-opacity="0.8"/>
<path d="M64.0353 25.2043C63.415 25.2043 62.8798 25.0674 62.4299 24.7935C61.9828 24.5169 61.6377 24.1313 61.3946 23.6367C61.1543 23.1393 61.0341 22.5608 61.0341 21.9014C61.0341 21.2419 61.1543 20.6606 61.3946 20.1577C61.6377 19.6519 61.9759 19.2579 62.409 18.9756C62.8449 18.6906 63.3535 18.5481 63.9347 18.5481C64.27 18.5481 64.6012 18.604 64.9281 18.7158C65.2551 18.8275 65.5527 19.0092 65.8209 19.2607C66.0892 19.5094 66.303 19.8391 66.4622 20.2499C66.6215 20.6606 66.7012 21.1664 66.7012 21.7672V22.1864H61.7383V21.3313H65.6952C65.6952 20.968 65.6225 20.6439 65.4772 20.3589C65.3347 20.0738 65.1307 19.8489 64.8652 19.684C64.6026 19.5191 64.2924 19.4367 63.9347 19.4367C63.5407 19.4367 63.1998 19.5345 62.912 19.7301C62.6269 19.9229 62.4076 20.1744 62.2539 20.4846C62.1002 20.7948 62.0234 21.1273 62.0234 21.4822V22.0523C62.0234 22.5385 62.1072 22.9506 62.2748 23.2888C62.4453 23.6241 62.6814 23.8798 62.9832 24.0558C63.285 24.2291 63.6357 24.3157 64.0353 24.3157C64.2952 24.3157 64.5299 24.2794 64.7395 24.2067C64.9519 24.1313 65.1349 24.0195 65.2886 23.8714C65.4423 23.7205 65.561 23.5333 65.6449 23.3097L66.6006 23.578C66.5 23.9021 66.3309 24.1872 66.0934 24.4331C65.8558 24.6762 65.5624 24.8662 65.2131 25.0031C64.8638 25.1373 64.4712 25.2043 64.0353 25.2043Z" fill="white"/>
<path d="M51.0779 25.0702V18.6319H52.0336V19.6379H52.1174C52.2515 19.2942 52.4681 19.0273 52.7671 18.8373C53.0661 18.6445 53.4252 18.5481 53.8443 18.5481C54.2691 18.5481 54.6226 18.6445 54.9048 18.8373C55.1898 19.0273 55.412 19.2942 55.5713 19.6379H55.6383C55.8032 19.3054 56.0505 19.0413 56.3802 18.8457C56.71 18.6473 57.1054 18.5481 57.5665 18.5481C58.1421 18.5481 58.613 18.7283 58.979 19.0888C59.3451 19.4465 59.5281 20.004 59.5281 20.7612V25.0702H58.5389V20.7612C58.5389 20.2862 58.409 19.9467 58.1491 19.7427C57.8892 19.5387 57.5832 19.4367 57.2311 19.4367C56.7784 19.4367 56.4278 19.5736 56.179 19.8475C55.9303 20.1185 55.806 20.4622 55.806 20.8786V25.0702H54.8V20.6606C54.8 20.2946 54.6813 19.9998 54.4437 19.7762C54.2062 19.5499 53.9002 19.4367 53.5258 19.4367C53.2687 19.4367 53.0284 19.5052 52.8048 19.6421C52.5841 19.779 52.4052 19.969 52.2683 20.2121C52.1342 20.4525 52.0671 20.7305 52.0671 21.0463V25.0702H51.0779Z" fill="white"/>
<path d="M46.3224 25.2211C45.9144 25.2211 45.5442 25.1442 45.2116 24.9905C44.8791 24.8341 44.615 24.6091 44.4194 24.3157C44.2238 24.0195 44.126 23.6618 44.126 23.2427C44.126 22.8738 44.1987 22.5748 44.344 22.3457C44.4893 22.1137 44.6835 21.9321 44.9266 21.8008C45.1697 21.6694 45.438 21.5716 45.7314 21.5073C46.0276 21.4403 46.3252 21.3872 46.6242 21.3481C47.0154 21.2978 47.3326 21.26 47.5757 21.2349C47.8216 21.207 48.0004 21.1608 48.1122 21.0966C48.2268 21.0323 48.284 20.9205 48.284 20.7612V20.7277C48.284 20.3141 48.1709 19.9928 47.9445 19.7637C47.721 19.5345 47.3815 19.4199 46.926 19.4199C46.4537 19.4199 46.0835 19.5233 45.8152 19.7301C45.547 19.9369 45.3583 20.1577 45.2493 20.3924L44.3104 20.0571C44.4781 19.6658 44.7017 19.3613 44.9811 19.1433C45.2633 18.9225 45.5707 18.7689 45.9032 18.6822C46.2386 18.5928 46.5683 18.5481 46.8924 18.5481C47.0992 18.5481 47.3368 18.5732 47.605 18.6235C47.8761 18.671 48.1373 18.7702 48.3888 18.9211C48.6431 19.072 48.8541 19.2998 49.0218 19.6044C49.1894 19.909 49.2733 20.3169 49.2733 20.8283V25.0702H48.284V24.1983H48.2337C48.1667 24.3381 48.0549 24.4876 47.8984 24.6468C47.7419 24.8061 47.5338 24.9416 47.2739 25.0534C47.014 25.1652 46.6968 25.2211 46.3224 25.2211ZM46.4733 24.3325C46.8645 24.3325 47.1942 24.2556 47.4625 24.1019C47.7336 23.9482 47.9375 23.7498 48.0745 23.5067C48.2142 23.2636 48.284 23.0079 48.284 22.7397V21.8343C48.2421 21.8846 48.1499 21.9307 48.0074 21.9726C47.8677 22.0117 47.7056 22.0467 47.5212 22.0774C47.3395 22.1053 47.1621 22.1305 46.9889 22.1528C46.8184 22.1724 46.6801 22.1892 46.5739 22.2031C46.3168 22.2367 46.0765 22.2912 45.8529 22.3666C45.6322 22.4393 45.4533 22.5497 45.3164 22.6978C45.1823 22.8431 45.1152 23.0415 45.1152 23.293C45.1152 23.6367 45.2424 23.8965 45.4967 24.0726C45.7537 24.2458 46.0793 24.3325 46.4733 24.3325Z" fill="white"/>
<path d="M40.0364 25.0704V18.6321H40.9921V19.6045H41.0592C41.1765 19.286 41.3889 19.0275 41.6963 18.8291C42.0037 18.6307 42.3502 18.5315 42.7358 18.5315C42.8084 18.5315 42.8993 18.5329 43.0082 18.5357C43.1172 18.5385 43.1997 18.5427 43.2556 18.5483V19.5542C43.222 19.5459 43.1452 19.5333 43.025 19.5165C42.9077 19.497 42.7833 19.4872 42.652 19.4872C42.339 19.4872 42.0596 19.5528 41.8136 19.6842C41.5705 19.8127 41.3777 19.9916 41.2352 20.2207C41.0955 20.447 41.0256 20.7055 41.0256 20.9961V25.0704H40.0364Z" fill="white"/>
<path d="M33.3126 25.0704V16.4861H38.4598V17.4082H34.3521V20.3088H38.0742V21.2309H34.3521V25.0704H33.3126Z" fill="white"/>
</g>
</g>
</svg>
`;
@@ -0,0 +1,11 @@
import { html } from 'lit';
// prettier-ignore
export const EmptyTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="170" height="68" rx="2" fill="white"/>
<mask id="mask0_16460_864" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68">
<rect width="170" height="68" rx="2" fill="white"/>
</mask>
<g mask="url(#mask0_16460_864)">
</g>
</svg>
`;

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