chore: merge blocksuite source code (#9213)

This commit is contained in:
Mirone
2024-12-20 15:38:06 +08:00
committed by GitHub
parent 2c9ef916f4
commit 30200ff86d
2031 changed files with 238888 additions and 229 deletions
@@ -0,0 +1,568 @@
import {
NotificationProvider,
ThemeProvider,
} from '@blocksuite/affine-shared/services';
import {
getPageRootByElement,
stopPropagation,
} from '@blocksuite/affine-shared/utils';
import type { BaseSelection } from '@blocksuite/block-std';
import { WidgetComponent } from '@blocksuite/block-std';
import { assertExists } from '@blocksuite/global/utils';
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 type { AIError } from '../../../_common/components/index.js';
import type { EdgelessRootService } from '../../edgeless/edgeless-root-service.js';
import { PageRootService } from '../../page/page-root-service.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);
}
.ai-panel-container {
display: flex;
flex-direction: column;
box-sizing: border-box;
width: 100%;
height: fit-content;
padding: 8px 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 _cancelCallback = () => {
this.focus();
};
private _clearDiscardModal = () => {
if (this._discardModalAbort) {
this._discardModalAbort.abort();
this._discardModalAbort = null;
}
};
private _clickOutside = () => {
switch (this.state) {
case 'hidden':
return;
case 'error':
case 'finished':
if (!this._answer) {
this.hide();
} else {
this.discard();
}
break;
default:
this.discard();
}
};
private _discardCallback = () => {
this.hide();
this.config?.discardCallback?.();
};
private _discardModalAbort: AbortController | null = null;
private _inputFinish = (text: string) => {
this._inputText = text;
this.generate();
};
private _inputText: string | null = null;
private _onDocumentClick = (e: MouseEvent) => {
if (
this.state !== 'hidden' &&
e.target !== this &&
!this.contains(e.target as Node)
) {
this._clickOutside();
return true;
}
return false;
};
private _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 _resetAbortController = () => {
if (this.state === 'generating') {
this._abortController.abort();
}
this._abortController = new AbortController();
};
private _selection?: BaseSelection[];
private _stopAutoUpdate?: undefined | (() => void);
ctx: unknown = null;
discard = () => {
if ((this.state === 'finished' || this.state === 'error') && !this.answer) {
this._discardCallback();
return;
}
if (this.state === 'input') {
this.hide();
return;
}
this.showDiscardModal()
.then(discard => {
if (discard) {
this._discardCallback();
} else {
this._cancelCallback();
}
this.restoreSelection();
})
.catch(console.error);
};
/**
* 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 rootService = this.host.std.getService('affine:page');
if (rootService instanceof PageRootService) {
rootBoundary = undefined;
} else {
// TODO circular dependency: instanceof EdgelessRootService
const viewport = (rootService as EdgelessRootService).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.discard}
.onFinish=${this._inputFinish}
.onInput=${this.onInput}
></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,107 @@
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 { css, html, LitElement, nothing } 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`
.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 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 {
AIErrorType,
type AIItemGroupConfig,
} from '../../../../../_common/components/index.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 _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,174 @@
import { AIStarIcon } from '@blocksuite/affine-components/icons';
import { stopPropagation } from '@blocksuite/affine-shared/utils';
import { WithDisposable } from '@blocksuite/global/utils';
import { SendIcon } from '@blocksuite/icons/lit';
import { css, html, LitElement, nothing } from 'lit';
import { property, query, state } from 'lit/decorators.js';
export class AIPanelInput extends 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);
}
.icon {
display: flex;
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: 10px;
border-radius: 4px;
background: var(--affine-black-10, rgba(0, 0, 0, 0.1));
svg {
width: 16px;
height: 16px;
color: var(--affine-pure-white, #fff);
}
}
.arrow[data-active] {
background: var(--affine-brand-color, #1e96eb);
}
.arrow[data-active]:hover {
cursor: pointer;
}
`;
private _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 _onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
e.preventDefault();
this._sendToAI();
}
};
private _sendToAI = () => {
const value = this.textarea.value.trim();
if (value.length === 0) return;
this.onFinish?.(value);
this.remove();
};
override render() {
return html`<div class="root">
<div class="icon">${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>
<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 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,60 @@
import type { nothing, TemplateResult } from 'lit';
import type {
AIError,
AIItemGroupConfig,
} from '../../../_common/components/ai-item/types.js';
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 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;
hideCallback?: () => void;
discardCallback?: () => void;
inputCallback?: (input: string) => void;
copy?: CopyConfig;
}
export type AffineAIPanelState =
| 'hidden'
| 'input'
| 'generating'
| 'finished'
| 'error';
@@ -0,0 +1,21 @@
import { isInsidePageEditor } from '@blocksuite/affine-shared/utils';
import type { EditorHost } from '@blocksuite/block-std';
import type { AIItemGroupConfig } from '../../../_common/components/ai-item/types.js';
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,149 @@
import { MoreVerticalIcon } from '@blocksuite/affine-components/icons';
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, WithDisposable } from '@blocksuite/global/utils';
import { flip, offset } from '@floating-ui/dom';
import { css, html, LitElement } from 'lit';
import { property, query, state } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import type { CodeBlockToolbarContext } from '../context.js';
export class AffineCodeToolbar extends WithDisposable(LitElement) {
static override styles = css`
:host {
position: absolute;
top: 0;
right: 0;
}
.code-toolbar-container {
height: 24px;
gap: 4px;
padding: 4px;
margin: 0;
}
.code-toolbar-button {
color: var(--affine-icon-color);
background-color: var(--affine-background-primary-color);
box-shadow: var(--affine-shadow-1);
border-radius: 4px;
}
`;
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 _toggleMoreMenu() {
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="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>
`,
// should be greater than block-selection z-index as selection and popover wil share the same stacking context(editor-host)
portalStyles: {
zIndex: 'var(--affine-z-index-popover)',
},
container: this.context.host,
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();
}
override render() {
return html`
<editor-toolbar class="code-toolbar-container" data-without-bg>
${renderGroups(this.primaryGroups, this.context)}
<editor-icon-button
class="code-toolbar-button more"
data-testid="more"
aria-label="More"
.tooltip=${'More'}
.tooltipOffset=${4}
.iconSize=${'16px'}
.iconContainerPadding=${4}
.showTooltip=${!this._moreMenuOpen}
?disabled=${this.context.doc.readonly}
@click=${() => this._toggleMoreMenu()}
>
${MoreVerticalIcon}
</editor-icon-button>
</editor-toolbar>
`;
}
@query('.code-toolbar-button.more')
private accessor _moreButton!: EditorIconButton;
@state()
private accessor _moreMenuOpen = false;
@property({ attribute: false })
accessor context!: CodeBlockToolbarContext;
@property({ attribute: false })
accessor moreGroups!: MenuItemGroup<CodeBlockToolbarContext>[];
@property({ attribute: false })
accessor onActiveStatusChange: (active: boolean) => void = noop;
@property({ attribute: false })
accessor primaryGroups!: MenuItemGroup<CodeBlockToolbarContext>[];
}
@@ -0,0 +1,154 @@
import { ArrowDownIcon } from '@blocksuite/affine-components/icons';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { noop, SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import { css, LitElement, nothing } from 'lit';
import { property, query } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import { html } from 'lit/static-html.js';
import {
type FilterableListItem,
type FilterableListOptions,
showPopFilterableList,
} from '../../../../_common/components/filterable-list/index.js';
import type { CodeBlockComponent } from '../../../../code-block/code-block.js';
export class LanguageListButton extends WithDisposable(
SignalWatcher(LitElement)
) {
static override styles = css`
.lang-button {
background-color: var(--affine-background-primary-color);
box-shadow: var(--affine-shadow-1);
display: flex;
gap: 4px;
padding: 2px 4px;
}
.lang-button:hover {
background: var(--affine-hover-color-filled);
}
.lang-button[hover] {
background: var(--affine-hover-color-filled);
}
.lang-button-icon {
display: flex;
align-items: center;
color: ${unsafeCSSVarV2('icon/primary')};
svg {
height: 16px;
width: 16px;
}
}
`;
private _abortController?: AbortController;
private _clickLangBtn = () => {
if (this.blockComponent.doc.readonly) return;
if (this._abortController) {
// Close the language list if it's already opened.
this._abortController.abort();
return;
}
this._abortController = new AbortController();
this._abortController.signal.addEventListener('abort', () => {
this.onActiveStatusChange(false);
this._abortController = undefined;
});
this.onActiveStatusChange(true);
const options: FilterableListOptions = {
placeholder: 'Search for a language',
onSelect: item => {
const sortedBundledLanguages = this._sortedBundledLanguages;
const index = sortedBundledLanguages.indexOf(item);
if (index !== -1) {
sortedBundledLanguages.splice(index, 1);
sortedBundledLanguages.unshift(item);
}
this.blockComponent.doc.transact(() => {
this.blockComponent.model.language$.value = item.name;
});
},
active: item => item.name === this.blockComponent.model.language,
items: this._sortedBundledLanguages,
};
showPopFilterableList({
options,
referenceElement: this._langButton,
container: this.blockComponent.host,
abortController: this._abortController,
// stacking-context(editor-host)
portalStyles: {
zIndex: 'var(--affine-z-index-popover)',
},
});
};
private _sortedBundledLanguages: FilterableListItem[] = [];
override connectedCallback(): void {
super.connectedCallback();
const langList = localStorage.getItem('blocksuite:code-block:lang-list');
if (langList) {
this._sortedBundledLanguages = JSON.parse(langList);
} else {
this._sortedBundledLanguages = this.blockComponent.service.langs.map(
lang => ({
label: lang.name,
name: lang.id,
aliases: lang.aliases,
})
);
}
this.disposables.add(() => {
localStorage.setItem(
'blocksuite:code-block:lang-list',
JSON.stringify(this._sortedBundledLanguages)
);
});
}
override render() {
const textStyles = styleMap({
fontFamily: 'Inter',
fontSize: 'var(--affine-font-xs)',
fontStyle: 'normal',
fontWeight: '500',
lineHeight: '20px',
padding: '0 4px',
});
return html`<icon-button
class="lang-button"
data-testid="lang-button"
width="auto"
.text=${html`<div style=${textStyles}>
${this.blockComponent.languageName$.value}
</div>`}
height="24px"
@click=${this._clickLangBtn}
?disabled=${this.blockComponent.doc.readonly}
>
<span class="lang-button-icon" slot="suffix">
${!this.blockComponent.doc.readonly ? ArrowDownIcon : nothing}
</span>
</icon-button> `;
}
@query('.lang-button')
private accessor _langButton!: HTMLElement;
@property({ attribute: false })
accessor blockComponent!: CodeBlockComponent;
@property({ attribute: false })
accessor onActiveStatusChange: (active: boolean) => void = noop;
}
@@ -0,0 +1,177 @@
import {
CancelWrapIcon,
CaptionIcon,
CopyIcon,
DeleteIcon,
DuplicateIcon,
WrapIcon,
} from '@blocksuite/affine-components/icons';
import type { MenuItemGroup } from '@blocksuite/affine-components/toolbar';
import { isInsidePageEditor } from '@blocksuite/affine-shared/utils';
import { noop, sleep } from '@blocksuite/global/utils';
import { html } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.js';
import type { CodeBlockToolbarContext } from './context.js';
import { duplicateCodeBlock } from './utils.js';
export const PRIMARY_GROUPS: MenuItemGroup<CodeBlockToolbarContext>[] = [
{
type: 'primary',
items: [
{
type: 'change-lang',
generate: ({ blockComponent, setActive }) => {
const state = { active: false };
return {
action: noop,
render: () =>
html`<language-list-button
.blockComponent=${blockComponent}
.onActiveStatusChange=${async (active: boolean) => {
state.active = active;
if (!active) {
await sleep(1000);
if (state.active) return;
}
setActive(active);
}}
>
</language-list-button>`,
};
},
},
{
type: 'copy-code',
label: 'Copy code',
icon: CopyIcon,
generate: ({ blockComponent }) => {
return {
action: () => {
blockComponent.copyCode();
},
render: item => html`
<editor-icon-button
class="code-toolbar-button copy-code"
aria-label=${ifDefined(item.label)}
.tooltip=${item.label}
.tooltipOffset=${4}
.iconSize=${'16px'}
.iconContainerPadding=${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="code-toolbar-button caption"
aria-label=${ifDefined(item.label)}
.tooltip=${item.label}
.tooltipOffset=${4}
.iconSize=${'16px'}
.iconContainerPadding=${4}
@click=${(e: MouseEvent) => {
e.stopPropagation();
item.action();
}}
>
${item.icon}
</editor-icon-button>
`,
};
},
},
],
},
];
// Clipboard Group
export const clipboardGroup: MenuItemGroup<CodeBlockToolbarContext> = {
type: 'clipboard',
items: [
{
type: 'wrap',
generate: ({ blockComponent, close }) => {
const wrapped = blockComponent.model.wrap;
const label = wrapped ? 'Cancel wrap' : 'Wrap';
const icon = wrapped ? CancelWrapIcon : WrapIcon;
return {
label,
icon,
action: () => {
blockComponent.setWrap(!wrapped);
close();
},
};
},
},
{
type: 'duplicate',
label: 'Duplicate',
icon: DuplicateIcon,
when: ({ doc }) => !doc.readonly,
action: ({ host, blockComponent, close }) => {
const codeId = duplicateCodeBlock(blockComponent.model);
host.updateComplete
.then(() => {
host.selection.setGroup('note', [
host.selection.create('block', {
blockId: codeId,
}),
]);
if (isInsidePageEditor(host)) {
const duplicateElement = host.view.getBlock(codeId);
if (duplicateElement) {
duplicateElement.scrollIntoView({ block: 'nearest' });
}
}
})
.catch(console.error);
close();
},
},
],
};
// Delete Group
export const deleteGroup: MenuItemGroup<CodeBlockToolbarContext> = {
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<CodeBlockToolbarContext>[] = [
clipboardGroup,
deleteGroup,
];
@@ -0,0 +1,45 @@
import type { CodeBlockComponent } from '../../../code-block/code-block.js';
import { MenuContext } from '../../configs/toolbar.js';
export class CodeBlockToolbarContext 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.blockComponent.std;
}
constructor(
public blockComponent: CodeBlockComponent,
public abortController: AbortController,
public setActive: (active: boolean) => void
) {
super();
}
isEmpty() {
return false;
}
isMultiple() {
return false;
}
isSingle() {
return true;
}
}
@@ -0,0 +1,20 @@
import { AffineCodeToolbar } from './components/code-toolbar.js';
import { LanguageListButton } from './components/lang-button.js';
import {
AFFINE_CODE_TOOLBAR_WIDGET,
AffineCodeToolbarWidget,
} from './index.js';
export function effects() {
customElements.define('language-list-button', LanguageListButton);
customElements.define('affine-code-toolbar', AffineCodeToolbar);
customElements.define(AFFINE_CODE_TOOLBAR_WIDGET, AffineCodeToolbarWidget);
}
declare global {
interface HTMLElementTagNameMap {
'language-list-button': LanguageListButton;
'affine-code-toolbar': AffineCodeToolbar;
[AFFINE_CODE_TOOLBAR_WIDGET]: AffineCodeToolbarWidget;
}
}
@@ -0,0 +1,157 @@
import { HoverController } from '@blocksuite/affine-components/hover';
import type {
AdvancedMenuItem,
MenuItemGroup,
} from '@blocksuite/affine-components/toolbar';
import { cloneGroups } from '@blocksuite/affine-components/toolbar';
import type { CodeBlockModel } from '@blocksuite/affine-model';
import { WidgetComponent } from '@blocksuite/block-std';
import { limitShift, shift } from '@floating-ui/dom';
import { html } from 'lit';
import { PAGE_HEADER_HEIGHT } from '../../../_common/consts.js';
import type { CodeBlockComponent } from '../../../code-block/code-block.js';
import { getMoreMenuConfig } from '../../configs/toolbar.js';
import { MORE_GROUPS, PRIMARY_GROUPS } from './config.js';
import { CodeBlockToolbarContext } from './context.js';
export const AFFINE_CODE_TOOLBAR_WIDGET = 'affine-code-toolbar-widget';
export class AffineCodeToolbarWidget extends WidgetComponent<
CodeBlockModel,
CodeBlockComponent
> {
private _hoverController: HoverController | null = null;
private _isActivated = false;
private _setHoverController = () => {
this._hoverController = null;
this._hoverController = new HoverController(
this,
({ abortController }) => {
const codeBlock = this.block;
const selection = this.host.selection;
const textSelection = selection.find('text');
if (
!!textSelection &&
(!!textSelection.to || !!textSelection.from.length)
) {
return null;
}
const blockSelections = selection.filter('block');
if (
blockSelections.length > 1 ||
(blockSelections.length === 1 &&
blockSelections[0].blockId !== codeBlock.blockId)
) {
return null;
}
const setActive = (active: boolean) => {
this._isActivated = active;
if (!active && !this._hoverController?.isHovering) {
this._hoverController?.abort();
}
};
const context = new CodeBlockToolbarContext(
codeBlock,
abortController,
setActive
);
return {
template: html`<affine-code-toolbar
.context=${context}
.primaryGroups=${this.primaryGroups}
.moreGroups=${this.moreGroups}
.onActiveStatusChange=${setActive}
></affine-code-toolbar>`,
container: this.block,
// stacking-context(editor-host)
portalStyles: {
zIndex: 'var(--affine-z-index-popover)',
},
computePosition: {
referenceElement: codeBlock,
placement: 'right-start',
middleware: [
shift({
crossAxis: true,
padding: {
top: PAGE_HEADER_HEIGHT + 12,
bottom: 12,
right: 12,
},
limiter: limitShift(),
}),
],
autoUpdate: true,
},
};
},
{ allowMultiple: true }
);
const codeBlock = this.block;
this._hoverController.setReference(codeBlock);
this._hoverController.onAbort = () => {
// If the more menu is opened, don't close it.
if (this._isActivated) return;
this._hoverController?.abort();
return;
};
};
addMoretems = (
items: AdvancedMenuItem<CodeBlockToolbarContext>[],
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<CodeBlockToolbarContext>[],
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.
*/
protected moreGroups: MenuItemGroup<CodeBlockToolbarContext>[] =
cloneGroups(MORE_GROUPS);
protected primaryGroups: MenuItemGroup<CodeBlockToolbarContext>[] =
cloneGroups(PRIMARY_GROUPS);
override firstUpdated() {
this.moreGroups = getMoreMenuConfig(this.std).configure(this.moreGroups);
this._setHoverController();
}
}
@@ -0,0 +1,16 @@
import type { CodeBlockModel } from '@blocksuite/affine-model';
export const duplicateCodeBlock = (model: CodeBlockModel) => {
const keys = model.keys as (keyof typeof model)[];
const values = keys.map(key => model[key]);
const blockProps = Object.fromEntries(keys.map((key, i) => [key, values[i]]));
const { text: _text, ...duplicateProps } = blockProps;
const newProps = {
flavour: model.flavour,
text: model.text.clone(),
...duplicateProps,
};
return model.doc.addSiblingBlocks(model, [newProps])[0];
};
@@ -0,0 +1,5 @@
import type { BlockModel } from '@blocksuite/store';
export type DocRemoteSelectionConfig = {
blockSelectionBackgroundTransparent: (block: BlockModel) => boolean;
};
@@ -0,0 +1,316 @@
import { matchFlavours } from '@blocksuite/affine-shared/utils';
import {
type BaseSelection,
BlockSelection,
TextSelection,
WidgetComponent,
} from '@blocksuite/block-std';
import { assertExists } from '@blocksuite/global/utils';
import type { UserInfo } from '@blocksuite/store';
import { computed } from '@preact/signals-core';
import { css, html, nothing } from 'lit';
import { styleMap } from 'lit/directives/style-map.js';
import { RemoteColorManager } from '../../../root-block/remote-color-manager/remote-color-manager.js';
import type { DocRemoteSelectionConfig } from './config.js';
import { cursorStyle, selectionStyle } from './utils.js';
export interface SelectionRect {
width: number;
height: number;
top: number;
left: number;
transparent?: boolean;
}
export const AFFINE_DOC_REMOTE_SELECTION_WIDGET =
'affine-doc-remote-selection-widget';
export class AffineDocRemoteSelectionWidget extends WidgetComponent {
// avoid being unable to select text by mouse click or drag
static override styles = css`
:host {
pointer-events: none;
}
`;
private _abortController = new AbortController();
private _remoteColorManager: RemoteColorManager | null = null;
private _remoteSelections = computed(() => {
const status = this.doc.awarenessStore.getStates();
return [...this.std.selection.remoteSelections.entries()].map(
([id, selections]) => {
return {
id,
selections,
user: status.get(id)?.user,
};
}
);
});
private _resizeObserver: ResizeObserver = new ResizeObserver(() => {
this.requestUpdate();
});
private get _config(): DocRemoteSelectionConfig {
const config =
this.std.getConfig('affine:page')?.docRemoteSelectionWidget ?? {};
return {
blockSelectionBackgroundTransparent: block => {
return (
matchFlavours(block, [
'affine:code',
'affine:database',
'affine:image',
'affine:attachment',
'affine:bookmark',
'affine:surface-ref',
]) || /affine:embed-*/.test(block.flavour)
);
},
...config,
};
}
private get _container() {
return this.offsetParent;
}
private get _containerRect() {
return this.offsetParent?.getBoundingClientRect();
}
private get _selectionManager() {
return this.host.selection;
}
private _getCursorRect(selections: BaseSelection[]): SelectionRect | null {
if (this.block.model.flavour !== 'affine:page') {
console.error('remote selection widget must be used in page component');
return null;
}
const textSelection = selections.find(
selection => selection instanceof TextSelection
) as TextSelection | undefined;
const blockSelections = selections.filter(
selection => selection instanceof BlockSelection
);
const container = this._container;
const containerRect = this._containerRect;
if (textSelection) {
const range = this.std.range.textSelectionToRange(
this._selectionManager.create('text', {
from: {
blockId: textSelection.to
? textSelection.to.blockId
: textSelection.from.blockId,
index: textSelection.to
? textSelection.to.index + textSelection.to.length
: textSelection.from.index + textSelection.from.length,
length: 0,
},
to: null,
})
);
if (!range) {
return null;
}
const container = this._container;
const containerRect = this._containerRect;
const rangeRects = Array.from(range.getClientRects());
if (rangeRects.length > 0) {
const rect =
rangeRects.length === 1
? rangeRects[0]
: rangeRects[rangeRects.length - 1];
return {
width: 2,
height: rect.height,
top:
rect.top - (containerRect?.top ?? 0) + (container?.scrollTop ?? 0),
left:
rect.left -
(containerRect?.left ?? 0) +
(container?.scrollLeft ?? 0),
};
}
} else if (blockSelections.length > 0) {
const lastBlockSelection = blockSelections[blockSelections.length - 1];
const block = this.host.view.getBlock(lastBlockSelection.blockId);
if (block) {
const rect = block.getBoundingClientRect();
return {
width: 2,
height: rect.height,
top:
rect.top - (containerRect?.top ?? 0) + (container?.scrollTop ?? 0),
left:
rect.left +
rect.width -
(containerRect?.left ?? 0) +
(container?.scrollLeft ?? 0),
};
}
}
return null;
}
private _getSelectionRect(selections: BaseSelection[]): SelectionRect[] {
if (this.block.model.flavour !== 'affine:page') {
console.error('remote selection widget must be used in page component');
return [];
}
const textSelection = selections.find(
selection => selection instanceof TextSelection
) as TextSelection | undefined;
const blockSelections = selections.filter(
selection => selection instanceof BlockSelection
);
if (!textSelection && !blockSelections.length) return [];
const { selectionRects } = this.std.command.exec('getSelectionRects', {
textSelection,
blockSelections,
});
if (!selectionRects) return [];
return selectionRects.map(({ blockId, ...rect }) => {
if (!blockId) return rect;
const block = this.host.view.getBlock(blockId);
if (!block) return rect;
const isTransparent = this._config.blockSelectionBackgroundTransparent(
block.model
);
return {
...rect,
transparent: isTransparent,
};
});
}
override connectedCallback() {
super.connectedCallback();
this.handleEvent('wheel', () => {
this.requestUpdate();
});
this.disposables.addFromEvent(window, 'resize', () => {
this.requestUpdate();
});
this._remoteColorManager = new RemoteColorManager(this.std);
}
override disconnectedCallback() {
super.disconnectedCallback();
this._resizeObserver.disconnect();
this._abortController.abort();
}
override render() {
if (this._remoteSelections.value.length === 0) {
return nothing;
}
const remoteUsers = new Set<number>();
const selections: Array<{
id: number;
selections: BaseSelection[];
rects: SelectionRect[];
user?: UserInfo;
}> = this._remoteSelections.value.flatMap(({ selections, id, user }) => {
if (remoteUsers.has(id)) {
return [];
} else {
remoteUsers.add(id);
}
return {
id,
selections,
rects: this._getSelectionRect(selections),
user,
};
});
const remoteColorManager = this._remoteColorManager;
assertExists(remoteColorManager);
return html`<div>
${selections.flatMap(selection => {
const color = remoteColorManager.get(selection.id);
if (!color) return [];
const cursorRect = this._getCursorRect(selection.selections);
return selection.rects
.map(r => html`<div style="${selectionStyle(r, color)}"></div>`)
.concat([
html`
<div
style="${cursorRect
? cursorStyle(cursorRect, color)
: styleMap({
display: 'none',
})}"
>
<div
style="${styleMap({
position: 'relative',
height: '100%',
})}"
>
<div
style="${styleMap({
position: 'absolute',
left: '-4px',
bottom: `${
cursorRect?.height ? cursorRect.height - 4 : 0
}px`,
backgroundColor: color,
color: 'white',
maxWidth: '160px',
padding: '0 3px',
border: '1px solid var(--affine-pure-black-20)',
boxShadow: '0px 1px 6px 0px rgba(0, 0, 0, 0.16)',
borderRadius: '4px',
fontSize: '12px',
lineHeight: '18px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
display: selection.user ? 'block' : 'none',
})}"
>
${selection.user?.name}
</div>
</div>
</div>
`,
]);
})}
</div>`;
}
}
declare global {
interface HTMLElementTagNameMap {
[AFFINE_DOC_REMOTE_SELECTION_WIDGET]: AffineDocRemoteSelectionWidget;
}
}
@@ -0,0 +1,2 @@
export * from './config.js';
export * from './doc-remote-selection.js';
@@ -0,0 +1,36 @@
import type { DirectiveResult } from 'lit/directive.js';
import { styleMap, type StyleMapDirective } from 'lit/directives/style-map.js';
import type { SelectionRect } from './doc-remote-selection.js';
export function selectionStyle(
rect: SelectionRect,
color: string
): DirectiveResult<typeof StyleMapDirective> {
return styleMap({
position: 'absolute',
width: `${rect.width}px`,
height: `${rect.height}px`,
top: `${rect.top}px`,
left: `${rect.left}px`,
backgroundColor: rect.transparent ? 'transparent' : color,
pointerEvent: 'none',
opacity: '20%',
borderRadius: '3px',
});
}
export function cursorStyle(
rect: SelectionRect,
color: string
): DirectiveResult<typeof StyleMapDirective> {
return styleMap({
position: 'absolute',
width: `${rect.width}px`,
height: `${rect.height}px`,
top: `${rect.top}px`,
left: `${rect.left}px`,
backgroundColor: color,
pointerEvent: 'none',
});
}
@@ -0,0 +1,63 @@
import type { EditorHost } from '@blocksuite/block-std';
import { ShadowlessElement } from '@blocksuite/block-std';
import { Point } from '@blocksuite/global/utils';
import { baseTheme } from '@toeverything/theme';
import type { TemplateResult } from 'lit';
import { html } from 'lit';
import { property } from 'lit/decorators.js';
export class DragPreview extends ShadowlessElement {
offset: Point;
constructor(offset?: Point) {
super();
this.offset = offset ?? new Point(0, 0);
}
override disconnectedCallback() {
if (this.onRemove) {
this.onRemove();
}
super.disconnectedCallback();
}
override render() {
return html`<style>
affine-drag-preview {
box-sizing: border-box;
position: absolute;
display: block;
height: auto;
font-family: ${baseTheme.fontSansFamily};
font-size: var(--affine-font-base);
line-height: var(--affine-line-height);
color: var(--affine-text-primary-color);
font-weight: 400;
top: 0;
left: 0;
transform-origin: 0 0;
opacity: 0.5;
user-select: none;
pointer-events: none;
caret-color: transparent;
z-index: 3;
}
.affine-drag-preview-grabbing * {
cursor: grabbing !important;
}</style
>${this.template}`;
}
@property({ attribute: false })
accessor onRemove: (() => void) | null = null;
@property({ attribute: false })
accessor template: TemplateResult | EditorHost | null = null;
}
declare global {
interface HTMLElementTagNameMap {
'affine-drag-preview': DragPreview;
}
}
@@ -0,0 +1,45 @@
import type { Rect } from '@blocksuite/global/utils';
import { css, html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
export class DropIndicator extends LitElement {
static override styles = css`
.affine-drop-indicator {
position: absolute;
top: 0;
left: 0;
background: var(--affine-primary-color);
transition-property: height, transform;
transition-duration: 100ms;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-delay: 0s;
transform-origin: 0 0;
pointer-events: none;
z-index: 2;
}
`;
override render() {
if (!this.rect) {
return null;
}
const { left, top, width, height } = this.rect;
const style = styleMap({
width: `${width}px`,
height: `${height}px`,
top: `${top}px`,
left: `${left}px`,
});
return html`<div class="affine-drop-indicator" style=${style}></div>`;
}
@property({ attribute: false })
accessor rect: Rect | null = null;
}
declare global {
interface HTMLElementTagNameMap {
'affine-drop-indicator': DropIndicator;
}
}
@@ -0,0 +1,79 @@
import type {
DragHandleOption,
DropType,
} from '@blocksuite/affine-shared/services';
import type { Disposable, Rect } from '@blocksuite/global/utils';
export const DRAG_HANDLE_CONTAINER_HEIGHT = 24;
export const DRAG_HANDLE_CONTAINER_WIDTH = 16;
export const DRAG_HANDLE_CONTAINER_WIDTH_TOP_LEVEL = 8;
export const DRAG_HANDLE_CONTAINER_OFFSET_LEFT = 2;
export const DRAG_HANDLE_CONTAINER_OFFSET_LEFT_LIST = 18;
export const DRAG_HANDLE_CONTAINER_OFFSET_LEFT_TOP_LEVEL = 5;
export const DRAG_HANDLE_CONTAINER_PADDING = 8;
export const DRAG_HANDLE_GRABBER_HEIGHT = 12;
export const DRAG_HANDLE_GRABBER_WIDTH = 4;
export const DRAG_HANDLE_GRABBER_WIDTH_HOVERED = 2;
export const DRAG_HANDLE_GRABBER_BORDER_RADIUS = 4;
export const DRAG_HANDLE_GRABBER_MARGIN = 4;
export const HOVER_AREA_RECT_PADDING_TOP_LEVEL = 6;
export const NOTE_CONTAINER_PADDING = 24;
export const EDGELESS_NOTE_EXTRA_PADDING = 20;
export const DRAG_HOVER_RECT_PADDING = 4;
export type DropResult = {
rect: Rect | null;
dropBlockId: string;
dropType: DropType;
};
export class DragHandleOptionsRunner {
private optionMap = new Map<DragHandleOption, number>();
get options(): DragHandleOption[] {
return Array.from(this.optionMap.keys());
}
private _decreaseOptionCount(option: DragHandleOption) {
const count = this.optionMap.get(option) || 0;
if (count > 1) {
this.optionMap.set(option, count - 1);
} else {
this.optionMap.delete(option);
}
}
private _getExistingOptionWithSameFlavour(
option: DragHandleOption
): DragHandleOption | undefined {
return Array.from(this.optionMap.keys()).find(
op => op.flavour === option.flavour
);
}
getOption(flavour: string): DragHandleOption | undefined {
return this.options.find(option => {
if (typeof option.flavour === 'string') {
return option.flavour === flavour;
} else {
return option.flavour.test(flavour);
}
});
}
register(option: DragHandleOption): Disposable {
const currentOption =
this._getExistingOptionWithSameFlavour(option) || option;
const count = this.optionMap.get(currentOption) || 0;
this.optionMap.set(currentOption, count + 1);
return {
dispose: () => {
this._decreaseOptionCount(currentOption);
},
};
}
}
@@ -0,0 +1 @@
export const AFFINE_DRAG_HANDLE_WIDGET = 'affine-drag-handle-widget';
@@ -0,0 +1,464 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import type { RootBlockModel } from '@blocksuite/affine-model';
import {
DocModeProvider,
DragHandleConfigIdentifier,
type DropType,
} from '@blocksuite/affine-shared/services';
import {
getScrollContainer,
isInsideEdgelessEditor,
isInsidePageEditor,
matchFlavours,
} from '@blocksuite/affine-shared/utils';
import {
type BlockComponent,
type DndEventState,
WidgetComponent,
} from '@blocksuite/block-std';
import type { GfxBlockElementModel } from '@blocksuite/block-std/gfx';
import type { IVec } from '@blocksuite/global/utils';
import { DisposableGroup, Point, Rect } from '@blocksuite/global/utils';
import { computed, type ReadonlySignal, signal } from '@preact/signals-core';
import { html } from 'lit';
import { query, state } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import { isTopLevelBlock } from '../../../root-block/edgeless/utils/query.js';
import { autoScroll } from '../../../root-block/text-selection/utils.js';
import type { EdgelessRootService } from '../../edgeless/index.js';
import type { DragPreview } from './components/drag-preview.js';
import type { DropIndicator } from './components/drop-indicator.js';
import type { DropResult } from './config.js';
import { DragHandleOptionsRunner } from './config.js';
import type { AFFINE_DRAG_HANDLE_WIDGET } from './consts.js';
import { PreviewHelper } from './helpers/preview-helper.js';
import { RectHelper } from './helpers/rect-helper.js';
import { SelectionHelper } from './helpers/selection-helper.js';
import { styles } from './styles.js';
import {
calcDropTarget,
containBlock,
containChildBlock,
getClosestBlockByPoint,
getClosestNoteBlock,
isOutOfNoteBlock,
updateDragHandleClassName,
} from './utils.js';
import { DragEventWatcher } from './watchers/drag-event-watcher.js';
import { EdgelessWatcher } from './watchers/edgeless-watcher.js';
import { HandleEventWatcher } from './watchers/handle-event-watcher.js';
import { KeyboardEventWatcher } from './watchers/keyboard-event-watcher.js';
import { LegacyDragEventWatcher } from './watchers/legacy-drag-event-watcher.js';
import { PageWatcher } from './watchers/page-watcher.js';
import { PointerEventWatcher } from './watchers/pointer-event-watcher.js';
export class AffineDragHandleWidget extends WidgetComponent<RootBlockModel> {
static override styles = styles;
private _anchorModelDisposables: DisposableGroup | null = null;
private _dragEventWatcher = new DragEventWatcher(this);
private _getBlockView = (blockId: string) => {
return this.host.view.getBlock(blockId);
};
/**
* When dragging, should update indicator position and target drop block id
*/
private _getDropResult = (state: DndEventState): DropResult | null => {
const point = new Point(state.raw.x, state.raw.y);
const closestBlock = getClosestBlockByPoint(
this.host,
this.rootComponent,
point
);
if (!closestBlock) return null;
const blockId = closestBlock.model.id;
const model = closestBlock.model;
const isDatabase = matchFlavours(model, ['affine:database']);
if (isDatabase) return null;
// note block can only be dropped into another note block
// prevent note block from being dropped into other blocks
const isDraggedElementNote =
this.draggingElements.length === 1 &&
matchFlavours(this.draggingElements[0].model, ['affine:note']);
if (isDraggedElementNote) {
const parent = this.std.doc.getParent(closestBlock.model);
if (!parent) return null;
const parentElement = this._getBlockView(parent.id);
if (!parentElement) return null;
if (!matchFlavours(parentElement.model, ['affine:note'])) return null;
}
// Should make sure that target drop block is
// neither within the dragging elements
// nor a child-block of any dragging elements
if (
containBlock(
this.draggingElements.map(block => block.model.id),
blockId
) ||
containChildBlock(this.draggingElements, model)
) {
return null;
}
let rect = null;
let dropType: DropType = 'before';
const result = calcDropTarget(
point,
model,
closestBlock,
this.draggingElements,
this.scale.peek(),
isDraggedElementNote === false
);
if (result) {
rect = result.rect;
dropType = result.dropType;
}
if (isDraggedElementNote && dropType === 'in') return null;
const dropIndicator = {
rect,
dropBlockId: blockId,
dropType,
};
return dropIndicator;
};
private _handleEventWatcher = new HandleEventWatcher(this);
private _keyboardEventWatcher = new KeyboardEventWatcher(this);
private _legacyDragEventWatcher = new LegacyDragEventWatcher(this);
private _pageWatcher = new PageWatcher(this);
private _removeDropIndicator = () => {
if (this.dropIndicator) {
this.dropIndicator.remove();
this.dropIndicator = null;
}
};
private _reset = () => {
this.draggingElements = [];
this.dropBlockId = '';
this.dropType = null;
this.lastDragPointerState = null;
this.rafID = 0;
this.dragging = false;
this.dragHoverRect = null;
this.anchorBlockId.value = null;
this.isDragHandleHovered = false;
this.isHoverDragHandleVisible = false;
this.isTopLevelDragHandleVisible = false;
this.pointerEventWatcher.reset();
this.previewHelper.removeDragPreview();
this._removeDropIndicator();
this._resetCursor();
};
private _resetCursor = () => {
document.documentElement.classList.remove('affine-drag-preview-grabbing');
};
private _resetDropResult = () => {
this.dropBlockId = '';
this.dropType = null;
if (this.dropIndicator) this.dropIndicator.rect = null;
};
private _updateDropResult = (dropResult: DropResult | null) => {
if (!this.dropIndicator) return;
this.dropBlockId = dropResult?.dropBlockId ?? '';
this.dropType = dropResult?.dropType ?? null;
if (dropResult?.rect) {
const offsetParentRect =
this.dragHandleContainerOffsetParent.getBoundingClientRect();
let { left, top } = dropResult.rect;
left -= offsetParentRect.left;
top -= offsetParentRect.top;
const { width, height } = dropResult.rect;
const rect = Rect.fromLWTH(left, width, top, height);
this.dropIndicator.rect = rect;
} else {
this.dropIndicator.rect = dropResult?.rect ?? null;
}
};
anchorBlockId = signal<string | null>(null);
anchorBlockComponent = computed<BlockComponent | null>(() => {
if (!this.anchorBlockId.value) return null;
return this.std.view.getBlock(this.anchorBlockId.value);
});
anchorEdgelessElement: ReadonlySignal<GfxBlockElementModel | null> = computed(
() => {
if (!this.anchorBlockId.value) return null;
if (this.mode === 'page') return null;
const service = this.std.getService('affine:page') as EdgelessRootService;
const edgelessElement = service.getElementById(this.anchorBlockId.value);
return isTopLevelBlock(edgelessElement) ? edgelessElement : null;
}
);
// Single block: drag handle should show on the vertical middle of the first line of element
center: IVec = [0, 0];
dragging = false;
rectHelper = new RectHelper(this);
draggingAreaRect: ReadonlySignal<Rect | null> = computed(
this.rectHelper.getDraggingAreaRect
);
draggingElements: BlockComponent[] = [];
dragPreview: DragPreview | null = null;
dropBlockId = '';
dropIndicator: DropIndicator | null = null;
dropType: DropType | null = null;
edgelessWatcher = new EdgelessWatcher(this);
handleAnchorModelDisposables = () => {
const block = this.anchorBlockComponent.peek();
if (!block) return;
const blockModel = block.model;
if (this._anchorModelDisposables) {
this._anchorModelDisposables.dispose();
this._anchorModelDisposables = null;
}
this._anchorModelDisposables = new DisposableGroup();
this._anchorModelDisposables.add(
blockModel.propsUpdated.on(() => this.hide())
);
this._anchorModelDisposables.add(blockModel.deleted.on(() => this.hide()));
};
hide = (force = false) => {
if (this.dragging && !force) return;
updateDragHandleClassName();
this.isHoverDragHandleVisible = false;
this.isTopLevelDragHandleVisible = false;
this.isDragHandleHovered = false;
this.anchorBlockId.value = null;
if (this.dragHandleContainer) {
this.dragHandleContainer.style.display = 'none';
}
if (force) {
this._reset();
}
};
isDragHandleHovered = false;
isHoverDragHandleVisible = false;
isTopLevelDragHandleVisible = false;
lastDragPointerState: DndEventState | null = null;
noteScale = signal(1);
readonly optionRunner = new DragHandleOptionsRunner();
pointerEventWatcher = new PointerEventWatcher(this);
previewHelper = new PreviewHelper(this);
rafID = 0;
scale = signal(1);
scaleInNote = computed(() => this.scale.value * this.noteScale.value);
selectionHelper = new SelectionHelper(this);
updateDropIndicator = (
state: DndEventState,
shouldAutoScroll: boolean = false
) => {
const point = new Point(state.raw.x, state.raw.y);
const closestNoteBlock = getClosestNoteBlock(
this.host,
this.rootComponent,
point
);
if (
!closestNoteBlock ||
isOutOfNoteBlock(this.host, closestNoteBlock, point, this.scale.peek())
) {
this._resetDropResult();
} else {
const dropResult = this._getDropResult(state);
this._updateDropResult(dropResult);
}
this.lastDragPointerState = state;
if (this.mode === 'page') {
if (!shouldAutoScroll) return;
const scrollContainer = getScrollContainer(this.rootComponent);
const result = autoScroll(scrollContainer, state.raw.y);
if (!result) {
this.clearRaf();
return;
}
this.rafID = requestAnimationFrame(() =>
this.updateDropIndicator(state, true)
);
} else {
this.clearRaf();
}
};
updateDropIndicatorOnScroll = () => {
if (
!this.dragging ||
this.draggingElements.length === 0 ||
!this.lastDragPointerState
)
return;
const state = this.lastDragPointerState;
this.rafID = requestAnimationFrame(() =>
this.updateDropIndicator(state, false)
);
};
private get _enableNewDnd() {
return this.std.doc.awarenessStore.getFlag('enable_new_dnd') ?? true;
}
get dragHandleContainerOffsetParent() {
return this.dragHandleContainer.parentElement!;
}
get mode() {
return this.std.get(DocModeProvider).getEditorMode();
}
get rootComponent() {
return this.block;
}
clearRaf() {
if (this.rafID) {
cancelAnimationFrame(this.rafID);
this.rafID = 0;
}
}
override connectedCallback() {
super.connectedCallback();
this.std.provider.getAll(DragHandleConfigIdentifier).forEach(config => {
this.optionRunner.register(config);
});
this.pointerEventWatcher.watch();
this._keyboardEventWatcher.watch();
if (this._enableNewDnd) {
this._dragEventWatcher.watch();
} else {
this._legacyDragEventWatcher.watch();
}
}
override disconnectedCallback() {
this.hide(true);
this._disposables.dispose();
this._anchorModelDisposables?.dispose();
super.disconnectedCallback();
}
override firstUpdated() {
this.hide(true);
this._disposables.addFromEvent(this.host, 'pointerleave', () => {
this.hide();
});
this._handleEventWatcher.watch();
if (isInsidePageEditor(this.host)) {
this._pageWatcher.watch();
} else if (isInsideEdgelessEditor(this.host)) {
this.edgelessWatcher.watch();
}
}
override render() {
const hoverRectStyle = styleMap(
this.dragHoverRect
? {
width: `${this.dragHoverRect.width}px`,
height: `${this.dragHoverRect.height}px`,
top: `${this.dragHoverRect.top}px`,
left: `${this.dragHoverRect.left}px`,
}
: {
display: 'none',
}
);
return html`
<div class="affine-drag-handle-widget">
<div class="affine-drag-handle-container" draggable="true">
<div class="affine-drag-handle-grabber"></div>
</div>
<div class="affine-drag-hover-rect" style=${hoverRectStyle}></div>
</div>
`;
}
@query('.affine-drag-handle-container')
accessor dragHandleContainer!: HTMLDivElement;
@query('.affine-drag-handle-grabber')
accessor dragHandleGrabber!: HTMLDivElement;
@state()
accessor dragHoverRect: {
width: number;
height: number;
left: number;
top: number;
} | null = null;
}
declare global {
interface HTMLElementTagNameMap {
[AFFINE_DRAG_HANDLE_WIDGET]: AffineDragHandleWidget;
}
}
@@ -0,0 +1,118 @@
import { SpecProvider } from '@blocksuite/affine-shared/utils';
import {
type BlockComponent,
BlockStdScope,
type DndEventState,
} from '@blocksuite/block-std';
import { Point } from '@blocksuite/global/utils';
import { BlockViewType, type Query } from '@blocksuite/store';
import { DragPreview } from '../components/drag-preview.js';
import type { AffineDragHandleWidget } from '../drag-handle.js';
export class PreviewHelper {
private _calculatePreviewOffset = (
blocks: BlockComponent[],
state: DndEventState
) => {
const { top, left } = blocks[0].getBoundingClientRect();
const previewOffset = new Point(state.raw.x - left, state.raw.y - top);
return previewOffset;
};
private _calculateQuery = (selectedIds: string[]): Query => {
const ids: Array<{ id: string; viewType: BlockViewType }> = selectedIds.map(
id => ({
id,
viewType: BlockViewType.Display,
})
);
// The ancestors of the selected blocks should be rendered as Bypass
selectedIds.forEach(block => {
let parent: string | null = block;
do {
if (!selectedIds.includes(parent)) {
ids.push({ viewType: BlockViewType.Bypass, id: parent });
}
parent = this.widget.doc.blockCollection.crud.getParent(parent);
} while (parent && !ids.map(({ id }) => id).includes(parent));
});
// The children of the selected blocks should be rendered as Display
const addChildren = (id: string) => {
const children = this.widget.doc.getBlock(id)?.model.children ?? [];
children.forEach(child => {
ids.push({ viewType: BlockViewType.Display, id: child.id });
addChildren(child.id);
});
};
selectedIds.forEach(addChildren);
return {
match: ids,
mode: 'strict',
};
};
createDragPreview = (
blocks: BlockComponent[],
state: DndEventState,
dragPreviewEl?: HTMLElement,
dragPreviewOffset?: Point
): DragPreview => {
if (this.widget.dragPreview) {
this.widget.dragPreview.remove();
}
let dragPreview: DragPreview;
if (dragPreviewEl) {
dragPreview = new DragPreview(dragPreviewOffset);
dragPreview.append(dragPreviewEl);
} else {
let width = 0;
blocks.forEach(element => {
width = Math.max(width, element.getBoundingClientRect().width);
});
const selectedIds = blocks.map(block => block.model.id);
const query = this._calculateQuery(selectedIds);
const doc = this.widget.doc.blockCollection.getDoc({ query });
const previewSpec = SpecProvider.getInstance().getSpec('page:preview');
const previewStd = new BlockStdScope({
doc,
extensions: previewSpec.value,
});
const previewTemplate = previewStd.render();
const offset = this._calculatePreviewOffset(blocks, state);
const posX = state.raw.x - offset.x;
const posY = state.raw.y - offset.y;
const altKey = state.raw.altKey;
dragPreview = new DragPreview(offset);
dragPreview.template = previewTemplate;
dragPreview.onRemove = () => {
this.widget.doc.blockCollection.clearQuery(query);
};
dragPreview.style.width = `${width / this.widget.scaleInNote.peek()}px`;
dragPreview.style.transform = `translate(${posX}px, ${posY}px) scale(${this.widget.scaleInNote.peek()})`;
dragPreview.style.opacity = altKey ? '1' : '0.5';
}
this.widget.rootComponent.append(dragPreview);
return dragPreview;
};
removeDragPreview = () => {
if (this.widget.dragPreview) {
this.widget.dragPreview.remove();
this.widget.dragPreview = null;
}
};
constructor(readonly widget: AffineDragHandleWidget) {}
}
@@ -0,0 +1,96 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { getCurrentNativeRange } from '@blocksuite/affine-shared/utils';
import type { BlockComponent } from '@blocksuite/block-std';
import { Rect } from '@blocksuite/global/utils';
import {
DRAG_HANDLE_CONTAINER_WIDTH,
DRAG_HOVER_RECT_PADDING,
} from '../config.js';
import type { AffineDragHandleWidget } from '../drag-handle.js';
import {
containBlock,
getDragHandleLeftPadding,
includeTextSelection,
} from '../utils.js';
export class RectHelper {
private _getHoveredBlocks = (): BlockComponent[] => {
if (!this.widget.isHoverDragHandleVisible || !this.widget.anchorBlockId)
return [];
const hoverBlock = this.widget.anchorBlockComponent.peek();
if (!hoverBlock) return [];
const selections = this.widget.selectionHelper.selectedBlocks;
let blocks: BlockComponent[] = [];
// When current selection is TextSelection, should cover all the blocks in native range
if (selections.length > 0 && includeTextSelection(selections)) {
const range = getCurrentNativeRange();
if (!range) return [];
const rangeManager = this.widget.std.range;
if (!rangeManager) return [];
blocks = rangeManager.getSelectedBlockComponentsByRange(range, {
match: el => el.model.role === 'content',
mode: 'highest',
});
} else {
blocks = this.widget.selectionHelper.selectedBlockComponents;
}
if (
containBlock(
blocks.map(block => block.blockId),
this.widget.anchorBlockId.peek()!
)
) {
return blocks;
}
return [hoverBlock];
};
getDraggingAreaRect = (): Rect | null => {
const block = this.widget.anchorBlockComponent.value;
if (!block) return null;
// When hover block is in selected blocks, should show hover rect on the selected blocks
// Top: the top of the first selected block
// Left: the left of the first selected block
// Right: the largest right of the selected blocks
// Bottom: the bottom of the last selected block
let { left, top, right, bottom } = block.getBoundingClientRect();
const blocks = this._getHoveredBlocks();
blocks.forEach(block => {
left = Math.min(left, block.getBoundingClientRect().left);
top = Math.min(top, block.getBoundingClientRect().top);
right = Math.max(right, block.getBoundingClientRect().right);
bottom = Math.max(bottom, block.getBoundingClientRect().bottom);
});
const offsetLeft = getDragHandleLeftPadding(blocks);
const offsetParentRect =
this.widget.dragHandleContainerOffsetParent.getBoundingClientRect();
if (!offsetParentRect) return null;
left -= offsetParentRect.left;
right -= offsetParentRect.left;
top -= offsetParentRect.top;
bottom -= offsetParentRect.top;
const scaleInNote = this.widget.scaleInNote.value;
// Add padding to hover rect
left -= (DRAG_HANDLE_CONTAINER_WIDTH + offsetLeft) * scaleInNote;
top -= DRAG_HOVER_RECT_PADDING * scaleInNote;
right += DRAG_HOVER_RECT_PADDING * scaleInNote;
bottom += DRAG_HOVER_RECT_PADDING * scaleInNote;
return new Rect(left, top, right, bottom);
};
constructor(readonly widget: AffineDragHandleWidget) {}
}
@@ -0,0 +1,68 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { findNoteBlockModel } from '@blocksuite/affine-shared/utils';
import type { BlockComponent } from '@blocksuite/block-std';
import type { AffineDragHandleWidget } from '../drag-handle.js';
export class SelectionHelper {
/** Check if given block component is selected */
isBlockSelected = (block?: BlockComponent) => {
if (!block) return false;
return this.selectedBlocks.some(
selection => selection.blockId === block.model.id
);
};
setSelectedBlocks = (blocks: BlockComponent[], noteId?: string) => {
const { selection } = this;
const selections = blocks.map(block =>
selection.create('block', {
blockId: block.blockId,
})
);
// When current page is edgeless page
// We need to remain surface selection and set editing as true
if (this.widget.mode === 'edgeless') {
const surfaceElementId = noteId
? noteId
: findNoteBlockModel(blocks[0].model)?.id;
if (!surfaceElementId) return;
const surfaceSelection = selection.create(
'surface',
blocks[0]!.blockId,
[surfaceElementId],
true
);
selections.push(surfaceSelection);
}
selection.set(selections);
};
get selectedBlockComponents() {
return this.selectedBlocks
.map(block => this.widget.std.view.getBlock(block.blockId))
.filter((block): block is BlockComponent => !!block);
}
get selectedBlockIds() {
return this.selectedBlocks.map(block => block.blockId);
}
get selectedBlocks() {
const selection = this.selection;
// eslint-disable-next-line
return selection.find('text')
? selection.filter('text')
: selection.filter('block');
}
get selection() {
return this.widget.std.selection;
}
constructor(readonly widget: AffineDragHandleWidget) {}
}
@@ -0,0 +1,16 @@
import type { BlockStdScope } from '@blocksuite/block-std';
import type { JobMiddleware } from '@blocksuite/store';
export const newIdCrossDoc =
(std: BlockStdScope): JobMiddleware =>
({ slots, collection }) => {
let samePage = false;
slots.beforeImport.on(payload => {
if (payload.type === 'slice') {
samePage = payload.snapshot.pageId === std.doc.id;
}
if (payload.type === 'block' && !samePage) {
payload.snapshot.id = collection.idGenerator();
}
});
};
@@ -0,0 +1,29 @@
import type { BlockStdScope } from '@blocksuite/block-std';
import type { JobMiddleware } from '@blocksuite/store';
export const surfaceRefToEmbed =
(std: BlockStdScope): JobMiddleware =>
({ slots, collection }) => {
let pageId: string | null = null;
slots.beforeImport.on(payload => {
if (payload.type === 'slice') {
pageId = payload.snapshot.pageId;
}
});
slots.beforeImport.on(payload => {
if (
pageId &&
payload.type === 'block' &&
payload.snapshot.flavour === 'affine:surface-ref' &&
!std.doc.hasBlock(payload.snapshot.id)
) {
const id = payload.snapshot.id;
payload.snapshot.id = collection.idGenerator();
payload.snapshot.flavour = 'affine:embed-linked-doc';
payload.snapshot.props = {
blockId: id,
pageId,
};
}
});
};
@@ -0,0 +1,59 @@
import { css } from 'lit';
import { DRAG_HANDLE_CONTAINER_WIDTH } from './config.js';
export const styles = css`
.affine-drag-handle-widget {
display: flex;
position: absolute;
left: 0;
top: 0;
contain: size layout;
}
.affine-drag-handle-container {
top: 0;
left: 0;
position: absolute;
display: flex;
justify-content: center;
width: ${DRAG_HANDLE_CONTAINER_WIDTH}px;
min-height: 12px;
pointer-events: auto;
user-select: none;
box-sizing: border-box;
}
.affine-drag-handle-container:hover {
cursor: grab;
}
.affine-drag-handle-grabber {
width: 4px;
height: 100%;
border-radius: 1px;
background: var(--affine-placeholder-color);
transition: width 0.25s ease;
}
@media print {
.affine-drag-handle-widget {
display: none;
}
}
.affine-drag-hover-rect {
position: absolute;
top: 0;
left: 0;
border-radius: 6px;
background: var(--affine-hover-color);
pointer-events: none;
z-index: 2;
animation: expand 0.25s forwards;
}
@keyframes expand {
0% {
width: 0;
height: 0;
}
}
`;
@@ -0,0 +1,350 @@
import { ParagraphBlockComponent } from '@blocksuite/affine-block-paragraph';
import type { ParagraphBlockModel } from '@blocksuite/affine-model';
import { BLOCK_CHILDREN_CONTAINER_PADDING_LEFT } from '@blocksuite/affine-shared/consts';
import {
DocModeProvider,
type DropType,
} from '@blocksuite/affine-shared/services';
import {
findClosestBlockComponent,
getBlockProps,
getClosestBlockComponentByElement,
getClosestBlockComponentByPoint,
getRectByBlockComponent,
matchFlavours,
} from '@blocksuite/affine-shared/utils';
import type {
BaseSelection,
BlockComponent,
EditorHost,
} from '@blocksuite/block-std';
import { Point, Rect } from '@blocksuite/global/utils';
import type { BlockModel } from '@blocksuite/store';
import {
getDropRectByPoint,
getHoveringNote,
} from '../../../_common/utils/index.js';
import {
DRAG_HANDLE_CONTAINER_HEIGHT,
DRAG_HANDLE_CONTAINER_OFFSET_LEFT,
DRAG_HANDLE_CONTAINER_OFFSET_LEFT_LIST,
type DropResult,
EDGELESS_NOTE_EXTRA_PADDING,
NOTE_CONTAINER_PADDING,
} from './config.js';
const heightMap: Record<string, number> = {
text: 23,
h1: 40,
h2: 36,
h3: 32,
h4: 32,
h5: 28,
h6: 26,
quote: 46,
list: 24,
database: 28,
image: 28,
divider: 36,
};
export const getDragHandleContainerHeight = (model: BlockModel) => {
const flavour = model.flavour;
const index = flavour.indexOf(':');
let key = flavour.slice(index + 1);
if (key === 'paragraph' && (model as ParagraphBlockModel).type) {
key = (model as ParagraphBlockModel).type;
}
const height = heightMap[key] ?? DRAG_HANDLE_CONTAINER_HEIGHT;
return height;
};
// To check if the block is a child block of the selected blocks
export const containChildBlock = (
blocks: BlockComponent[],
childModel: BlockModel
) => {
return blocks.some(block => {
let currentBlock: BlockModel | null = childModel;
while (currentBlock) {
if (currentBlock.id === block.model.id) {
return true;
}
currentBlock = block.doc.getParent(currentBlock.id);
}
return false;
});
};
export const containBlock = (blockIDs: string[], targetID: string) => {
return blockIDs.some(blockID => blockID === targetID);
};
// TODO: this is a hack, need to find a better way
export const insideDatabaseTable = (element: Element) => {
return !!element.closest('.affine-database-block-table');
};
export const includeTextSelection = (selections: BaseSelection[]) => {
return selections.some(selection => selection.type === 'text');
};
/**
* Check if the path of two blocks are equal
*/
export const isBlockIdEqual = (
id1: string | null | undefined,
id2: string | null | undefined
) => {
if (!id1 || !id2) {
return false;
}
return id1 === id2;
};
export const isOutOfNoteBlock = (
editorHost: EditorHost,
noteBlock: Element,
point: Point,
scale: number
) => {
// TODO: need to find a better way to check if the point is out of note block
const rect = noteBlock.getBoundingClientRect();
const insidePageEditor =
editorHost.std.get(DocModeProvider).getEditorMode() === 'page';
const padding =
(NOTE_CONTAINER_PADDING +
(insidePageEditor ? 0 : EDGELESS_NOTE_EXTRA_PADDING)) *
scale;
return rect
? insidePageEditor
? point.y < rect.top ||
point.y > rect.bottom ||
point.x > rect.right + padding
: point.y < rect.top ||
point.y > rect.bottom ||
point.x < rect.left - padding ||
point.x > rect.right + padding
: true;
};
export const getClosestNoteBlock = (
editorHost: EditorHost,
rootComponent: BlockComponent,
point: Point
) => {
const isInsidePageEditor =
editorHost.std.get(DocModeProvider).getEditorMode() === 'page';
return isInsidePageEditor
? findClosestBlockComponent(rootComponent, point, 'affine-note')
: getHoveringNote(point)?.closest('affine-edgeless-note');
};
export const getClosestBlockByPoint = (
editorHost: EditorHost,
rootComponent: BlockComponent,
point: Point
) => {
const closestNoteBlock = getClosestNoteBlock(
editorHost,
rootComponent,
point
);
if (!closestNoteBlock || closestNoteBlock.closest('.affine-surface-ref')) {
return null;
}
const noteRect = Rect.fromDOM(closestNoteBlock);
const block = getClosestBlockComponentByPoint(point, {
container: closestNoteBlock,
rect: noteRect,
}) as BlockComponent | null;
const blockSelector =
'.affine-note-block-container > .affine-block-children-container > [data-block-id]';
const closestBlock = (
block && containChildBlock([closestNoteBlock], block.model)
? block
: findClosestBlockComponent(
closestNoteBlock as BlockComponent,
point.clone(),
blockSelector
)
) as BlockComponent;
if (!closestBlock || !!closestBlock.closest('.surface-ref-note-portal')) {
return null;
}
return closestBlock;
};
export function calcDropTarget(
point: Point,
model: BlockModel,
element: Element,
draggingElements: BlockComponent[],
scale: number,
/**
* Allow the dragging block to be dropped as sublist
*/
allowSublist: boolean = true
): DropResult | null {
let type: DropType | 'none' = 'none';
const height = 3 * scale;
const { rect: domRect } = getDropRectByPoint(point, model, element);
const distanceToTop = Math.abs(domRect.top - point.y);
const distanceToBottom = Math.abs(domRect.bottom - point.y);
const before = distanceToTop < distanceToBottom;
type = before ? 'before' : 'after';
let offsetY = 4;
if (type === 'before') {
// before
let prev;
let prevRect;
prev = element.previousElementSibling;
if (prev) {
if (
draggingElements.length &&
prev === draggingElements[draggingElements.length - 1]
) {
type = 'none';
} else {
prevRect = getRectByBlockComponent(prev);
}
} else {
prev = element.parentElement?.previousElementSibling;
if (prev) {
prevRect = prev.getBoundingClientRect();
}
}
if (prevRect) {
offsetY = (domRect.top - prevRect.bottom) / 2;
}
} else {
// Only consider drop as children when target block is list block.
// To drop in, the position must after the target first
// If drop in target has children, we can use insert before or after of that children
// to achieve the same effect.
const hasChild = (element as BlockComponent).childBlocks.length;
if (
allowSublist &&
matchFlavours(model, ['affine:list']) &&
!hasChild &&
point.x > domRect.x + BLOCK_CHILDREN_CONTAINER_PADDING_LEFT
) {
type = 'in';
}
// after
let next;
let nextRect;
next = element.nextElementSibling;
if (next) {
if (
type === 'after' &&
draggingElements.length &&
next === draggingElements[0]
) {
type = 'none';
next = null;
}
} else {
next = getClosestBlockComponentByElement(
element.parentElement
)?.nextElementSibling;
}
if (next) {
nextRect = getRectByBlockComponent(next);
offsetY = (nextRect.top - domRect.bottom) / 2;
}
}
if (type === 'none') return null;
let top = domRect.top;
if (type === 'before') {
top -= offsetY;
} else {
top += domRect.height + offsetY;
}
if (type === 'in') {
domRect.x += BLOCK_CHILDREN_CONTAINER_PADDING_LEFT;
domRect.width -= BLOCK_CHILDREN_CONTAINER_PADDING_LEFT;
}
return {
rect: Rect.fromLWTH(domRect.left, domRect.width, top - height / 2, height),
dropBlockId: model.id,
dropType: type,
};
}
export const getDropResult = (
event: MouseEvent,
scale: number = 1
): DropResult | null => {
let dropIndicator = null;
const point = new Point(event.x, event.y);
const closestBlock = getClosestBlockComponentByPoint(point) as BlockComponent;
if (!closestBlock) {
return dropIndicator;
}
const model = closestBlock.model;
const isDatabase = matchFlavours(model, ['affine:database']);
if (isDatabase) {
return dropIndicator;
}
const result = calcDropTarget(point, model, closestBlock, [], scale);
if (result) {
dropIndicator = result;
}
return dropIndicator;
};
export function getDragHandleLeftPadding(blocks: BlockComponent[]) {
const hasToggleList = blocks.some(
block =>
(matchFlavours(block.model, ['affine:list']) &&
block.model.children.length > 0) ||
(block instanceof ParagraphBlockComponent &&
block.model.type.startsWith('h') &&
block.collapsedSiblings.length > 0)
);
const offsetLeft = hasToggleList
? DRAG_HANDLE_CONTAINER_OFFSET_LEFT_LIST
: DRAG_HANDLE_CONTAINER_OFFSET_LEFT;
return offsetLeft;
}
let previousEle: BlockComponent[] = [];
export function updateDragHandleClassName(blocks: BlockComponent[] = []) {
const className = 'with-drag-handle';
previousEle.forEach(block => block.classList.remove(className));
previousEle = blocks;
blocks.forEach(block => block.classList.add(className));
}
export function getDuplicateBlocks(blocks: BlockModel[]) {
const duplicateBlocks = blocks.map(block => ({
flavour: block.flavour,
blockProps: getBlockProps(block),
}));
return duplicateBlocks;
}
@@ -0,0 +1,596 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import type { EmbedCardStyle, NoteBlockModel } from '@blocksuite/affine-model';
import {
EMBED_CARD_HEIGHT,
EMBED_CARD_WIDTH,
} from '@blocksuite/affine-shared/consts';
import {
DndApiExtensionIdentifier,
DocModeProvider,
TelemetryProvider,
} from '@blocksuite/affine-shared/services';
import {
captureEventTarget,
getBlockComponentsExcludeSubtrees,
getClosestBlockComponentByPoint,
matchFlavours,
} from '@blocksuite/affine-shared/utils';
import {
type BlockComponent,
type DndEventState,
isGfxBlockComponent,
type UIEventHandler,
type UIEventStateContext,
} from '@blocksuite/block-std';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import { Bound, Point } from '@blocksuite/global/utils';
import { Job, Slice, type SliceSnapshot } from '@blocksuite/store';
import {
HtmlAdapter,
MarkdownAdapter,
} from '../../../../_common/adapters/index.js';
import {
calcDropTarget,
type DropResult,
} from '../../../../_common/utils/index.js';
import type { EdgelessRootBlockComponent } from '../../../edgeless/index.js';
import { addNoteAtPoint } from '../../../edgeless/utils/common.js';
import { DropIndicator } from '../components/drop-indicator.js';
import { AFFINE_DRAG_HANDLE_WIDGET } from '../consts.js';
import type { AffineDragHandleWidget } from '../drag-handle.js';
import { newIdCrossDoc } from '../middleware/new-id-cross-doc.js';
import { surfaceRefToEmbed } from '../middleware/surface-ref-to-embed.js';
import { containBlock, includeTextSelection } from '../utils.js';
export class DragEventWatcher {
private _computeEdgelessBound = (
x: number,
y: number,
width: number,
height: number
) => {
const controller = this._std.get(GfxControllerIdentifier);
const border = 2;
const noteScale = this.widget.noteScale.peek();
const { viewport } = controller;
const { left: viewportLeft, top: viewportTop } = viewport;
const currentViewBound = new Bound(
x - viewportLeft,
y - viewportTop,
width + border / noteScale,
height + border / noteScale
);
const currentModelBound = viewport.toModelBound(currentViewBound);
return new Bound(
currentModelBound.x,
currentModelBound.y,
width * noteScale,
height * noteScale
);
};
private _createDropIndicator = () => {
if (!this.widget.dropIndicator) {
this.widget.dropIndicator = new DropIndicator();
this.widget.rootComponent.append(this.widget.dropIndicator);
}
};
private _dragEndHandler: UIEventHandler = () => {
this.widget.clearRaf();
this.widget.hide(true);
};
private _dragMoveHandler: UIEventHandler = ctx => {
if (
this.widget.isHoverDragHandleVisible ||
this.widget.isTopLevelDragHandleVisible
) {
this.widget.hide();
}
if (!this.widget.dragging || this.widget.draggingElements.length === 0) {
return false;
}
ctx.get('defaultState').event.preventDefault();
const state = ctx.get('dndState');
// call default drag move handler if no option return true
return this._onDragMove(state);
};
/**
* When start dragging, should set dragging elements and create drag preview
*/
private _dragStartHandler: UIEventHandler = ctx => {
const state = ctx.get('dndState');
// If not click left button to start dragging, should do nothing
const { button } = state.raw;
if (button !== 0) {
return false;
}
return this._onDragStart(state);
};
private _dropHandler = (context: UIEventStateContext) => {
this._onDrop(context);
this._std.selection.setGroup('gfx', []);
this.widget.clearRaf();
this.widget.hide(true);
};
private _onDragMove = (state: DndEventState) => {
this.widget.clearRaf();
this.widget.rafID = requestAnimationFrame(() => {
this.widget.edgelessWatcher.updateDragPreviewPosition(state);
this.widget.updateDropIndicator(state, true);
});
return true;
};
private _onDragStart = (state: DndEventState) => {
// Get current hover block element by path
const hoverBlock = this.widget.anchorBlockComponent.peek();
if (!hoverBlock) return false;
const element = captureEventTarget(state.raw.target);
const dragByHandle = !!element?.closest(AFFINE_DRAG_HANDLE_WIDGET);
const isInSurface = isGfxBlockComponent(hoverBlock);
if (isInSurface && dragByHandle) {
this._startDragging([hoverBlock], state);
return true;
}
const selectBlockAndStartDragging = () => {
this._std.selection.setGroup('note', [
this._std.selection.create('block', {
blockId: hoverBlock.blockId,
}),
]);
this._startDragging([hoverBlock], state);
};
if (this.widget.draggingElements.length === 0) {
const dragByBlock =
hoverBlock.contains(element) && !hoverBlock.model.text;
const canDragByBlock =
matchFlavours(hoverBlock.model, [
'affine:attachment',
'affine:bookmark',
]) || hoverBlock.model.flavour.startsWith('affine:embed-');
if (!isInSurface && dragByBlock && canDragByBlock) {
selectBlockAndStartDragging();
return true;
}
}
// Should only start dragging when pointer down on drag handle
// And current mouse button is left button
if (!dragByHandle) {
this.widget.hide();
return false;
}
if (this.widget.draggingElements.length === 1 && !isInSurface) {
selectBlockAndStartDragging();
return true;
}
if (!this.widget.isHoverDragHandleVisible) return false;
let selections = this.widget.selectionHelper.selectedBlocks;
// When current selection is TextSelection
// Should set BlockSelection for the blocks in native range
if (selections.length > 0 && includeTextSelection(selections)) {
const nativeSelection = document.getSelection();
const rangeManager = this._std.range;
if (nativeSelection && nativeSelection.rangeCount > 0 && rangeManager) {
const range = nativeSelection.getRangeAt(0);
const blocks = rangeManager.getSelectedBlockComponentsByRange(range, {
match: el => el.model.role === 'content',
mode: 'highest',
});
this.widget.selectionHelper.setSelectedBlocks(blocks);
selections = this.widget.selectionHelper.selectedBlocks;
}
}
// When there is no selected blocks
// Or selected blocks not including current hover block
// Set current hover block as selected
if (
selections.length === 0 ||
!containBlock(
selections.map(selection => selection.blockId),
this.widget.anchorBlockId.peek()!
)
) {
const block = this.widget.anchorBlockComponent.peek();
if (block) {
this.widget.selectionHelper.setSelectedBlocks([block]);
}
}
const blocks = this.widget.selectionHelper.selectedBlockComponents;
// This could be skipped if we can ensure that all selected blocks are on the same level
// Which means not selecting parent block and child block at the same time
const blocksExcludingChildren = getBlockComponentsExcludeSubtrees(
blocks
) as BlockComponent[];
if (blocksExcludingChildren.length === 0) return false;
this._startDragging(blocksExcludingChildren, state);
this.widget.hide();
return true;
};
private _onDrop = (context: UIEventStateContext) => {
const state = context.get('dndState');
const event = state.raw;
const { clientX, clientY } = event;
const point = new Point(clientX, clientY);
const element = getClosestBlockComponentByPoint(point.clone());
if (!element) {
const target = captureEventTarget(event.target);
const isEdgelessContainer =
target?.classList.contains('edgeless-container');
if (!isEdgelessContainer) return;
// drop to edgeless container
this._onDropOnEdgelessCanvas(context);
return;
}
const model = element.model;
const parent = this._std.doc.getParent(model.id);
if (!parent) return;
if (matchFlavours(parent, ['affine:surface'])) {
return;
}
const result: DropResult | null = calcDropTarget(point, model, element);
if (!result) return;
const index =
parent.children.indexOf(model) + (result.type === 'before' ? 0 : 1);
event.preventDefault();
if (matchFlavours(parent, ['affine:note'])) {
const snapshot = this._deserializeSnapshot(state);
if (snapshot) {
const [first] = snapshot.content;
if (first.flavour === 'affine:note') {
if (parent.id !== first.id) {
this._onDropNoteOnNote(snapshot, parent.id, index);
}
return;
}
}
}
this._deserializeData(state, parent.id, index).catch(console.error);
};
private _onDropNoteOnNote = (
snapshot: SliceSnapshot,
parent?: string,
index?: number
) => {
const [first] = snapshot.content;
const id = first.id;
const std = this._std;
const job = this._getJob();
const snapshotWithoutNote = {
...snapshot,
content: first.children,
};
job
.snapshotToSlice(snapshotWithoutNote, std.doc, parent, index)
.then(() => {
const block = std.doc.getBlock(id)?.model;
if (block) {
std.doc.deleteBlock(block);
}
})
.catch(console.error);
};
private _onDropOnEdgelessCanvas = (context: UIEventStateContext) => {
const state = context.get('dndState');
// If drop a note, should do nothing
const snapshot = this._deserializeSnapshot(state);
const edgelessRoot = this.widget
.rootComponent as EdgelessRootBlockComponent;
if (!snapshot) {
return;
}
const [first] = snapshot.content;
if (first.flavour === 'affine:note') return;
if (snapshot.content.length === 1) {
const importToSurface = (
width: number,
height: number,
newBound: Bound
) => {
first.props.xywh = newBound.serialize();
first.props.width = width;
first.props.height = height;
const std = this._std;
const job = this._getJob();
job
.snapshotToSlice(snapshot, std.doc, edgelessRoot.surfaceBlockModel.id)
.catch(console.error);
};
if (
['affine:attachment', 'affine:bookmark'].includes(first.flavour) ||
first.flavour.startsWith('affine:embed-')
) {
const style = (first.props.style ?? 'horizontal') as EmbedCardStyle;
const width = EMBED_CARD_WIDTH[style];
const height = EMBED_CARD_HEIGHT[style];
const newBound = this._computeEdgelessBound(
state.raw.clientX,
state.raw.clientY,
width,
height
);
if (!newBound) return;
if (first.flavour === 'affine:embed-linked-doc') {
this._trackLinkedDocCreated(first.id);
}
importToSurface(width, height, newBound);
return;
}
if (first.flavour === 'affine:image') {
const noteScale = this.widget.noteScale.peek();
const width = Number(first.props.width || 100) * noteScale;
const height = Number(first.props.height || 100) * noteScale;
const newBound = this._computeEdgelessBound(
state.raw.clientX,
state.raw.clientY,
width,
height
);
if (!newBound) return;
importToSurface(width, height, newBound);
return;
}
}
const { left: viewportLeft, top: viewportTop } = edgelessRoot.viewport;
const newNoteId = addNoteAtPoint(
edgelessRoot.std,
new Point(state.raw.x - viewportLeft, state.raw.y - viewportTop),
{
scale: this.widget.noteScale.peek(),
}
);
const newNoteBlock = this.widget.doc.getBlock(newNoteId)?.model as
| NoteBlockModel
| undefined;
if (!newNoteBlock) return;
const bound = Bound.deserialize(newNoteBlock.xywh);
bound.h *= this.widget.noteScale.peek();
bound.w *= this.widget.noteScale.peek();
this.widget.doc.updateBlock(newNoteBlock, {
xywh: bound.serialize(),
edgeless: {
...newNoteBlock.edgeless,
scale: this.widget.noteScale.peek(),
},
});
this._deserializeData(state, newNoteId).catch(console.error);
};
private _startDragging = (
blocks: BlockComponent[],
state: DndEventState,
dragPreviewEl?: HTMLElement,
dragPreviewOffset?: Point
) => {
if (!blocks.length) {
return;
}
this.widget.draggingElements = blocks;
this.widget.dragPreview = this.widget.previewHelper.createDragPreview(
blocks,
state,
dragPreviewEl,
dragPreviewOffset
);
const slice = Slice.fromModels(
this._std.doc,
blocks.map(block => block.model)
);
this.widget.dragging = true;
this._createDropIndicator();
this.widget.hide();
this._serializeData(slice, state);
};
private _trackLinkedDocCreated = (id: string) => {
const isNewBlock = !this._std.doc.hasBlock(id);
if (!isNewBlock) {
return;
}
const mode =
this._std.getOptional(DocModeProvider)?.getEditorMode() ?? 'page';
const telemetryService = this._std.getOptional(TelemetryProvider);
telemetryService?.track('LinkedDocCreated', {
control: `drop on ${mode}`,
module: 'drag and drop',
type: 'doc',
other: 'new doc',
});
};
private get _dndAPI() {
return this._std.get(DndApiExtensionIdentifier);
}
private get _std() {
return this.widget.std;
}
constructor(readonly widget: AffineDragHandleWidget) {}
private async _deserializeData(
state: DndEventState,
parent?: string,
index?: number
) {
try {
const dataTransfer = state.raw.dataTransfer;
if (!dataTransfer) throw new Error('No data transfer');
const std = this._std;
const job = this._getJob();
const snapshot = this._deserializeSnapshot(state);
if (snapshot) {
if (snapshot.content.length === 1) {
const [first] = snapshot.content;
if (first.flavour === 'affine:embed-linked-doc') {
this._trackLinkedDocCreated(first.id);
}
}
// use snapshot
const slice = await job.snapshotToSlice(
snapshot,
std.doc,
parent,
index
);
return slice;
}
const html = dataTransfer.getData('text/html');
if (html) {
// use html parser;
const htmlAdapter = new HtmlAdapter(job);
const slice = await htmlAdapter.toSlice(
{ file: html },
std.doc,
parent,
index
);
return slice;
}
const text = dataTransfer.getData('text/plain');
const textAdapter = new MarkdownAdapter(job);
const slice = await textAdapter.toSlice(
{ file: text },
std.doc,
parent,
index
);
return slice;
} catch {
return null;
}
}
private _deserializeSnapshot(state: DndEventState) {
try {
const dataTransfer = state.raw.dataTransfer;
if (!dataTransfer) throw new Error('No data transfer');
const data = dataTransfer.getData(this._dndAPI.mimeType);
const snapshot = this._dndAPI.decodeSnapshot(data);
return snapshot;
} catch {
return null;
}
}
private _getJob() {
const std = this._std;
return new Job({
collection: std.collection,
middlewares: [newIdCrossDoc(std), surfaceRefToEmbed(std)],
});
}
private _serializeData(slice: Slice, state: DndEventState) {
const dataTransfer = state.raw.dataTransfer;
if (!dataTransfer) return;
const job = this._getJob();
const snapshot = job.sliceToSnapshot(slice);
if (!snapshot) return;
const data = this._dndAPI.encodeSnapshot(snapshot);
dataTransfer.setData(this._dndAPI.mimeType, data);
}
watch() {
this.widget.handleEvent('pointerDown', ctx => {
const state = ctx.get('pointerState');
const event = state.raw;
const target = captureEventTarget(event.target);
if (!target) return;
if (this.widget.contains(target)) {
return true;
}
return;
});
this.widget.handleEvent('dragStart', ctx => {
const state = ctx.get('pointerState');
const event = state.raw;
const target = captureEventTarget(event.target);
if (!target) return;
if (this.widget.contains(target)) {
return true;
}
return;
});
this.widget.handleEvent('nativeDragStart', this._dragStartHandler, {
global: true,
});
this.widget.handleEvent('nativeDragMove', this._dragMoveHandler, {
global: true,
});
this.widget.handleEvent('nativeDragEnd', this._dragEndHandler, {
global: true,
});
this.widget.handleEvent('nativeDrop', this._dropHandler, {
global: true,
});
}
}
@@ -0,0 +1,269 @@
import type { DndEventState } from '@blocksuite/block-std';
import {
GfxControllerIdentifier,
type GfxToolsFullOptionValue,
} from '@blocksuite/block-std/gfx';
import { type IVec, Rect } from '@blocksuite/global/utils';
import { effect } from '@preact/signals-core';
import type {
EdgelessRootBlockComponent,
EdgelessRootService,
} from '../../../edgeless/index.js';
import {
getSelectedRect,
isTopLevelBlock,
} from '../../../edgeless/utils/query.js';
import {
DRAG_HANDLE_CONTAINER_OFFSET_LEFT_TOP_LEVEL,
DRAG_HANDLE_CONTAINER_WIDTH_TOP_LEVEL,
DRAG_HANDLE_GRABBER_BORDER_RADIUS,
DRAG_HANDLE_GRABBER_WIDTH_HOVERED,
HOVER_AREA_RECT_PADDING_TOP_LEVEL,
} from '../config.js';
import type { AffineDragHandleWidget } from '../drag-handle.js';
export class EdgelessWatcher {
private _handleEdgelessToolUpdated = (newTool: GfxToolsFullOptionValue) => {
if (newTool.type === 'default') {
this.checkTopLevelBlockSelection();
} else {
this.widget.hide();
}
};
private _handleEdgelessViewPortUpdated = ({
zoom,
center,
}: {
zoom: number;
center: IVec;
}) => {
if (this.widget.scale.peek() !== zoom) {
this.widget.scale.value = zoom;
this._updateDragPreviewOnViewportUpdate();
}
if (
this.widget.center[0] !== center[0] &&
this.widget.center[1] !== center[1]
) {
this.widget.center = [...center];
this.widget.updateDropIndicatorOnScroll();
}
if (this.widget.isTopLevelDragHandleVisible) {
this._showDragHandleOnTopLevelBlocks().catch(console.error);
this._updateDragHoverRectTopLevelBlock();
} else {
this.widget.hide();
}
};
private _showDragHandleOnTopLevelBlocks = async () => {
if (this.widget.mode === 'page') return;
const { edgelessRoot } = this;
await edgelessRoot.surface.updateComplete;
if (!this.widget.anchorBlockId) return;
const container = this.widget.dragHandleContainer;
const grabber = this.widget.dragHandleGrabber;
if (!container || !grabber) return;
const area = this.hoverAreaTopLevelBlock;
if (!area) return;
const height = area.height;
const posLeft = area.left;
const posTop = (area.top += area.padding);
container.style.transition = 'none';
container.style.paddingTop = `0px`;
container.style.paddingBottom = `0px`;
container.style.width = `${area.containerWidth}px`;
container.style.left = `${posLeft}px`;
container.style.top = `${posTop}px`;
container.style.display = 'flex';
container.style.height = `${height}px`;
grabber.style.width = `${DRAG_HANDLE_GRABBER_WIDTH_HOVERED * this.widget.scale.peek()}px`;
grabber.style.borderRadius = `${
DRAG_HANDLE_GRABBER_BORDER_RADIUS * this.widget.scale.peek()
}px`;
this.widget.handleAnchorModelDisposables();
this.widget.isTopLevelDragHandleVisible = true;
};
private _updateDragHoverRectTopLevelBlock = () => {
if (!this.widget.dragHoverRect) return;
this.widget.dragHoverRect = this.hoverAreaRectTopLevelBlock;
};
private _updateDragPreviewOnViewportUpdate = () => {
if (this.widget.dragPreview && this.widget.lastDragPointerState) {
this.updateDragPreviewPosition(this.widget.lastDragPointerState);
}
};
checkTopLevelBlockSelection = () => {
if (!this.widget.isConnected) return;
if (this.widget.doc.readonly || this.widget.mode === 'page') {
this.widget.hide();
return;
}
const { edgelessRoot } = this;
const editing = edgelessRoot.service.selection.editing;
const selectedElements = edgelessRoot.service.selection.selectedElements;
if (editing || selectedElements.length !== 1) {
this.widget.hide();
return;
}
const selectedElement = selectedElements[0];
if (!isTopLevelBlock(selectedElement)) {
this.widget.hide();
return;
}
const flavour = selectedElement.flavour;
const dragHandleOptions = this.widget.optionRunner.getOption(flavour);
if (!dragHandleOptions || !dragHandleOptions.edgeless) {
this.widget.hide();
return;
}
this.widget.anchorBlockId.value = selectedElement.id;
this._showDragHandleOnTopLevelBlocks().catch(console.error);
};
updateDragPreviewPosition = (state: DndEventState) => {
if (!this.widget.dragPreview) return;
const offsetParentRect =
this.widget.dragHandleContainerOffsetParent.getBoundingClientRect();
const dragPreviewOffset = this.widget.dragPreview.offset;
const posX = state.raw.x - dragPreviewOffset.x - offsetParentRect.left;
const posY = state.raw.y - dragPreviewOffset.y - offsetParentRect.top;
this.widget.dragPreview.style.transform = `translate(${posX}px, ${posY}px) scale(${this.widget.scaleInNote.peek()})`;
const altKey = state.raw.altKey;
this.widget.dragPreview.style.opacity = altKey ? '1' : '0.5';
};
get edgelessRoot() {
return this.widget.rootComponent as EdgelessRootBlockComponent;
}
get hoverAreaRectTopLevelBlock() {
const area = this.hoverAreaTopLevelBlock;
if (!area) return null;
return new Rect(area.left, area.top, area.right, area.bottom);
}
get hoverAreaTopLevelBlock() {
const edgelessElement = this.widget.anchorEdgelessElement.peek();
if (!edgelessElement) return null;
const { edgelessRoot } = this;
const rect = getSelectedRect([edgelessElement]);
let [left, top] = edgelessRoot.service.viewport.toViewCoord(
rect.left,
rect.top
);
const scale = this.widget.scale.peek();
const width = rect.width * scale;
const height = rect.height * scale;
let [right, bottom] = [left + width, top + height];
const padding = HOVER_AREA_RECT_PADDING_TOP_LEVEL * scale;
const containerWidth = DRAG_HANDLE_CONTAINER_WIDTH_TOP_LEVEL * scale;
const offsetLeft = DRAG_HANDLE_CONTAINER_OFFSET_LEFT_TOP_LEVEL * scale;
left -= containerWidth + offsetLeft;
top -= padding;
right += padding;
bottom += padding;
return {
left,
top,
right,
bottom,
width,
height,
padding,
containerWidth,
};
}
constructor(readonly widget: AffineDragHandleWidget) {}
watch() {
const { disposables, std } = this.widget;
const gfxController = std.get(GfxControllerIdentifier);
const { viewport } = gfxController;
const edgelessService = std.getService(
'affine:page'
) as EdgelessRootService;
const edgelessSlots = edgelessService.slots;
disposables.add(
viewport.viewportUpdated.on(this._handleEdgelessViewPortUpdated)
);
disposables.add(
edgelessService.selection.slots.updated.on(() => {
this.checkTopLevelBlockSelection();
})
);
disposables.add(
effect(() => {
const value = gfxController.tool.currentToolOption$.value;
value && this._handleEdgelessToolUpdated(value);
})
);
disposables.add(
edgelessSlots.readonlyUpdated.on(() => {
this.checkTopLevelBlockSelection();
})
);
disposables.add(
edgelessSlots.draggingAreaUpdated.on(() => {
this.checkTopLevelBlockSelection();
})
);
disposables.add(
edgelessSlots.elementResizeStart.on(() => {
this.widget.hide();
})
);
disposables.add(
edgelessSlots.elementResizeEnd.on(() => {
this.checkTopLevelBlockSelection();
})
);
}
}
@@ -0,0 +1,93 @@
import {
DRAG_HANDLE_CONTAINER_PADDING,
DRAG_HANDLE_GRABBER_BORDER_RADIUS,
DRAG_HANDLE_GRABBER_WIDTH_HOVERED,
} from '../config.js';
import type { AffineDragHandleWidget } from '../drag-handle.js';
export class HandleEventWatcher {
private _onDragHandlePointerDown = () => {
if (!this.widget.isHoverDragHandleVisible || !this.widget.anchorBlockId)
return;
this.widget.dragHoverRect = this.widget.draggingAreaRect.value;
};
private _onDragHandlePointerEnter = () => {
const container = this.widget.dragHandleContainer;
const grabber = this.widget.dragHandleGrabber;
if (!container || !grabber) return;
if (this.widget.isHoverDragHandleVisible && this.widget.anchorBlockId) {
const block = this.widget.anchorBlockComponent;
if (!block) return;
const padding = DRAG_HANDLE_CONTAINER_PADDING * this.widget.scale.peek();
container.style.paddingTop = `${padding}px`;
container.style.paddingBottom = `${padding}px`;
container.style.transition = `padding 0.25s ease`;
grabber.style.width = `${
DRAG_HANDLE_GRABBER_WIDTH_HOVERED * this.widget.scaleInNote.peek()
}px`;
grabber.style.borderRadius = `${
DRAG_HANDLE_GRABBER_BORDER_RADIUS * this.widget.scaleInNote.peek()
}px`;
this.widget.isDragHandleHovered = true;
} else if (this.widget.isTopLevelDragHandleVisible) {
this.widget.dragHoverRect =
this.widget.edgelessWatcher.hoverAreaRectTopLevelBlock;
this.widget.isDragHandleHovered = true;
}
};
private _onDragHandlePointerLeave = () => {
this.widget.isDragHandleHovered = false;
this.widget.dragHoverRect = null;
if (this.widget.isTopLevelDragHandleVisible) return;
if (this.widget.dragging) return;
this.widget.pointerEventWatcher.showDragHandleOnHoverBlock();
};
private _onDragHandlePointerUp = () => {
if (!this.widget.isHoverDragHandleVisible) return;
this.widget.dragHoverRect = null;
};
constructor(readonly widget: AffineDragHandleWidget) {}
watch() {
const { dragHandleContainer, disposables } = this.widget;
// When pointer enter drag handle grabber
// Extend drag handle grabber to the height of the hovered block
disposables.addFromEvent(
dragHandleContainer,
'pointerenter',
this._onDragHandlePointerEnter
);
disposables.addFromEvent(
dragHandleContainer,
'pointerdown',
this._onDragHandlePointerDown
);
disposables.addFromEvent(
dragHandleContainer,
'pointerup',
this._onDragHandlePointerUp
);
// When pointer leave drag handle grabber, should reset drag handle grabber style
disposables.addFromEvent(
dragHandleContainer,
'pointerleave',
this._onDragHandlePointerLeave
);
}
}
@@ -0,0 +1,27 @@
import type { UIEventHandler } from '@blocksuite/block-std';
import type { AffineDragHandleWidget } from '../drag-handle.js';
export class KeyboardEventWatcher {
private _keyboardHandler: UIEventHandler = ctx => {
if (!this.widget.dragging || !this.widget.dragPreview) {
return;
}
const state = ctx.get('defaultState');
const event = state.event as KeyboardEvent;
event.preventDefault();
event.stopPropagation();
const altKey = event.key === 'Alt' && event.altKey;
this.widget.dragPreview.style.opacity = altKey ? '1' : '0.5';
};
constructor(readonly widget: AffineDragHandleWidget) {}
watch() {
this.widget.handleEvent('beforeInput', () => this.widget.hide());
this.widget.handleEvent('keyDown', this._keyboardHandler, { global: true });
this.widget.handleEvent('keyUp', this._keyboardHandler, { global: true });
}
}
@@ -0,0 +1,474 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import type { NoteBlockModel } from '@blocksuite/affine-model';
import {
captureEventTarget,
findNoteBlockModel,
getBlockComponentsExcludeSubtrees,
matchFlavours,
} from '@blocksuite/affine-shared/utils';
import {
type BlockComponent,
isGfxBlockComponent,
type PointerEventState,
type UIEventHandler,
} from '@blocksuite/block-std';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import { IS_MOBILE } from '@blocksuite/global/env';
import { Bound, Point } from '@blocksuite/global/utils';
import type { BlockModel } from '@blocksuite/store';
import { render } from 'lit';
import type { EdgelessRootBlockComponent } from '../../../edgeless/index.js';
import { addNoteAtPoint } from '../../../edgeless/utils/common.js';
import { DropIndicator } from '../components/drop-indicator.js';
import { AFFINE_DRAG_HANDLE_WIDGET } from '../consts.js';
import type { AffineDragHandleWidget } from '../drag-handle.js';
import {
containBlock,
getDuplicateBlocks,
includeTextSelection,
} from '../utils.js';
export class LegacyDragEventWatcher {
private _changeCursorToGrabbing = () => {
document.documentElement.classList.add('affine-drag-preview-grabbing');
};
private _createDropIndicator = () => {
if (!this.widget.dropIndicator) {
this.widget.dropIndicator = new DropIndicator();
this.widget.rootComponent.append(this.widget.dropIndicator);
}
};
/**
* When drag end, should move blocks to drop position
*/
private _dragEndHandler: UIEventHandler = ctx => {
this.widget.clearRaf();
if (!this.widget.dragging || !this.widget.dragPreview) return false;
if (this.widget.draggingElements.length === 0 || this.widget.doc.readonly) {
this.widget.hide(true);
return false;
}
const state = ctx.get('pointerState');
const { target } = state.raw;
if (!this.widget.host.contains(target as Node)) {
this.widget.hide(true);
return true;
}
for (const option of this.widget.optionRunner.options) {
if (
option.onDragEnd?.({
// @ts-expect-error FIXME: ts error
state,
draggingElements: this.widget.draggingElements,
dropBlockId: this.widget.dropBlockId,
dropType: this.widget.dropType,
dragPreview: this.widget.dragPreview,
noteScale: this.widget.noteScale.peek(),
editorHost: this.widget.host,
})
) {
this.widget.hide(true);
if (this.widget.mode === 'edgeless') {
this.widget.edgelessWatcher.checkTopLevelBlockSelection();
}
return true;
}
}
// call default drag end handler if no option return true
this._onDragEnd(state);
if (this.widget.mode === 'edgeless') {
this.widget.edgelessWatcher.checkTopLevelBlockSelection();
}
return true;
};
/**
* When dragging, should:
* Update drag preview position
* Update indicator position
* Update drop block id
*/
private _dragMoveHandler: UIEventHandler = ctx => {
if (
this.widget.isHoverDragHandleVisible ||
this.widget.isTopLevelDragHandleVisible
) {
this.widget.hide();
}
if (!this.widget.dragging || this.widget.draggingElements.length === 0) {
return false;
}
ctx.get('defaultState').event.preventDefault();
const state = ctx.get('pointerState');
for (const option of this.widget.optionRunner.options) {
if (
option.onDragMove?.({
// @ts-expect-error FIXME: ts error
state,
draggingElements: this.widget.draggingElements,
})
) {
return true;
}
}
// call default drag move handler if no option return true
return this._onDragMove(state);
};
/**
* When start dragging, should set dragging elements and create drag preview
*/
private _dragStartHandler: UIEventHandler = ctx => {
const state = ctx.get('pointerState');
// If not click left button to start dragging, should do nothing
const { button } = state.raw;
if (button !== 0) {
return false;
}
// call default drag start handler if no option return true
for (const option of this.widget.optionRunner.options) {
if (
option.onDragStart?.({
// @ts-expect-error FIXME: ts error
state,
// @ts-expect-error FIXME: ts error
startDragging: this._startDragging,
anchorBlockId: this.widget.anchorBlockId.peek() ?? '',
editorHost: this.widget.host,
})
) {
return true;
}
}
return this._onDragStart(state);
};
private _onDragEnd = (state: PointerEventState) => {
const targetBlockId = this.widget.dropBlockId;
const dropType = this.widget.dropType;
const draggingElements = this.widget.draggingElements;
this.widget.hide(true);
// handle drop of blocks from note onto edgeless container
if (!targetBlockId) {
const target = captureEventTarget(state.raw.target);
if (!target) return false;
const isTargetEdgelessContainer =
target.classList.contains('edgeless-container');
if (!isTargetEdgelessContainer) return false;
const selectedBlocks = getBlockComponentsExcludeSubtrees(draggingElements)
.map(element => element.model)
.filter((x): x is BlockModel => !!x);
if (selectedBlocks.length === 0) return false;
const isSurfaceComponent = selectedBlocks.some(block => {
const parent = this.widget.doc.getParent(block.id);
return matchFlavours(parent, ['affine:surface']);
});
if (isSurfaceComponent) return true;
const edgelessRoot = this.widget
.rootComponent as EdgelessRootBlockComponent;
const { left: viewportLeft, top: viewportTop } = edgelessRoot.viewport;
const newNoteId = addNoteAtPoint(
edgelessRoot.std,
new Point(state.raw.x - viewportLeft, state.raw.y - viewportTop),
{
scale: this.widget.noteScale.peek(),
}
);
const newNoteBlock = this.widget.doc.getBlockById(
newNoteId
) as NoteBlockModel;
if (!newNoteBlock) return;
const bound = Bound.deserialize(newNoteBlock.xywh);
bound.h *= this.widget.noteScale.peek();
bound.w *= this.widget.noteScale.peek();
this.widget.doc.updateBlock(newNoteBlock, {
xywh: bound.serialize(),
edgeless: {
...newNoteBlock.edgeless,
scale: this.widget.noteScale.peek(),
},
});
const altKey = state.raw.altKey;
if (altKey) {
const duplicateBlocks = getDuplicateBlocks(selectedBlocks);
this.widget.doc.addBlocks(duplicateBlocks, newNoteBlock);
} else {
this.widget.doc.moveBlocks(selectedBlocks, newNoteBlock);
}
edgelessRoot.service.selection.set({
elements: [newNoteBlock.id],
editing: true,
});
return true;
}
// Should make sure drop block id is not in selected blocks
if (
containBlock(this.widget.selectionHelper.selectedBlockIds, targetBlockId)
) {
return false;
}
const selectedBlocks = getBlockComponentsExcludeSubtrees(draggingElements)
.map(element => element.model)
.filter((x): x is BlockModel => !!x);
if (!selectedBlocks.length) {
return false;
}
const targetBlock = this.widget.doc.getBlockById(targetBlockId);
if (!targetBlock) return;
const shouldInsertIn = dropType === 'in';
const parent = shouldInsertIn
? targetBlock
: this.widget.doc.getParent(targetBlockId);
if (!parent) return;
const altKey = state.raw.altKey;
if (shouldInsertIn) {
if (altKey) {
const duplicateBlocks = getDuplicateBlocks(selectedBlocks);
this.widget.doc.addBlocks(duplicateBlocks, targetBlock);
} else {
this.widget.doc.moveBlocks(selectedBlocks, targetBlock);
}
} else {
if (altKey) {
const duplicateBlocks = getDuplicateBlocks(selectedBlocks);
const parentIndex =
parent.children.indexOf(targetBlock) + (dropType === 'after' ? 1 : 0);
this.widget.doc.addBlocks(duplicateBlocks, parent, parentIndex);
} else {
this.widget.doc.moveBlocks(
selectedBlocks,
parent,
targetBlock,
dropType === 'before'
);
}
}
// TODO: need a better way to update selection
// Should update selection after moving blocks
// In doc page mode, update selected blocks
// In edgeless mode, focus on the first block
setTimeout(() => {
if (!parent) return;
// Need to update selection when moving blocks successfully
// Because the block path may be changed after moving
const parentElement = this.widget.std.view.getBlock(parent.id);
if (parentElement) {
const newSelectedBlocks = selectedBlocks.map(block => {
return this.widget.std.view.getBlock(block.id);
});
if (!newSelectedBlocks) return;
const note = findNoteBlockModel(parentElement.model);
if (!note) return;
this.widget.selectionHelper.setSelectedBlocks(
newSelectedBlocks as BlockComponent[],
note.id
);
}
}, 0);
return true;
};
private _onDragMove = (state: PointerEventState) => {
this.widget.clearRaf();
this.widget.rafID = requestAnimationFrame(() => {
// @ts-expect-error FIXME: ts error
this.widget.edgelessWatcher.updateDragPreviewPosition(state);
// @ts-expect-error FIXME: ts error
this.widget.updateDropIndicator(state, true);
});
return true;
};
private _onDragStart = (state: PointerEventState) => {
// Get current hover block element by path
const hoverBlock = this.widget.anchorBlockComponent.peek();
if (!hoverBlock) return false;
const element = captureEventTarget(state.raw.target);
const dragByHandle = !!element?.closest(AFFINE_DRAG_HANDLE_WIDGET);
const isInSurface = isGfxBlockComponent(hoverBlock);
if (isInSurface && dragByHandle) {
const viewport = this.widget.std.get(GfxControllerIdentifier).viewport;
const zoom = viewport.zoom ?? 1;
const dragPreviewEl = document.createElement('div');
const bound = Bound.deserialize(hoverBlock.model.xywh);
const offset = new Point(bound.x * zoom, bound.y * zoom);
// TODO: not use `dangerouslyRenderModel` to render drag preview
render(
this.widget.std.host.dangerouslyRenderModel(hoverBlock.model),
dragPreviewEl
);
this._startDragging([hoverBlock], state, dragPreviewEl, offset);
return true;
}
const selectBlockAndStartDragging = () => {
this.widget.std.selection.setGroup('note', [
this.widget.std.selection.create('block', {
blockId: hoverBlock.blockId,
}),
]);
this._startDragging([hoverBlock], state);
};
if (this.widget.draggingElements.length === 0) {
const dragByBlock =
hoverBlock.contains(element) && !hoverBlock.model.text;
const canDragByBlock =
matchFlavours(hoverBlock.model, [
'affine:attachment',
'affine:bookmark',
]) || hoverBlock.model.flavour.startsWith('affine:embed-');
if (!isInSurface && dragByBlock && canDragByBlock) {
selectBlockAndStartDragging();
return true;
}
}
// Should only start dragging when pointer down on drag handle
// And current mouse button is left button
if (!dragByHandle) {
this.widget.hide();
return false;
}
if (this.widget.draggingElements.length === 1 && !isInSurface) {
selectBlockAndStartDragging();
return true;
}
if (!this.widget.isHoverDragHandleVisible) return false;
let selections = this.widget.selectionHelper.selectedBlocks;
// When current selection is TextSelection
// Should set BlockSelection for the blocks in native range
if (selections.length > 0 && includeTextSelection(selections)) {
const nativeSelection = document.getSelection();
const rangeManager = this.widget.std.range;
if (nativeSelection && nativeSelection.rangeCount > 0 && rangeManager) {
const range = nativeSelection.getRangeAt(0);
const blocks = rangeManager.getSelectedBlockComponentsByRange(range, {
match: el => el.model.role === 'content',
mode: 'highest',
});
this.widget.selectionHelper.setSelectedBlocks(blocks);
selections = this.widget.selectionHelper.selectedBlocks;
}
}
// When there is no selected blocks
// Or selected blocks not including current hover block
// Set current hover block as selected
if (
selections.length === 0 ||
!containBlock(
selections.map(selection => selection.blockId),
this.widget.anchorBlockId.peek()!
)
) {
const block = this.widget.anchorBlockComponent.peek();
if (block) {
this.widget.selectionHelper.setSelectedBlocks([block]);
}
}
const blocks = this.widget.selectionHelper.selectedBlockComponents;
// This could be skip if we can ensure that all selected blocks are on the same level
// Which means not selecting parent block and child block at the same time
const blocksExcludingChildren = getBlockComponentsExcludeSubtrees(
blocks
) as BlockComponent[];
if (blocksExcludingChildren.length === 0) return false;
this._startDragging(blocksExcludingChildren, state);
this.widget.hide();
return true;
};
private _startDragging = (
blocks: BlockComponent[],
state: PointerEventState,
dragPreviewEl?: HTMLElement,
dragPreviewOffset?: Point
) => {
if (!blocks.length) {
return;
}
this.widget.draggingElements = blocks;
this.widget.dragPreview = this.widget.previewHelper.createDragPreview(
blocks,
// @ts-expect-error FIXME: ts error
state,
dragPreviewEl,
dragPreviewOffset
);
this.widget.dragging = true;
this._changeCursorToGrabbing();
this._createDropIndicator();
this.widget.hide();
};
constructor(readonly widget: AffineDragHandleWidget) {}
watch() {
this.widget.disposables.addFromEvent(this.widget, 'pointerdown', e => {
e.preventDefault();
});
if (IS_MOBILE) return;
this.widget.handleEvent('dragStart', this._dragStartHandler);
this.widget.handleEvent('dragMove', this._dragMoveHandler);
this.widget.handleEvent('dragEnd', this._dragEndHandler, { global: true });
}
}
@@ -0,0 +1,37 @@
import { getScrollContainer } from '@blocksuite/affine-shared/utils';
import type { PageRootBlockComponent } from '../../../page/page-root-block.js';
import type { AffineDragHandleWidget } from '../drag-handle.js';
export class PageWatcher {
get pageRoot() {
return this.widget.rootComponent as PageRootBlockComponent;
}
constructor(readonly widget: AffineDragHandleWidget) {}
watch() {
const { pageRoot } = this;
const { disposables } = this.widget;
const scrollContainer = getScrollContainer(pageRoot);
disposables.add(
this.widget.doc.slots.blockUpdated.on(() => this.widget.hide())
);
disposables.add(
pageRoot.slots.viewportUpdated.on(() => {
this.widget.hide();
if (this.widget.dropIndicator) {
this.widget.dropIndicator.rect = null;
}
})
);
disposables.addFromEvent(
scrollContainer,
'scrollend',
this.widget.updateDropIndicatorOnScroll
);
}
}
@@ -0,0 +1,336 @@
import { captureEventTarget } from '@blocksuite/affine-shared/utils';
import {
BLOCK_ID_ATTR,
type BlockComponent,
type PointerEventState,
type UIEventHandler,
} from '@blocksuite/block-std';
import { Point, throttle } from '@blocksuite/global/utils';
import { computed } from '@preact/signals-core';
import type { NoteBlockComponent } from '../../../../note-block/index.js';
import type { EdgelessRootBlockComponent } from '../../../edgeless/index.js';
import {
DRAG_HANDLE_CONTAINER_WIDTH,
DRAG_HANDLE_GRABBER_BORDER_RADIUS,
DRAG_HANDLE_GRABBER_HEIGHT,
DRAG_HANDLE_GRABBER_WIDTH,
} from '../config.js';
import { AFFINE_DRAG_HANDLE_WIDGET } from '../consts.js';
import type { AffineDragHandleWidget } from '../drag-handle.js';
import {
getClosestBlockByPoint,
getClosestNoteBlock,
getDragHandleContainerHeight,
includeTextSelection,
insideDatabaseTable,
isBlockIdEqual,
isOutOfNoteBlock,
updateDragHandleClassName,
} from '../utils.js';
export class PointerEventWatcher {
private _canEditing = (noteBlock: BlockComponent) => {
if (noteBlock.doc.id !== this.widget.doc.id) return false;
if (this.widget.mode === 'page') return true;
const edgelessRoot = this.widget
.rootComponent as EdgelessRootBlockComponent;
const noteBlockId = noteBlock.model.id;
return (
edgelessRoot.service.selection.editing &&
edgelessRoot.service.selection.selectedIds[0] === noteBlockId
);
};
/**
* When click on drag handle
* Should select the block and show slash menu if current block is not selected
* Should clear selection if current block is the first selected block
*/
private _clickHandler: UIEventHandler = ctx => {
if (!this.widget.isHoverDragHandleVisible) return;
const state = ctx.get('pointerState');
const { target } = state.raw;
const element = captureEventTarget(target);
const insideDragHandle = !!element?.closest(AFFINE_DRAG_HANDLE_WIDGET);
if (!insideDragHandle) return;
const anchorBlockId = this.widget.anchorBlockId.peek();
if (!anchorBlockId) return;
const { selection } = this.widget.std;
const selectedBlocks = this.widget.selectionHelper.selectedBlocks;
// Should clear selection if current block is the first selected block
if (
selectedBlocks.length > 0 &&
!includeTextSelection(selectedBlocks) &&
selectedBlocks[0].blockId === anchorBlockId
) {
selection.clear(['block']);
this.widget.dragHoverRect = null;
this.showDragHandleOnHoverBlock();
return;
}
// Should select the block if current block is not selected
const block = this.widget.anchorBlockComponent.peek();
if (!block) return;
if (selectedBlocks.length > 1) {
this.showDragHandleOnHoverBlock();
}
this.widget.selectionHelper.setSelectedBlocks([block]);
};
// Need to consider block padding and scale
private _getTopWithBlockComponent = (block: BlockComponent) => {
const computedStyle = getComputedStyle(block);
const { top } = block.getBoundingClientRect();
const paddingTop =
parseInt(computedStyle.paddingTop) * this.widget.scale.peek();
return (
top +
paddingTop -
this.widget.dragHandleContainerOffsetParent.getBoundingClientRect().top
);
};
private _containerStyle = computed(() => {
const draggingAreaRect = this.widget.draggingAreaRect.value;
if (!draggingAreaRect) return null;
const block = this.widget.anchorBlockComponent.value;
if (!block) return null;
const containerHeight = getDragHandleContainerHeight(block.model);
const posTop = this._getTopWithBlockComponent(block);
const scaleInNote = this.widget.scaleInNote.value;
const rowPaddingY =
((containerHeight - DRAG_HANDLE_GRABBER_HEIGHT) / 2 + 2) * scaleInNote;
// use padding to control grabber's height
const paddingTop = rowPaddingY + posTop - draggingAreaRect.top;
const paddingBottom =
draggingAreaRect.height -
paddingTop -
DRAG_HANDLE_GRABBER_HEIGHT * scaleInNote;
return {
paddingTop: `${paddingTop}px`,
paddingBottom: `${paddingBottom}px`,
width: `${DRAG_HANDLE_CONTAINER_WIDTH * scaleInNote}px`,
left: `${draggingAreaRect.left}px`,
top: `${draggingAreaRect.top}px`,
height: `${draggingAreaRect.height}px`,
};
});
private _grabberStyle = computed(() => {
const scaleInNote = this.widget.scaleInNote.value;
return {
width: `${DRAG_HANDLE_GRABBER_WIDTH * scaleInNote}px`,
borderRadius: `${DRAG_HANDLE_GRABBER_BORDER_RADIUS * scaleInNote}px`,
};
});
private _lastHoveredBlockId: string | null = null;
private _lastShowedBlock: { id: string; el: BlockComponent } | null = null;
/**
* When pointer move on block, should show drag handle
* And update hover block id and path
*/
private _pointerMoveOnBlock = (state: PointerEventState) => {
if (this.widget.isTopLevelDragHandleVisible) return;
const point = new Point(state.raw.x, state.raw.y);
const closestBlock = getClosestBlockByPoint(
this.widget.host,
this.widget.rootComponent,
point
);
if (!closestBlock) {
this.widget.anchorBlockId.value = null;
return;
}
const blockId = closestBlock.getAttribute(BLOCK_ID_ATTR);
if (!blockId) return;
this.widget.anchorBlockId.value = blockId;
if (insideDatabaseTable(closestBlock) || this.widget.doc.readonly) {
this.widget.hide();
return;
}
// If current block is not the last hovered block, show drag handle beside the hovered block
if (
(!this._lastHoveredBlockId ||
!isBlockIdEqual(
this.widget.anchorBlockId.peek(),
this._lastHoveredBlockId
) ||
!this.widget.isHoverDragHandleVisible) &&
!this.widget.isDragHandleHovered
) {
this.showDragHandleOnHoverBlock();
this._lastHoveredBlockId = this.widget.anchorBlockId.peek();
}
};
private _pointerOutHandler: UIEventHandler = ctx => {
const state = ctx.get('pointerState');
state.raw.preventDefault();
const { target } = state.raw;
const element = captureEventTarget(target);
if (!element) return;
const { relatedTarget } = state.raw;
// TODO: when pointer out of page viewport, should hide drag handle
// But the pointer out event is not as expected
// Need to be optimized
const relatedElement = captureEventTarget(relatedTarget);
const outOfPageViewPort = element.classList.contains(
'affine-page-viewport'
);
const inPage = !!relatedElement?.closest('.affine-page-viewport');
const inDragHandle = !!relatedElement?.closest(AFFINE_DRAG_HANDLE_WIDGET);
if (outOfPageViewPort && !inDragHandle && !inPage) {
this.widget.hide();
}
};
private _throttledPointerMoveHandler = throttle<UIEventHandler>(ctx => {
if (
this.widget.doc.readonly ||
this.widget.dragging ||
!this.widget.isConnected
) {
this.widget.hide();
return;
}
if (this.widget.isTopLevelDragHandleVisible) return;
const state = ctx.get('pointerState');
const { target } = state.raw;
const element = captureEventTarget(target);
// When pointer not on block or on dragging, should do nothing
if (!element) return;
// When pointer on drag handle, should do nothing
if (element.closest('.affine-drag-handle-container')) return;
// When pointer out of note block hover area or inside database, should hide drag handle
const point = new Point(state.raw.x, state.raw.y);
const closestNoteBlock = getClosestNoteBlock(
this.widget.host,
this.widget.rootComponent,
point
) as NoteBlockComponent | null;
this.widget.noteScale.value =
this.widget.mode === 'page'
? 1
: (closestNoteBlock?.model.edgeless.scale ?? 1);
if (
closestNoteBlock &&
this._canEditing(closestNoteBlock) &&
!isOutOfNoteBlock(
this.widget.host,
closestNoteBlock,
point,
this.widget.scaleInNote.peek()
)
) {
this._pointerMoveOnBlock(state);
return true;
}
this.widget.hide();
return false;
}, 1000 / 60);
// Multiple blocks: drag handle should show on the vertical middle of all blocks
showDragHandleOnHoverBlock = () => {
const block = this.widget.anchorBlockComponent.peek();
if (!block) return;
const container = this.widget.dragHandleContainer;
const grabber = this.widget.dragHandleGrabber;
if (!container || !grabber) return;
this.widget.isHoverDragHandleVisible = true;
const draggingAreaRect = this.widget.draggingAreaRect.peek();
if (!draggingAreaRect) return;
// Ad-hoc solution for list with toggle icon
updateDragHandleClassName([block]);
// End of ad-hoc solution
const applyStyle = (transition?: boolean) => {
const containerStyle = this._containerStyle.value;
if (!containerStyle) return;
container.style.transition = transition ? 'padding 0.25s ease' : 'none';
Object.assign(container.style, containerStyle);
container.style.display = 'flex';
};
if (isBlockIdEqual(block.blockId, this._lastShowedBlock?.id)) {
applyStyle(true);
} else if (this.widget.selectionHelper.selectedBlocks.length) {
if (this.widget.selectionHelper.isBlockSelected(block))
applyStyle(
this.widget.isDragHandleHovered &&
this.widget.selectionHelper.isBlockSelected(
this._lastShowedBlock?.el
)
);
else applyStyle(false);
} else {
applyStyle(false);
}
const grabberStyle = this._grabberStyle.value;
Object.assign(grabber.style, grabberStyle);
this.widget.handleAnchorModelDisposables();
if (!isBlockIdEqual(block.blockId, this._lastShowedBlock?.id)) {
this._lastShowedBlock = {
id: block.blockId,
el: block,
};
}
};
constructor(readonly widget: AffineDragHandleWidget) {}
reset() {
this._lastHoveredBlockId = null;
this._lastShowedBlock = null;
}
watch() {
this.widget.handleEvent('click', this._clickHandler);
this.widget.handleEvent('pointerMove', this._throttledPointerMoveHandler);
this.widget.handleEvent('pointerOut', this._pointerOutHandler);
}
}
@@ -0,0 +1,595 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import {
AutoConnectLeftIcon,
AutoConnectRightIcon,
HiddenIcon,
SmallDocIcon,
} from '@blocksuite/affine-components/icons';
import {
FrameBlockModel,
NoteBlockModel,
NoteDisplayMode,
type RootBlockModel,
type SurfaceRefBlockModel,
} from '@blocksuite/affine-model';
import {
matchFlavours,
stopPropagation,
} from '@blocksuite/affine-shared/utils';
import { WidgetComponent } from '@blocksuite/block-std';
import { Bound } from '@blocksuite/global/utils';
import { css, html, nothing, type TemplateResult } from 'lit';
import { state } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js';
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
import type { EdgelessRootService } from '../../edgeless/edgeless-root-service.js';
import { isNoteBlock } from '../../edgeless/utils/query.js';
const PAGE_VISIBLE_INDEX_LABEL_WIDTH = 44;
const PAGE_VISIBLE_INDEX_LABEL_HEIGHT = 24;
const EDGELESS_ONLY_INDEX_LABEL_WIDTH = 24;
const EDGELESS_ONLY_INDEX_LABEL_HEIGHT = 24;
const INDEX_LABEL_OFFSET = 16;
function calculatePosition(gap: number, count: number, iconWidth: number) {
const positions = [];
if (count === 1) {
positions.push([0, 10]);
return positions;
}
const middleIndex = (count - 1) / 2;
const isEven = count % 2 === 0;
const middleOffset = (gap + iconWidth) / 2;
function getSign(num: number) {
return num - middleIndex > 0 ? 1 : -1;
}
for (let j = 0; j < count; j++) {
let left = 10;
if (isEven) {
if (Math.abs(j - middleIndex) < 1 && isEven) {
left = 10 + middleOffset * getSign(j);
} else {
left =
10 +
((Math.ceil(Math.abs(j - middleIndex)) - 1) * (gap + 24) +
middleOffset) *
getSign(j);
}
} else {
const offset = gap + iconWidth;
left = 10 + Math.ceil(Math.abs(j - middleIndex)) * offset * getSign(j);
}
positions.push([0, left]);
}
return positions;
}
function getIndexLabelTooltip(icon: TemplateResult, content: string) {
const styles = css`
.index-label-tooltip {
display: flex;
align-items: center;
flex-wrap: nowrap;
gap: 10px;
}
.index-label-tooltip-icon {
display: flex;
align-items: center;
justify-content: center;
}
.index-label-tooltip-content {
font-size: var(--affine-font-sm);
display: flex;
height: 16px;
line-height: 16px;
}
`;
return html`<style>
${styles}
</style>
<div class="index-label-tooltip">
<span class="index-label-tooltip-icon">${icon}</span>
<span class="index-label-tooltip-content">${content}</span>
</div>`;
}
type AutoConnectElement = NoteBlockModel | FrameBlockModel;
function isAutoConnectElement(element: unknown): element is AutoConnectElement {
return (
element instanceof NoteBlockModel || element instanceof FrameBlockModel
);
}
export const AFFINE_EDGELESS_AUTO_CONNECT_WIDGET =
'affine-edgeless-auto-connect-widget';
export class EdgelessAutoConnectWidget extends WidgetComponent<
RootBlockModel,
EdgelessRootBlockComponent,
EdgelessRootService
> {
static override styles = css`
.page-visible-index-label {
box-sizing: border-box;
padding: 0px 6px;
border: 1px solid #0000001a;
width: fit-content;
height: 24px;
min-width: 24px;
color: var(--affine-white);
font-size: 15px;
line-height: 22px;
text-align: center;
cursor: pointer;
user-select: none;
border-radius: 25px;
background: var(--affine-primary-color);
}
.navigator {
width: 48px;
padding: 4px;
border-radius: 58px;
border: 1px solid rgba(227, 226, 228, 1);
transition: opacity 0.5s ease-in-out;
background: rgba(251, 251, 252, 1);
display: flex;
align-items: center;
justify-content: space-between;
opacity: 0;
}
.navigator div {
display: flex;
align-items: center;
cursor: pointer;
}
.navigator span {
display: inline-block;
height: 8px;
border: 1px solid rgba(227, 226, 228, 1);
}
.navigator div:hover {
background: var(--affine-hover-color);
}
.navigator.show {
opacity: 1;
}
`;
private _updateLabels = () => {
const service = this.service;
if (!service.doc.root) return;
const pageVisibleBlocks = new Map<AutoConnectElement, number>();
const notes = service.doc.root?.children.filter(child =>
matchFlavours(child, ['affine:note'])
);
const edgelessOnlyNotesSet = new Set<NoteBlockModel>();
notes.forEach(note => {
if (isNoteBlock(note)) {
if (note.displayMode$.value === NoteDisplayMode.EdgelessOnly) {
edgelessOnlyNotesSet.add(note);
} else if (note.displayMode$.value === NoteDisplayMode.DocAndEdgeless) {
pageVisibleBlocks.set(note, 1);
}
}
note.children.forEach(model => {
if (matchFlavours(model, ['affine:surface-ref'])) {
const reference = service.getElementById(model.reference);
if (!isAutoConnectElement(reference)) return;
if (!pageVisibleBlocks.has(reference)) {
pageVisibleBlocks.set(reference, 1);
} else {
pageVisibleBlocks.set(
reference,
pageVisibleBlocks.get(reference)! + 1
);
}
}
});
});
this._edgelessOnlyNotesSet = edgelessOnlyNotesSet;
this._pageVisibleElementsMap = pageVisibleBlocks;
};
private _EdgelessOnlyLabels() {
const { _edgelessOnlyNotesSet } = this;
if (!_edgelessOnlyNotesSet.size) return nothing;
return html`${repeat(
_edgelessOnlyNotesSet,
note => note.id,
note => {
const { viewport } = this.service;
const { zoom } = viewport;
const bound = Bound.deserialize(note.xywh);
const [left, right] = viewport.toViewCoord(bound.x, bound.y);
const [width, height] = [bound.w * zoom, bound.h * zoom];
const style = styleMap({
width: `${EDGELESS_ONLY_INDEX_LABEL_WIDTH}px`,
height: `${EDGELESS_ONLY_INDEX_LABEL_HEIGHT}px`,
borderRadius: '50%',
backgroundColor: 'var(--affine-text-secondary-color)',
border: '1px solid var(--affine-border-color)',
color: 'var(--affine-white)',
position: 'absolute',
transform: `translate(${
left + width / 2 - EDGELESS_ONLY_INDEX_LABEL_WIDTH / 2
}px,
${right + height + INDEX_LABEL_OFFSET}px)`,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
});
return html`<div style=${style} class="edgeless-only-index-label">
${HiddenIcon}
<affine-tooltip tip-position="bottom">
${getIndexLabelTooltip(SmallDocIcon, 'Hidden on page')}
</affine-tooltip>
</div>`;
}
)}`;
}
private _getElementsAndCounts() {
const elements: AutoConnectElement[] = [];
const counts: number[] = [];
for (const [key, value] of this._pageVisibleElementsMap.entries()) {
elements.push(key);
counts.push(value);
}
return { elements, counts };
}
private _initLabels() {
const { service } = this.block;
const surfaceRefs = service.doc
.getBlocksByFlavour('affine:surface-ref')
.map(block => block.model) as SurfaceRefBlockModel[];
const getVisibility = () => {
const { selectedElements } = service.selection;
if (
selectedElements.length === 1 &&
!service.selection.editing &&
(isNoteBlock(selectedElements[0]) ||
surfaceRefs.some(ref => ref.reference === selectedElements[0].id))
) {
this._show = true;
} else {
this._show = false;
}
return this._show;
};
this._disposables.add(
service.selection.slots.updated.on(() => {
getVisibility();
})
);
this._disposables.add(
this.doc.slots.blockUpdated.on(payload => {
if (payload.flavour === 'affine:surface-ref') {
switch (payload.type) {
case 'add':
surfaceRefs.push(payload.model as SurfaceRefBlockModel);
break;
case 'delete':
{
const idx = surfaceRefs.indexOf(
payload.model as SurfaceRefBlockModel
);
if (idx >= 0) {
surfaceRefs.splice(idx, 1);
}
}
break;
case 'update':
if (payload.props.key !== 'reference') {
return;
}
}
this.requestUpdate();
}
})
);
this._disposables.add(
service.surface.elementUpdated.on(payload => {
if (
payload.props['xywh'] &&
surfaceRefs.some(ref => ref.reference === payload.id)
) {
this.requestUpdate();
}
})
);
}
private _navigateToNext() {
const { elements } = this._getElementsAndCounts();
if (this._index >= elements.length - 1) return;
this._index = this._index + 1;
const element = elements[this._index];
const bound = Bound.deserialize(element.xywh);
this.service.selection.set({
elements: [element.id],
editing: false,
});
this.service.viewport.setViewportByBound(bound, [80, 80, 80, 80], true);
}
private _navigateToPrev() {
const { elements } = this._getElementsAndCounts();
if (this._index <= 0) return;
this._index = this._index - 1;
const element = elements[this._index];
const bound = Bound.deserialize(element.xywh);
this.service.selection.set({
elements: [element.id],
editing: false,
});
this.service.viewport.setViewportByBound(bound, [80, 80, 80, 80], true);
}
private _NavigatorComponent(elements: AutoConnectElement[]) {
const { viewport } = this.service;
const { zoom } = viewport;
const className = `navigator ${this._index >= 0 ? 'show' : 'hidden'}`;
const element = elements[this._index];
const bound = Bound.deserialize(element.xywh);
const [left, right] = viewport.toViewCoord(bound.x, bound.y);
const [width, height] = [bound.w * zoom, bound.h * zoom];
const navigatorStyle = styleMap({
position: 'absolute',
transform: `translate(${left + width / 2 - 26}px, ${
right + height + 16
}px)`,
});
return html`<div class=${className} style=${navigatorStyle}>
<div
role="button"
class="edgeless-auto-connect-previous-button"
@pointerdown=${(e: PointerEvent) => {
stopPropagation(e);
this._navigateToPrev();
}}
>
${AutoConnectLeftIcon}
</div>
<span></span>
<div
role="button"
class="edgeless-auto-connect-next-button"
@pointerdown=${(e: PointerEvent) => {
stopPropagation(e);
this._navigateToNext();
}}
>
${AutoConnectRightIcon}
</div>
</div> `;
}
private _PageVisibleIndexLabels(
elements: AutoConnectElement[],
counts: number[]
) {
const { viewport } = this.service;
const { zoom } = viewport;
let index = 0;
return html`${repeat(
elements,
element => element.id,
(element, i) => {
const bound = Bound.deserialize(element.xywh$.value);
const [left, right] = viewport.toViewCoord(bound.x, bound.y);
const [width, height] = [bound.w * zoom, bound.h * zoom];
const style = styleMap({
width: `${PAGE_VISIBLE_INDEX_LABEL_WIDTH}px`,
maxWidth: `${PAGE_VISIBLE_INDEX_LABEL_WIDTH}px`,
height: `${PAGE_VISIBLE_INDEX_LABEL_HEIGHT}px`,
position: 'absolute',
transform: `translate(${
left + width / 2 - PAGE_VISIBLE_INDEX_LABEL_WIDTH / 2
}px,
${right + height + INDEX_LABEL_OFFSET}px)`,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
});
const components = [];
const count = counts[i];
const initGap = 24 / count - 24;
const positions = calculatePosition(
initGap,
count,
PAGE_VISIBLE_INDEX_LABEL_HEIGHT
);
for (let j = 0; j < count; j++) {
index++;
components.push(html`
<div
style=${styleMap({
position: 'absolute',
top: positions[j][0] + 'px',
left: positions[j][1] + 'px',
transition: 'all 0.1s linear',
})}
index=${i}
class="page-visible-index-label"
@pointerdown=${(e: PointerEvent) => {
stopPropagation(e);
this._index = this._index === i ? -1 : i;
}}
>
${index}
<affine-tooltip tip-position="bottom">
${getIndexLabelTooltip(SmallDocIcon, 'Page mode index')}
</affine-tooltip>
</div>
`);
}
function updateChildrenPosition(e: MouseEvent, positions: number[][]) {
if (!e.target) return;
const children = (e.target as HTMLElement).children;
(Array.from(children) as HTMLElement[]).forEach((c, index) => {
c.style.top = positions[index][0] + 'px';
c.style.left = positions[index][1] + 'px';
});
}
return html`<div
style=${style}
@mouseenter=${(e: MouseEvent) => {
const positions = calculatePosition(
5,
count,
PAGE_VISIBLE_INDEX_LABEL_HEIGHT
);
updateChildrenPosition(e, positions);
}}
@mouseleave=${(e: MouseEvent) => {
const positions = calculatePosition(
initGap,
count,
PAGE_VISIBLE_INDEX_LABEL_HEIGHT
);
updateChildrenPosition(e, positions);
}}
>
${components}
</div>`;
}
)}`;
}
private _setHostStyle() {
this.style.position = 'absolute';
this.style.top = '0';
this.style.left = '0';
this.style.zIndex = '1';
}
override connectedCallback(): void {
super.connectedCallback();
this._setHostStyle();
this._initLabels();
}
override firstUpdated(): void {
const { _disposables, service } = this;
_disposables.add(
service.viewport.viewportUpdated.on(() => {
this.requestUpdate();
})
);
_disposables.add(
service.selection.slots.updated.on(() => {
const { selectedElements } = service.selection;
if (
!(selectedElements.length === 1 && isNoteBlock(selectedElements[0]))
) {
this._index = -1;
}
})
);
_disposables.add(
service.uiEventDispatcher.add('dragStart', () => {
this._dragging = true;
})
);
_disposables.add(
service.uiEventDispatcher.add('dragEnd', () => {
this._dragging = false;
})
);
_disposables.add(
service.slots.elementResizeStart.on(() => {
this._dragging = true;
})
);
_disposables.add(
service.slots.elementResizeEnd.on(() => {
this._dragging = false;
})
);
}
override render() {
const advancedVisibilityEnabled = this.doc.awarenessStore.getFlag(
'enable_advanced_block_visibility'
);
if (!this._show || this._dragging || !advancedVisibilityEnabled) {
return nothing;
}
this._updateLabels();
const { elements, counts } = this._getElementsAndCounts();
return html`${this._PageVisibleIndexLabels(elements, counts)}
${this._EdgelessOnlyLabels()}
${this._index >= 0 && this._index < elements.length
? this._NavigatorComponent(elements)
: nothing} `;
}
@state()
private accessor _dragging = false;
@state()
private accessor _edgelessOnlyNotesSet = new Set<NoteBlockModel>();
@state()
private accessor _index = -1;
@state()
private accessor _pageVisibleElementsMap: Map<AutoConnectElement, number> =
new Map();
@state()
private accessor _show = false;
}
declare global {
interface HTMLElementTagNameMap {
'affine-edgeless-auto-connect-widget': EdgelessAutoConnectWidget;
}
}
@@ -0,0 +1,97 @@
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 { AIItemGroupConfig } from '../../../_common/components/ai-item/types.js';
import { scrollbarStyle } from '../../../_common/components/utils.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 { 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 { AIItemGroupConfig } from '../../../_common/components/ai-item/types.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 _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 { 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 { AIItemGroupConfig } from '../../../_common/components/ai-item/types.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,289 @@
import { RemoteCursor } from '@blocksuite/affine-components/icons';
import type { RootBlockModel } from '@blocksuite/affine-model';
import { requestThrottledConnectedFrame } from '@blocksuite/affine-shared/utils';
import { WidgetComponent } from '@blocksuite/block-std';
import { assertExists, pickValues } from '@blocksuite/global/utils';
import type { UserInfo } from '@blocksuite/store';
import { css, html } from 'lit';
import { state } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js';
import type { EdgelessRootBlockComponent } from '../../../root-block/edgeless/edgeless-root-block.js';
import {
getSelectedRect,
isTopLevelBlock,
} from '../../../root-block/edgeless/utils/query.js';
import { RemoteColorManager } from '../../../root-block/remote-color-manager/remote-color-manager.js';
export const AFFINE_EDGELESS_REMOTE_SELECTION_WIDGET =
'affine-edgeless-remote-selection-widget';
export class EdgelessRemoteSelectionWidget extends WidgetComponent<
RootBlockModel,
EdgelessRootBlockComponent
> {
static override styles = css`
:host {
pointer-events: none;
position: absolute;
left: 0;
top: 0;
transform-origin: left top;
contain: size layout;
z-index: 1;
}
.remote-rect {
position: absolute;
top: 0;
left: 0;
border-radius: 4px;
box-sizing: border-box;
border-width: 3px;
z-index: 1;
transform-origin: center center;
}
.remote-cursor {
position: absolute;
top: 0;
left: 0;
transform-origin: left top;
z-index: 1;
}
.remote-cursor > svg {
display: block;
}
.remote-username {
margin-left: 22px;
margin-top: -2px;
color: white;
max-width: 160px;
padding: 0px 3px;
border: 1px solid var(--affine-pure-black-20);
box-shadow: 0px 1px 6px 0px rgba(0, 0, 0, 0.16);
border-radius: 4px;
font-size: 12px;
line-height: 18px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
`;
private _remoteColorManager: RemoteColorManager | null = null;
private _updateOnElementChange = (element: string | { id: string }) => {
const id = typeof element === 'string' ? element : element.id;
if (this.isConnected && this.selection.hasRemote(id))
this._updateRemoteRects();
};
private _updateRemoteCursor = () => {
const remoteCursors: EdgelessRemoteSelectionWidget['_remoteCursors'] =
new Map();
const status = this.doc.awarenessStore.getStates();
this.selection.remoteCursorSelectionMap.forEach(
(cursorSelection, clientId) => {
remoteCursors.set(clientId, {
x: cursorSelection.x,
y: cursorSelection.y,
user: status.get(clientId)?.user,
});
}
);
this._remoteCursors = remoteCursors;
};
private _updateRemoteRects = () => {
const { selection, block } = this;
const remoteSelectionsMap = selection.remoteSurfaceSelectionsMap;
const remoteRects: EdgelessRemoteSelectionWidget['_remoteRects'] =
new Map();
remoteSelectionsMap.forEach((selections, clientId) => {
selections.forEach(selection => {
if (selection.elements.length === 0) return;
const elements = selection.elements
.map(id => block.service.getElementById(id))
.filter(element => element) as BlockSuite.EdgelessModel[];
const rect = getSelectedRect(elements);
if (rect.width === 0 || rect.height === 0) return;
const { left, top } = rect;
const [width, height] = [rect.width, rect.height];
let rotate = 0;
if (elements.length === 1) {
const element = elements[0];
if (!isTopLevelBlock(element)) {
rotate = element.rotate ?? 0;
}
}
remoteRects.set(clientId, {
width,
height,
borderStyle: 'solid',
left,
top,
rotate,
});
});
});
this._remoteRects = remoteRects;
};
private _updateTransform = requestThrottledConnectedFrame(() => {
const { translateX, translateY, zoom } = this.edgeless.service.viewport;
this.style.setProperty('--v-zoom', `${zoom}`);
this.style.setProperty(
'transform',
`translate(${translateX}px, ${translateY}px) scale(var(--v-zoom))`
);
}, this);
get edgeless() {
return this.block;
}
get selection() {
return this.edgeless.service.selection;
}
get surface() {
return this.edgeless.surface;
}
override connectedCallback() {
super.connectedCallback();
const { _disposables, doc, edgeless } = this;
pickValues(edgeless.service.surface, [
'elementAdded',
'elementRemoved',
'elementUpdated',
]).forEach(slot => {
_disposables.add(slot.on(this._updateOnElementChange));
});
_disposables.add(doc.slots.blockUpdated.on(this._updateOnElementChange));
_disposables.add(
this.selection.slots.remoteUpdated.on(this._updateRemoteRects)
);
_disposables.add(
this.selection.slots.remoteCursorUpdated.on(this._updateRemoteCursor)
);
_disposables.add(
edgeless.service.viewport.viewportUpdated.on(() => {
this._updateTransform();
})
);
this._updateTransform();
this._updateRemoteRects();
this._remoteColorManager = new RemoteColorManager(this.std);
}
override render() {
const { _remoteRects, _remoteCursors, _remoteColorManager } = this;
assertExists(_remoteColorManager);
const rects = repeat(
_remoteRects.entries(),
value => value[0],
([id, rect]) =>
html`<div
data-client-id=${id}
class="remote-rect"
style=${styleMap({
pointerEvents: 'none',
width: `${rect.width}px`,
height: `${rect.height}px`,
borderStyle: rect.borderStyle,
borderColor: _remoteColorManager.get(id),
transform: `translate(${rect.left}px, ${rect.top}px) rotate(${rect.rotate}deg)`,
})}
></div>`
);
const cursors = repeat(
_remoteCursors.entries(),
value => value[0],
([id, cursor]) => {
return html`<div
data-client-id=${id}
class="remote-cursor"
style=${styleMap({
pointerEvents: 'none',
transform: `translate(${cursor.x}px, ${cursor.y}px) scale(calc(1/var(--v-zoom)))`,
color: _remoteColorManager.get(id),
})}
>
${RemoteCursor}
<div
class="remote-username"
style=${styleMap({
backgroundColor: _remoteColorManager.get(id),
})}
>
${cursor.user?.name ?? 'Unknown'}
</div>
</div>`;
}
);
return html`
<div class="affine-edgeless-remote-selection">${rects}${cursors}</div>
`;
}
@state()
private accessor _remoteCursors: Map<
number,
{
x: number;
y: number;
user?: UserInfo | undefined;
}
> = new Map();
@state()
private accessor _remoteRects: Map<
number,
{
width: number;
height: number;
borderStyle: string;
left: number;
top: number;
rotate: number;
}
> = new Map();
}
declare global {
interface HTMLElementTagNameMap {
[AFFINE_EDGELESS_REMOTE_SELECTION_WIDGET]: EdgelessRemoteSelectionWidget;
}
}
@@ -0,0 +1,96 @@
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,
edgeless: { slots },
} = this;
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,114 @@
import { MoreIcon } from '@blocksuite/affine-components/icons';
import { createLitPortal } from '@blocksuite/affine-components/portal';
import { stopPropagation } from '@blocksuite/affine-shared/utils';
import { WithDisposable } from '@blocksuite/global/utils';
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}
@click=${() => this._toggleZoomMenu()}
>
${MoreIcon}
</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,213 @@
import {
MinusIcon,
PlusIcon,
ViewBarIcon,
} from '@blocksuite/affine-components/icons';
import { stopPropagation } from '@blocksuite/affine-shared/utils';
import { WithDisposable } from '@blocksuite/global/utils';
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';
import { ZOOM_STEP } from '../../edgeless/utils/zoom.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 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.edgelessService.zoomToFit()}
.iconContainerPadding=${4}
.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}
.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}
.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,62 @@
import { FrameIcon } from '@blocksuite/affine-components/icons';
import { MindmapElementModel } from '@blocksuite/affine-model';
import { TelemetryProvider } from '@blocksuite/affine-shared/services';
import { Bound, 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 EdgelessAddFrameButton extends WithDisposable(LitElement) {
static override styles = css`
.label {
padding-left: 4px;
}
`;
private _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'}
@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: BlockSuite.EdgelessModel[]
) {
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,54 @@
import { GroupIcon } from '@blocksuite/affine-components/icons';
import {
GroupElementModel,
MindmapElementModel,
} from '@blocksuite/affine-model';
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 EdgelessAddGroupButton extends WithDisposable(LitElement) {
static override styles = css`
.label {
padding-left: 4px;
}
`;
private _createGroup = () => {
this.edgeless.service.createGroupFromSelected();
};
protected override render() {
return html`
<editor-icon-button
aria-label="Group"
.tooltip=${'Group'}
.labelHeight=${'20px'}
@click=${this._createGroup}
>
${GroupIcon}<span class="label medium">Group</span>
</editor-icon-button>
`;
}
@property({ attribute: false })
accessor edgeless!: EdgelessRootBlockComponent;
}
export function renderAddGroupButton(
edgeless: EdgelessRootBlockComponent,
elements: BlockSuite.EdgelessModel[]
) {
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,340 @@
import { updateXYWH } from '@blocksuite/affine-block-surface';
import {
AlignBottomIcon,
AlignDistributeHorizontallyIcon,
AlignDistributeVerticallyIcon,
AlignHorizontallyIcon,
AlignLeftIcon,
AlignRightIcon,
AlignTopIcon,
AlignVerticallyIcon,
SmallArrowDownIcon,
} from '@blocksuite/affine-components/icons';
import { MindmapElementModel } from '@blocksuite/affine-model';
import { Bound, WithDisposable } from '@blocksuite/global/utils';
import { AutoTidyUpIcon, 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';
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 HORIZONTAL_ALIGNMENT: AlignmentIcon[] = [
{
name: Alignment.Left,
content: AlignLeftIcon,
},
{
name: Alignment.Horizontally,
content: AlignHorizontallyIcon,
},
{
name: Alignment.Right,
content: AlignRightIcon,
},
{
name: Alignment.DistributeHorizontally,
content: AlignDistributeHorizontallyIcon,
},
];
const VERTICAL_ALIGNMENT: AlignmentIcon[] = [
{
name: Alignment.Top,
content: AlignTopIcon,
},
{
name: Alignment.Vertically,
content: AlignVerticallyIcon,
},
{
name: Alignment.Bottom,
content: AlignBottomIcon,
},
{
name: Alignment.DistributeVertically,
content: AlignDistributeVerticallyIcon,
},
];
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('autoArrangeElements');
break;
case Alignment.AutoResize:
this.edgeless.std.command.exec('autoResizeElements');
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: BlockSuite.EdgelessModel, bound: Bound) {
const { updateElement } = this.edgeless.service;
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}
@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}${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: BlockSuite.EdgelessModel[]
) {
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,159 @@
import {
CaptionIcon,
DownloadIcon,
PaletteIcon,
} from '@blocksuite/affine-components/icons';
import { renderToolbarSeparator } from '@blocksuite/affine-components/toolbar';
import type { AttachmentBlockModel } from '@blocksuite/affine-model';
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 {
EMBED_CARD_HEIGHT,
EMBED_CARD_WIDTH,
} from '../../../_common/consts.js';
import type { EmbedCardStyle } from '../../../_common/types.js';
import { getEmbedCardIcons } from '../../../_common/utils/url.js';
import type { AttachmentBlockComponent } from '../../../attachment-block/index.js';
import { attachmentViewToggleMenu } from '../../../attachment-block/index.js';
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
export class EdgelessChangeAttachmentButton extends WithDisposable(LitElement) {
private _download = () => {
this._block?.download();
};
private _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 _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 type {
BrushElementModel,
BrushProps,
ColorScheme,
} from '@blocksuite/affine-model';
import { LINE_COLORS, LineWidth } from '@blocksuite/affine-model';
import { countBy, maxBy, WithDisposable } from '@blocksuite/global/utils';
import { html, LitElement, nothing } from 'lit';
import { property, query, state } from 'lit/decorators.js';
import { when } from 'lit/directives/when.js';
import type { EdgelessColorPickerButton } from '../../edgeless/components/color-picker/button.js';
import type { PickColorEvent } from '../../edgeless/components/color-picker/types.js';
import {
packColor,
packColorsWithColorScheme,
} from '../../edgeless/components/color-picker/utils.js';
import type { ColorEvent } from '../../edgeless/components/panel/color-panel.js';
import { GET_DEFAULT_LINE_COLOR } from '../../edgeless/components/panel/color-panel.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) => {
return typeof ele.color === 'object'
? (ele.color[colorScheme] ?? ele.color.normal ?? null)
: ele.color;
});
const max = maxBy(Object.entries(colors), ([_k, count]) => count);
return max ? (max[0] as string) : GET_DEFAULT_LINE_COLOR(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 _setBrushColor = ({ detail: color }: ColorEvent) => {
this._setBrushProp('color', color);
this._selectedColor = color;
};
private _setLineWidth = ({ detail: lineWidth }: LineWidthEvent) => {
this._setBrushProp('lineWidth', lineWidth);
this._selectedSize = lineWidth;
};
pickColor = (event: PickColorEvent) => {
if (event.type === 'pick') {
this.elements.forEach(ele =>
this.service.updateElement(
ele.id,
packColor('color', { ...event.detail })
)
);
return;
}
this.elements.forEach(ele =>
ele[event.type === 'start' ? 'stash' : 'pop']('color')
);
};
get doc() {
return this.edgeless.doc;
}
get selectedColor() {
const colorScheme = this.edgeless.surface.renderer.getColorScheme();
return (
this._selectedColor ?? getMostCommonColor(this.elements, colorScheme)
);
}
get selectedSize() {
return this._selectedSize ?? getMostCommonSize(this.elements);
}
get service() {
return this.edgeless.service;
}
get surface() {
return this.edgeless.surface;
}
private _setBrushProp<K extends keyof BrushProps>(
key: K,
value: BrushProps[K]
) {
this.doc.captureSync();
this.elements
.filter(notEqual(key, value))
.forEach(element =>
this.service.updateElement(element.id, { [key]: value })
);
}
override render() {
const colorScheme = this.edgeless.surface.renderer.getColorScheme();
const elements = this.elements;
const { selectedSize, selectedColor } = this;
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.awarenessStore.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}
.palettes=${LINE_COLORS}
>
</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}
@select=${this._setBrushColor}
>
</edgeless-color-panel>
</editor-menu-button>
`
)}
`;
}
@state()
private accessor _selectedColor: string | null = null;
@state()
private accessor _selectedSize: LineWidth | null = null;
@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,626 @@
import {
AddTextIcon,
ConnectorCWithArrowIcon,
ConnectorEndpointNoneIcon,
ConnectorLWithArrowIcon,
ConnectorXWithArrowIcon,
FlipDirectionIcon,
FrontEndpointArrowIcon,
FrontEndpointCircleIcon,
FrontEndpointDiamondIcon,
FrontEndpointTriangleIcon,
GeneralStyleIcon,
RearEndpointArrowIcon,
RearEndpointCircleIcon,
RearEndpointDiamondIcon,
RearEndpointTriangleIcon,
ScribbledStyleIcon,
SmallArrowDownIcon,
} from '@blocksuite/affine-components/icons';
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,
LINE_COLORS,
LineWidth,
PointStyle,
StrokeStyle,
} from '@blocksuite/affine-model';
import { countBy, maxBy, WithDisposable } from '@blocksuite/global/utils';
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 { EdgelessColorPickerButton } from '../../edgeless/components/color-picker/button.js';
import type { PickColorEvent } from '../../edgeless/components/color-picker/types.js';
import {
packColor,
packColorsWithColorScheme,
} from '../../edgeless/components/color-picker/utils.js';
import {
type ColorEvent,
GET_DEFAULT_LINE_COLOR,
} from '../../edgeless/components/panel/color-panel.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';
function getMostCommonColor(
elements: ConnectorElementModel[],
colorScheme: ColorScheme
): string | null {
const colors = countBy(elements, (ele: ConnectorElementModel) => {
return typeof ele.stroke === 'object'
? (ele.stroke[colorScheme] ?? ele.stroke.normal ?? null)
: ele.stroke;
});
const max = maxBy(Object.entries(colors), ([_k, count]) => count);
return max ? (max[0] as string) : null;
}
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 | null {
const sizes = countBy(elements, ele => ele.strokeStyle);
const max = maxBy(Object.entries(sizes), ([_k, count]) => count);
return max ? (max[0] as StrokeStyle) : null;
}
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
): PointStyle | null {
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) : null;
}
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 STYLE_LIST = [
{
name: 'General',
value: false,
icon: GeneralStyleIcon,
},
{
name: 'Scribbled',
value: true,
icon: ScribbledStyleIcon,
},
] as const;
const STYLE_CHOOSE: [boolean, () => TemplateResult<1>][] = [
[false, () => GeneralStyleIcon],
[true, () => ScribbledStyleIcon],
] as const;
const FRONT_ENDPOINT_STYLE_LIST: EndpointStyle[] = [
{
value: PointStyle.None,
icon: ConnectorEndpointNoneIcon,
},
{
value: PointStyle.Arrow,
icon: FrontEndpointArrowIcon,
},
{
value: PointStyle.Triangle,
icon: FrontEndpointTriangleIcon,
},
{
value: PointStyle.Circle,
icon: FrontEndpointCircleIcon,
},
{
value: PointStyle.Diamond,
icon: FrontEndpointDiamondIcon,
},
] as const;
const REAR_ENDPOINT_STYLE_LIST: EndpointStyle[] = [
{
value: PointStyle.Diamond,
icon: RearEndpointDiamondIcon,
},
{
value: PointStyle.Circle,
icon: RearEndpointCircleIcon,
},
{
value: PointStyle.Triangle,
icon: RearEndpointTriangleIcon,
},
{
value: PointStyle.Arrow,
icon: RearEndpointArrowIcon,
},
{
value: PointStyle.None,
icon: ConnectorEndpointNoneIcon,
},
] as const;
const MODE_LIST = [
{
name: 'Curve',
icon: ConnectorCWithArrowIcon,
value: ConnectorMode.Curve,
},
{
name: 'Elbowed',
icon: ConnectorXWithArrowIcon,
value: ConnectorMode.Orthogonal,
},
{
name: 'Straight',
icon: ConnectorLWithArrowIcon,
value: ConnectorMode.Straight,
},
] as const;
const MODE_CHOOSE: [ConnectorMode, () => TemplateResult<1>][] = [
[ConnectorMode.Curve, () => ConnectorCWithArrowIcon],
[ConnectorMode.Orthogonal, () => ConnectorXWithArrowIcon],
[ConnectorMode.Straight, () => ConnectorLWithArrowIcon],
] as const;
export class EdgelessChangeConnectorButton extends WithDisposable(LitElement) {
pickColor = (event: PickColorEvent) => {
if (event.type === 'pick') {
this.elements.forEach(ele =>
this.service.updateElement(
ele.id,
packColor('stroke', { ...event.detail })
)
);
return;
}
this.elements.forEach(ele =>
ele[event.type === 'start' ? 'stash' : 'pop']('stroke')
);
};
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.service.updateElement(element.id, {
frontEndpointStyle: rearEndpointStyle,
rearEndpointStyle: frontEndpointStyle,
})
);
}
private _getEndpointIcon(list: EndpointStyle[], style: PointStyle) {
return (
list.find(({ value }) => value === style)?.icon ||
ConnectorEndpointNoneIcon
);
}
private _setConnectorColor(stroke: string) {
this._setConnectorProp('stroke', stroke);
}
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.service.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.service.updateElement(element.id, { [key]: value })
);
}
private _setConnectorRough(rough: boolean) {
this._setConnectorProp('rough', rough);
}
private _setConnectorStroke({ type, value }: LineStyleEvent) {
if (type === 'size') {
this._setConnectorStrokeWidth(value);
return;
}
this._setConnectorStrokeStyle(value);
}
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) ??
GET_DEFAULT_LINE_COLOR(colorScheme);
const selectedMode = getMostCommonMode(elements);
const selectedLineSize = getMostCommonLineWidth(elements) ?? LineWidth.Four;
const selectedRough = getMostCommonRough(elements);
const selectedLineStyle =
getMostCommonLineStyle(elements) ?? StrokeStyle.Solid;
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.awarenessStore.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}
.palettes=${LINE_COLORS}
.hollowCircle=${true}
>
<div
slot="other"
class="line-styles"
style=${styleMap({
display: 'flex',
flexDirection: 'row',
gap: '8px',
alignItems: 'center',
})}
>
${LineStylesPanel({
selectedLineSize: selectedLineSize,
selectedLineStyle: selectedLineStyle,
onClick: (e: LineStyleEvent) => this._setConnectorStroke(e),
lineStyles: [StrokeStyle.Solid, StrokeStyle.Dash],
})}
</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'}
>
<edgeless-color-button
.color=${selectedColor}
></edgeless-color-button>
</editor-icon-button>
`}
>
<stroke-style-panel
.strokeWidth=${selectedLineSize}
.strokeStyle=${selectedLineStyle}
.strokeColor=${selectedColor}
.setStrokeStyle=${(e: LineStyleEvent) =>
this._setConnectorStroke(e)}
.setStrokeColor=${(e: ColorEvent) =>
this._setConnectorColor(e.detail)}
>
</stroke-style-panel>
</editor-menu-button>
`
),
html`
<editor-menu-button
.button=${html`
<editor-icon-button aria-label="Style" .tooltip=${'Style'}>
${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'}
@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'}
>
${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'}
@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}
@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'}
>
${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'}
@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'}
>
${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'}
@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'}
@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,869 @@
import { getDocContentWithMaxLength } from '@blocksuite/affine-block-embed';
import {
CaptionIcon,
CenterPeekIcon,
CopyIcon,
EditIcon,
ExpandFullSmallIcon,
OpenIcon,
PaletteIcon,
SmallArrowDownIcon,
} from '@blocksuite/affine-components/icons';
import { 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 } from '@blocksuite/affine-model';
import {
EmbedOptionProvider,
type EmbedOptions,
GenerateDocUrlProvider,
type GenerateDocUrlService,
type LinkEventType,
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 { toggleEmbedCardEditModal } from '../../../_common/components/embed-card/modal/embed-card-edit-modal.js';
import type {
EmbedBlockComponent,
EmbedModel,
} from '../../../_common/components/embed-card/type.js';
import { isInternalEmbedModel } from '../../../_common/components/embed-card/type.js';
import {
EMBED_CARD_HEIGHT,
EMBED_CARD_WIDTH,
} from '../../../_common/consts.js';
import type { EmbedCardStyle } from '../../../_common/types.js';
import { getEmbedCardIcons } from '../../../_common/utils/url.js';
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
import {
isBookmarkBlock,
isEmbedGithubBlock,
isEmbedHtmlBlock,
isEmbedLinkedDocBlock,
isEmbedSyncedDocBlock,
} from '../../edgeless/utils/query.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;
}
`;
private _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.edgeless.service.addBlock(
targetFlavour,
{ url, xywh: bound.serialize(), style: targetStyle, caption },
this.edgeless.surface.model
);
this.std.command.exec('reassociateConnectors', {
oldId: id,
newId,
});
this.edgeless.service.selection.set({
editing: false,
elements: [newId],
});
this._doc.deleteBlock(this.model);
};
private _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.edgeless.service.addBlock(
flavour,
{
url,
xywh: bound.serialize(),
style: targetStyle,
},
this.edgeless.surface.model
);
this.std.command.exec('reassociateConnectors', {
oldId: id,
newId,
});
this.edgeless.service.selection.set({
editing: false,
elements: [newId],
});
this._doc.deleteBlock(this.model);
};
private _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 _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 _open = () => {
this._blockComponent?.open();
};
private _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
);
track(this.std, this.model, this._viewType, 'OpenedAliasPopup', {
control: 'edit',
});
};
private _peek = () => {
if (!this._blockComponent) return;
peek(this._blockComponent);
};
private _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 _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 _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 _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 _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 _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 EmbedBlockComponent | 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.awarenessStore.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 _openButtonDisabled() {
return (
isEmbedLinkedDocBlock(this.model) && this.model.pageId === this._doc.id
);
}
get _originalDocInfo(): AliasInfo | undefined {
const model = this.model;
const doc = isInternalEmbedModel(model)
? this.std.collection.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.collection.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 buttons: MenuItem[] = [];
if (
isEmbedLinkedDocBlock(this.model) ||
isEmbedSyncedDocBlock(this.model)
) {
buttons.push({
type: 'open-this-doc',
label: 'Open this doc',
icon: ExpandFullSmallIcon,
action: this._open,
disabled: this._openButtonDisabled,
});
} else if (this._canShowFullScreenButton) {
buttons.push({
type: 'open-this-doc',
label: 'Open this doc',
icon: ExpandFullSmallIcon,
action: this._open,
});
}
// open in new tab
if (this._blockComponent && isPeekable(this._blockComponent)) {
buttons.push({
type: 'open-in-center-peek',
label: 'Open in center peek',
icon: CenterPeekIcon,
action: () => this._peek(),
});
}
// open in split view
if (buttons.length === 0) {
return nothing;
}
return html`
<editor-menu-button
.contentPadding=${'8px'}
.button=${html`
<editor-icon-button
aria-label="Open"
.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!: EmbedModel;
@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: EmbedModel,
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,257 @@
import {
NoteIcon,
RenameIcon,
UngroupButtonIcon,
} from '@blocksuite/affine-components/icons';
import { toast } from '@blocksuite/affine-components/toast';
import { renderToolbarSeparator } from '@blocksuite/affine-components/toolbar';
import {
type ColorScheme,
DEFAULT_NOTE_HEIGHT,
FRAME_BACKGROUND_COLORS,
type FrameBlockModel,
NoteDisplayMode,
} from '@blocksuite/affine-model';
import { matchFlavours } from '@blocksuite/affine-shared/utils';
import { GfxExtensionIdentifier } from '@blocksuite/block-std/gfx';
import {
countBy,
deserializeXYWH,
maxBy,
serializeXYWH,
WithDisposable,
} from '@blocksuite/global/utils';
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 { EdgelessColorPickerButton } from '../../edgeless/components/color-picker/button.js';
import type { PickColorEvent } from '../../edgeless/components/color-picker/types.js';
import {
packColor,
packColorsWithColorScheme,
} from '../../edgeless/components/color-picker/utils.js';
import type { ColorEvent } from '../../edgeless/components/panel/color-panel.js';
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
import type { EdgelessFrameManager } from '../../edgeless/frame-manager.js';
import { mountFrameTitleEditor } from '../../edgeless/utils/text.js';
function getMostCommonColor(
elements: FrameBlockModel[],
colorScheme: ColorScheme
): string | null {
const colors = countBy(elements, (ele: FrameBlockModel) => {
return typeof ele.background === 'object'
? (ele.background[colorScheme] ?? ele.background.normal ?? null)
: ele.background;
});
const max = maxBy(Object.entries(colors), ([_k, count]) => count);
return max ? (max[0] as string) : null;
}
export class EdgelessChangeFrameButton extends WithDisposable(LitElement) {
pickColor = (event: PickColorEvent) => {
if (event.type === 'pick') {
this.frames.forEach(ele =>
this.service.updateElement(
ele.id,
packColor('background', { ...event.detail })
)
);
return;
}
this.frames.forEach(ele =>
ele[event.type === 'start' ? 'stash' : 'pop']('background')
);
};
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 =>
matchFlavours(model, ['affine:note']) &&
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');
}
private _setFrameBackground(color: string) {
this.frames.forEach(frame => {
this.service.updateElement(frame.id, { background: color });
});
}
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) ?? '--affine-palette-transparent';
return join(
[
onlyOne
? html`
<editor-icon-button
aria-label=${'Insert into Page'}
.tooltip=${'Insert into Page'}
.iconSize=${'20px'}
.labelHeight=${'20px'}
@click=${this._insertIntoPage}
>
${NoteIcon}
<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)}
>
${RenameIcon}
</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();
}}
>
${UngroupButtonIcon}
</editor-icon-button>
`,
when(
this.edgeless.doc.awarenessStore.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}
.palettes=${FRAME_BACKGROUND_COLORS}
>
</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}
.options=${FRAME_BACKGROUND_COLORS}
@select=${(e: ColorEvent) => this._setFrameBackground(e.detail)}
>
</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 {
NoteIcon,
RenameIcon,
UngroupButtonIcon,
} from '@blocksuite/affine-components/icons';
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, NoteDisplayMode } from '@blocksuite/affine-model';
import { matchFlavours } from '@blocksuite/affine-shared/utils';
import {
deserializeXYWH,
serializeXYWH,
WithDisposable,
} from '@blocksuite/global/utils';
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 =>
matchFlavours(model, ['affine:note']) &&
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}
>
${NoteIcon}
<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)}
>
${RenameIcon}
</editor-icon-button>
`
: nothing,
html`
<editor-icon-button
aria-label="Ungroup"
.tooltip=${'Ungroup'}
.iconSize=${'20px'}
@click=${() =>
groups.forEach(group => this.edgeless.service.ungroup(group))}
>
${UngroupButtonIcon}
</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,85 @@
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 { ImageBlockComponent } from '../../../image-block/image-block.js';
import { downloadImageBlob } from '../../../image-block/utils.js';
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
export class EdgelessChangeImageButton extends WithDisposable(LitElement) {
private _download = () => {
if (!this._blockComponent) return;
downloadImageBlob(this._blockComponent).catch(console.error);
};
private _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,296 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import {
MindmapBalanceLayoutIcon,
MindmapLeftLayoutIcon,
MindmapRightLayoutIcon,
MindmapStyleFour,
MindmapStyleIcon,
MindmapStyleOne,
MindmapStyleThree,
MindmapStyleTwo,
SmallArrowDownIcon,
} from '@blocksuite/affine-components/icons';
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 { 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';
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: MindmapLeftLayoutIcon,
},
{
name: 'Radial',
value: LayoutType.BALANCE,
icon: MindmapBalanceLayoutIcon,
},
{
name: 'Right',
value: LayoutType.RIGHT,
icon: MindmapRightLayoutIcon,
},
] 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 _updateLayoutType = (layoutType: LayoutType) => {
this.edgeless.std.get(EditPropsStore).recordLastProps('mindmap', {
layoutType,
});
this.elements.forEach(element => {
element.layoutType = layoutType;
element.layout();
});
this.layoutType = layoutType;
};
private _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'}>
${MindmapStyleIcon}${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,499 @@
import {
ExpandIcon,
LineStyleIcon,
NoteCornerIcon,
NoteShadowIcon,
ScissorsIcon,
ShrinkIcon,
SmallArrowDownIcon,
} from '@blocksuite/affine-components/icons';
import {
type EditorMenuButton,
renderToolbarSeparator,
} from '@blocksuite/affine-components/toolbar';
import {
type ColorScheme,
DEFAULT_NOTE_BACKGROUND_COLOR,
NOTE_BACKGROUND_COLORS,
type NoteBlockModel,
NoteDisplayMode,
type StrokeStyle,
} from '@blocksuite/affine-model';
import { ThemeProvider } from '@blocksuite/affine-shared/services';
import { matchFlavours } from '@blocksuite/affine-shared/utils';
import {
assertExists,
Bound,
countBy,
maxBy,
WithDisposable,
} from '@blocksuite/global/utils';
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 {
EdgelessColorPickerButton,
PickColorEvent,
} from '../../edgeless/components/color-picker/index.js';
import {
packColor,
packColorsWithColorScheme,
} from '../../edgeless/components/color-picker/utils.js';
import type { ColorEvent } from '../../edgeless/components/panel/color-panel.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';
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 | null {
const colors = countBy(elements, (ele: NoteBlockModel) => {
return typeof ele.background === 'object'
? (ele.background[colorScheme] ?? ele.background.normal ?? null)
: ele.background;
});
const max = maxBy(Object.entries(colors), ([_k, count]) => count);
return max ? (max[0] as string) : null;
}
export class EdgelessChangeNoteButton extends WithDisposable(LitElement) {
private _setBorderRadius = (borderRadius: number) => {
this.notes.forEach(note => {
const props = {
edgeless: {
style: {
...note.edgeless.style,
borderRadius,
},
},
};
this.edgeless.service.updateElement(note.id, props);
});
};
private _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 = (event: PickColorEvent) => {
if (event.type === 'pick') {
this.notes.forEach(element => {
const props = packColor('background', { ...event.detail });
this.edgeless.service.updateElement(element.id, props);
});
return;
}
this.notes.forEach(ele =>
ele[event.type === 'start' ? 'stash' : 'pop']('background')
);
};
private get _advancedVisibilityEnabled() {
return this.doc.awarenessStore.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 _setBackground(background: string) {
this.notes.forEach(element => {
this.edgeless.service.updateElement(element.id, { background });
});
}
private _setCollapse() {
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 { displayMode: currentMode } = note;
if (newMode === currentMode) {
return;
}
this.edgeless.service.updateElement(note.id, { displayMode: newMode });
const noteParent = this.doc.getParent(note);
assertExists(noteParent);
const noteParentChildNotes = noteParent.children.filter(block =>
matchFlavours(block, ['affine:note'])
) as NoteBlockModel[];
const noteParentLastNote =
noteParentChildNotes[noteParentChildNotes.length - 1];
if (
currentMode === NoteDisplayMode.EdgelessOnly &&
newMode !== NoteDisplayMode.EdgelessOnly &&
note !== noteParentLastNote
) {
// move to the end
this.doc.moveBlocks([note], noteParent, noteParentLastNote, false);
}
// if change note to page only, should clear the selection
if (newMode === NoteDisplayMode.DocOnly) {
this.edgeless.service.selection.clear();
}
}
private _setShadowType(shadowType: string) {
this.notes.forEach(note => {
const props = {
edgeless: {
style: {
...note.edgeless.style,
shadowType,
},
},
};
this.edgeless.service.updateElement(note.id, props);
});
}
private _setStrokeStyle(borderStyle: StrokeStyle) {
this.notes.forEach(note => {
const props = {
edgeless: {
style: {
...note.edgeless.style,
borderStyle,
},
},
};
this.edgeless.service.updateElement(note.id, props);
});
}
private _setStrokeWidth(borderSize: number) {
this.notes.forEach(note => {
const props = {
edgeless: {
style: {
...note.edgeless.style,
borderSize,
},
},
};
this.edgeless.service.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) ??
DEFAULT_NOTE_BACKGROUND_COLOR;
const { collapse } = edgeless;
const scale = edgeless.scale ?? 1;
const currentMode = DisplayModeMap[displayMode];
const onlyOne = len === 1;
const isDocOnly = displayMode === NoteDisplayMode.DocOnly;
const theme = this.edgeless.std.get(ThemeProvider).theme;
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,
isDocOnly
? nothing
: when(
this.edgeless.doc.awarenessStore.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}
.colorType=${type}
.colors=${colors}
.palettes=${NOTE_BACKGROUND_COLORS}
>
</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}
.options=${NOTE_BACKGROUND_COLORS}
@select=${(e: ColorEvent) => this._setBackground(e.detail)}
>
</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'}
>
${NoteShadowIcon}${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}${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'}>
${NoteCornerIcon}${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}
@click=${() => this._handleNoteSlicerButtonClick()}
>
${ScissorsIcon}
</editor-icon-button>
`
: nothing,
onlyOne ? this.quickConnectButton : nothing,
html`
<editor-icon-button
aria-label="Size"
.tooltip=${collapse ? 'Auto height' : 'Customized height'}
@click=${() => this._setCollapse()}
>
${collapse ? ExpandIcon : ShrinkIcon}
</editor-icon-button>
<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,509 @@
import {
AddTextIcon,
ChangeShapeIcon,
GeneralStyleIcon,
ScribbledStyleIcon,
SmallArrowDownIcon,
} from '@blocksuite/affine-components/icons';
import { renderToolbarSeparator } from '@blocksuite/affine-components/toolbar';
import type {
ColorScheme,
ShapeElementModel,
ShapeProps,
} from '@blocksuite/affine-model';
import {
DEFAULT_SHAPE_FILL_COLOR,
DEFAULT_SHAPE_STROKE_COLOR,
FontFamily,
getShapeName,
getShapeRadius,
getShapeType,
LineWidth,
MindmapElementModel,
SHAPE_FILL_COLORS,
SHAPE_STROKE_COLORS,
ShapeStyle,
StrokeStyle,
} from '@blocksuite/affine-model';
import { countBy, maxBy, WithDisposable } from '@blocksuite/global/utils';
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 type { EdgelessColorPickerButton } from '../../edgeless/components/color-picker/button.js';
import type { PickColorEvent } from '../../edgeless/components/color-picker/types.js';
import {
packColor,
packColorsWithColorScheme,
} from '../../edgeless/components/color-picker/utils.js';
import {
type ColorEvent,
GET_DEFAULT_LINE_COLOR,
isTransparent,
} from '../../edgeless/components/panel/color-panel.js';
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 {
SHAPE_FILL_COLOR_BLACK,
SHAPE_TEXT_COLOR_PURE_BLACK,
SHAPE_TEXT_COLOR_PURE_WHITE,
} from '../../edgeless/utils/consts.js';
import { mountShapeTextEditor } from '../../edgeless/utils/text.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 | null {
const colors = countBy(elements, (ele: ShapeElementModel) => {
if (ele.filled) {
return typeof ele.fillColor === 'object'
? (ele.fillColor[colorScheme] ?? ele.fillColor.normal ?? null)
: ele.fillColor;
}
return '--affine-palette-transparent';
});
const max = maxBy(Object.entries(colors), ([_k, count]) => count);
return max ? (max[0] as string) : null;
}
function getMostCommonStrokeColor(
elements: ShapeElementModel[],
colorScheme: ColorScheme
): string | null {
const colors = countBy(elements, (ele: ShapeElementModel) => {
return typeof ele.strokeColor === 'object'
? (ele.strokeColor[colorScheme] ?? ele.strokeColor.normal ?? null)
: ele.strokeColor;
});
const max = maxBy(Object.entries(colors), ([_k, count]) => count);
return max ? (max[0] as string) : null;
}
function getMostCommonShape(
elements: ShapeElementModel[]
): ShapeToolOption['shapeName'] | null {
const shapeTypes = countBy(elements, (ele: ShapeElementModel) => {
return 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) => {
return 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 | null {
const sizes = countBy(elements, (ele: ShapeElementModel) => ele.strokeStyle);
const max = maxBy(Object.entries(sizes), ([_k, count]) => count);
return max ? (max[0] as StrokeStyle) : null;
}
function getMostCommonShapeStyle(elements: ShapeElementModel[]): ShapeStyle {
const roughnesses = countBy(elements, (ele: ShapeElementModel) => {
return 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];
get service() {
return this.edgeless.service;
}
#pickColor<K extends keyof Pick<ShapeProps, 'fillColor' | 'strokeColor'>>(
key: K
) {
return (event: PickColorEvent) => {
if (event.type === 'pick') {
this.elements.forEach(ele => {
const props = packColor(key, { ...event.detail });
// If `filled` can be set separately, this logic can be removed
if (key === 'fillColor' && !ele.filled) {
Object.assign(props, { filled: true });
}
this.service.updateElement(ele.id, props);
});
return;
}
this.elements.forEach(ele =>
ele[event.type === 'start' ? 'stash' : 'pop'](key)
);
};
}
private _addText() {
mountShapeTextEditor(this.elements[0], this.edgeless);
}
private _getTextColor(fillColor: string) {
const colorScheme = this.edgeless.surface.renderer.getColorScheme();
// 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.
const textColor = isTransparent(fillColor)
? GET_DEFAULT_LINE_COLOR(colorScheme)
: fillColor === SHAPE_FILL_COLOR_BLACK
? SHAPE_TEXT_COLOR_PURE_WHITE
: SHAPE_TEXT_COLOR_PURE_BLACK;
return textColor;
}
private _setShapeFillColor(fillColor: string) {
const filled = !isTransparent(fillColor);
const color = this._getTextColor(fillColor);
this.elements.forEach(ele =>
this.service.updateElement(ele.id, { filled, fillColor, color })
);
}
private _setShapeStrokeColor(strokeColor: string) {
this.elements.forEach(ele =>
this.service.updateElement(ele.id, { strokeColor })
);
}
private _setShapeStrokeStyle(strokeStyle: StrokeStyle) {
this.elements.forEach(ele =>
this.service.updateElement(ele.id, { strokeStyle })
);
}
private _setShapeStrokeWidth(strokeWidth: number) {
this.elements.forEach(ele =>
this.service.updateElement(ele.id, { strokeWidth })
);
}
private _setShapeStyle(shapeStyle: ShapeStyle) {
const fontFamily =
shapeStyle === ShapeStyle.General ? FontFamily.Inter : FontFamily.Kalam;
this.elements.forEach(ele => {
this.service.updateElement(ele.id, { shapeStyle, fontFamily });
});
}
private _setShapeStyles({ type, value }: LineStyleEvent) {
if (type === 'size') {
this._setShapeStrokeWidth(value);
return;
}
if (type === 'lineStyle') {
this._setShapeStrokeStyle(value);
}
}
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.service.updateElement(element.id, {
shapeType: getShapeType(shapeName),
radius: getShapeRadius(shapeName),
});
});
})
);
}
override render() {
const colorScheme = this.edgeless.surface.renderer.getColorScheme();
const elements = this.elements;
const selectedShape = getMostCommonShape(elements);
const selectedFillColor =
getMostCommonFillColor(elements, colorScheme) ?? DEFAULT_SHAPE_FILL_COLOR;
const selectedStrokeColor =
getMostCommonStrokeColor(elements, colorScheme) ??
DEFAULT_SHAPE_STROKE_COLOR;
const selectedLineSize = getMostCommonLineSize(elements) ?? LineWidth.Four;
const selectedLineStyle =
getMostCommonLineStyle(elements) ?? StrokeStyle.Solid;
const selectedShapeStyle =
getMostCommonShapeStyle(elements) ?? ShapeStyle.Scribbled;
return join(
[
html`
<editor-menu-button
.button=${html`
<editor-icon-button
aria-label="Switch type"
.tooltip=${'Switch type'}
>
${ChangeShapeIcon}${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
? GeneralStyleIcon
: ScribbledStyleIcon
)}
${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.awarenessStore.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}
.palettes=${SHAPE_FILL_COLORS}
>
</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}
.options=${SHAPE_FILL_COLORS}
@select=${(e: ColorEvent) => this._setShapeFillColor(e.detail)}
>
</edgeless-color-panel>
</editor-menu-button>
`
),
when(
this.edgeless.doc.awarenessStore.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}
.palettes=${SHAPE_STROKE_COLORS}
.hollowCircle=${true}
>
<div
slot="other"
class="line-styles"
style=${styleMap({
display: 'flex',
flexDirection: 'row',
gap: '8px',
alignItems: 'center',
})}
>
${LineStylesPanel({
selectedLineSize: selectedLineSize,
selectedLineStyle: selectedLineStyle,
onClick: (e: LineStyleEvent) => this._setShapeStyles(e),
lineStyles: [StrokeStyle.Solid, StrokeStyle.Dash],
})}
</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
.hollowCircle=${true}
.strokeWidth=${selectedLineSize}
.strokeStyle=${selectedLineStyle}
.strokeColor=${selectedStrokeColor}
.setStrokeStyle=${(e: LineStyleEvent) =>
this._setShapeStyles(e)}
.setStrokeColor=${(e: ColorEvent) =>
this._setShapeStrokeColor(e.detail)}
>
</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'}
@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,505 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import {
ConnectorUtils,
normalizeShapeBound,
TextUtils,
} from '@blocksuite/affine-block-surface';
import {
SmallArrowDownIcon,
TextAlignCenterIcon,
TextAlignLeftIcon,
TextAlignRightIcon,
} from '@blocksuite/affine-components/icons';
import { renderToolbarSeparator } from '@blocksuite/affine-components/toolbar';
import {
type ColorScheme,
ConnectorElementModel,
EdgelessTextBlockModel,
FontFamily,
FontStyle,
FontWeight,
LINE_COLORS,
ShapeElementModel,
TextAlign,
TextElementModel,
type TextStyleProps,
} from '@blocksuite/affine-model';
import {
Bound,
countBy,
maxBy,
WithDisposable,
} from '@blocksuite/global/utils';
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 {
EdgelessColorPickerButton,
PickColorEvent,
} from '../../edgeless/components/color-picker/index.js';
import {
packColor,
packColorsWithColorScheme,
} from '../../edgeless/components/color-picker/utils.js';
import {
type ColorEvent,
GET_DEFAULT_LINE_COLOR,
} from '../../edgeless/components/panel/color-panel.js';
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.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 TEXT_ALIGN_CHOOSE: [TextAlign, () => TemplateResult<1>][] = [
[TextAlign.Left, () => TextAlignLeftIcon],
[TextAlign.Center, () => TextAlignCenterIcon],
[TextAlign.Right, () => TextAlignRightIcon],
] as const;
function countByField<K extends keyof Omit<TextStyleProps, 'color'>>(
elements: BlockSuite.EdgelessTextModelType[],
field: K
) {
return countBy(elements, element => extractField(element, field));
}
function extractField<K extends keyof Omit<TextStyleProps, 'color'>>(
element: BlockSuite.EdgelessTextModelType,
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: BlockSuite.EdgelessTextModelType[],
field: K
) {
const values = countByField(elements, field);
return maxBy(Object.entries(values), ([_k, count]) => count);
}
function getMostCommonAlign(elements: BlockSuite.EdgelessTextModelType[]) {
const max = getMostCommonValue(elements, 'textAlign');
return max ? (max[0] as TextAlign) : TextAlign.Left;
}
function getMostCommonColor(
elements: BlockSuite.EdgelessTextModelType[],
colorScheme: ColorScheme
): string {
const colors = countBy(elements, (ele: BlockSuite.EdgelessTextModelType) => {
const color =
ele instanceof ConnectorElementModel ? ele.labelStyle.color : ele.color;
return typeof color === 'object'
? (color[colorScheme] ?? color.normal ?? null)
: color;
});
const max = maxBy(Object.entries(colors), ([_k, count]) => count);
return max ? (max[0] as string) : GET_DEFAULT_LINE_COLOR(colorScheme);
}
function getMostCommonFontFamily(elements: BlockSuite.EdgelessTextModelType[]) {
const max = getMostCommonValue(elements, 'fontFamily');
return max ? (max[0] as FontFamily) : FontFamily.Inter;
}
function getMostCommonFontSize(elements: BlockSuite.EdgelessTextModelType[]) {
const max = getMostCommonValue(elements, 'fontSize');
return max ? Number(max[0]) : FONT_SIZE_LIST[0].value;
}
function getMostCommonFontStyle(elements: BlockSuite.EdgelessTextModelType[]) {
const max = getMostCommonValue(elements, 'fontStyle');
return max ? (max[0] as FontStyle) : FontStyle.Normal;
}
function getMostCommonFontWeight(elements: BlockSuite.EdgelessTextModelType[]) {
const max = getMostCommonValue(elements, 'fontWeight');
return max ? (max[0] as FontWeight) : FontWeight.Regular;
}
function buildProps(
element: BlockSuite.EdgelessTextModelType,
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%;
}
`;
private _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.service.updateElement(element.id, buildProps(element, props));
this._updateElementBound(element);
});
};
private _setFontSize = (fontSize: number) => {
const props = { fontSize };
this.elements.forEach(element => {
this.service.updateElement(element.id, buildProps(element, props));
this._updateElementBound(element);
});
};
private _setFontWeightAndStyle = (
fontWeight: FontWeight,
fontStyle: FontStyle
) => {
const props = { fontWeight, fontStyle };
this.elements.forEach(element => {
this.service.updateElement(element.id, buildProps(element, props));
this._updateElementBound(element);
});
};
private _setTextAlign = (textAlign: TextAlign) => {
const props = { textAlign };
this.elements.forEach(element => {
this.service.updateElement(element.id, buildProps(element, props));
});
};
private _setTextColor = ({ detail: color }: ColorEvent) => {
const props = { color };
this.elements.forEach(element => {
this.service.updateElement(element.id, buildProps(element, props));
});
};
private _updateElementBound = (element: BlockSuite.EdgelessTextModelType) => {
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.service.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.service.updateElement(element.id, {
labelXYWH: bounds.toXYWH(),
});
} else if (
elementType === 'shape' &&
element instanceof ShapeElementModel
) {
const newBound = normalizeShapeBound(
element,
Bound.fromXYWH(element.deserializedXYWH)
);
this.service.updateElement(element.id, {
xywh: newBound.serialize(),
});
}
// no need to update the bound of edgeless text block, which updates itself using ResizeObserver
};
pickColor = (event: PickColorEvent) => {
if (event.type === 'pick') {
this.elements.forEach(element => {
const props = packColor('color', { ...event.detail });
this.service.updateElement(element.id, buildProps(element, props));
this._updateElementBound(element);
});
return;
}
const key = this.elementType === 'connector' ? 'labelStyle' : 'color';
this.elements.forEach(ele => {
// @ts-expect-error: FIXME
ele[event.type === 'start' ? 'stash' : 'pop'](key);
});
};
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.awarenessStore.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}
.palettes=${LINE_COLORS}
>
</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}
@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!: BlockSuite.EdgelessTextModelType[];
@property({ attribute: false })
accessor elementType!: BlockSuite.EdgelessTextModelKeyType;
@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,481 @@
import { CommonUtils } from '@blocksuite/affine-block-surface';
import { ConnectorCWithArrowIcon } from '@blocksuite/affine-components/icons';
import {
cloneGroups,
darkToolbarStyles,
lightToolbarStyles,
type MenuItemGroup,
renderToolbarSeparator,
} from '@blocksuite/affine-components/toolbar';
import type {
AttachmentBlockModel,
BrushElementModel,
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 {
atLeastNMatches,
getCommonBoundWithRotation,
groupBy,
pickValues,
} from '@blocksuite/global/utils';
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 { EmbedModel } from '../../../_common/components/embed-card/type.js';
import { getMoreMenuConfig } from '../../configs/toolbar.js';
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
import {
isAttachmentBlock,
isBookmarkBlock,
isEdgelessTextBlock,
isEmbeddedBlock,
isFrameBlock,
isImageBlock,
isNoteBlock,
} 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?: EmbedModel[];
edgelessText?: EdgelessTextBlockModel[];
};
type CustomEntry = {
render: (edgeless: EdgelessRootBlockComponent) => TemplateResult | null;
when: (model: BlockSuite.EdgelessModel[]) => 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 _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 _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 as BlockSuite.SurfaceElementModel).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 = CommonUtils.clamp(left, 10, width - rect.width - 10);
top = CommonUtils.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'}
@click=${this._quickConnect}
>
${ConnectorCWithArrowIcon}
</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: BlockSuite.EdgelessModel[]) => 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,152 @@
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;
doc.captureSync();
// 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;
const levels = selectedElements.map(element => element.groups.length);
const topElement = selectedElements[levels.indexOf(Math.min(...levels))];
const otherElements = selectedElements.filter(
element => element !== topElement
);
// release other elements from their groups and group with top element
otherElements.forEach(element => {
// eslint-disable-next-line
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.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;
doc.captureSync();
this._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,49 @@
import type { MenuItemGroup } from '@blocksuite/affine-components/toolbar';
import { renderGroups } from '@blocksuite/affine-components/toolbar';
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: BlockSuite.EdgelessModel[] = [];
@property({ attribute: false })
accessor groups!: MenuItemGroup<ElementToolbarMoreMenuContext>[];
@property({ attribute: false })
accessor vertical = false;
}
@@ -0,0 +1,398 @@
import type {
EmbedFigmaBlockComponent,
EmbedGithubBlockComponent,
EmbedLoomBlockComponent,
EmbedYoutubeBlockComponent,
} from '@blocksuite/affine-block-embed';
import { isPeekable, peek } from '@blocksuite/affine-components/peek';
import type { MenuItemGroup } from '@blocksuite/affine-components/toolbar';
import { TelemetryProvider } from '@blocksuite/affine-shared/services';
import { Bound, getCommonBoundWithRotation } from '@blocksuite/global/utils';
import {
ArrowDownBigBottomIcon,
ArrowDownBigIcon,
ArrowUpBigIcon,
ArrowUpBigTopIcon,
CenterPeekIcon,
CopyIcon,
DeleteIcon,
DuplicateIcon,
FrameIcon,
GroupIcon,
LinkedPageIcon,
OpenInNewIcon,
ResetIcon,
} from '@blocksuite/icons/lit';
import {
createLinkedDocFromEdgelessElements,
createLinkedDocFromNote,
notifyDocCreated,
promptDocTitle,
} from '../../../../_common/utils/render-linked-doc.js';
import type { AttachmentBlockComponent } from '../../../../attachment-block/attachment-block.js';
import type { BookmarkBlockComponent } from '../../../../bookmark-block/bookmark-block.js';
import type { ImageBlockComponent } from '../../../../image-block/image-block.js';
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';
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
export const openGroup: MenuItemGroup<ElementToolbarMoreMenuContext> = {
type: 'open',
items: [
{
icon: OpenInNewIcon({ 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,
};
},
},
{
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);
},
};
},
},
],
};
// 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, host, std } = ctx;
const element = ctx.getNoteBlock();
if (!element) return;
const title = await promptDocTitle(host);
if (title === null) return;
const linkedDoc = createLinkedDocFromNote(doc, element, title);
// insert linked doc card
const cardId = service.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,
service,
surface,
edgeless,
host,
std,
}) => {
const title = await promptDocTitle(host);
if (title === null) return;
const elements = getSortedCloneElements(selection.selectedElements);
const linkedDoc = createLinkedDocFromEdgelessElements(
host,
elements,
title
);
// delete selected elements
doc.transact(() => {
deleteElements(edgeless, elements);
});
// insert linked doc card
const width = 364;
const height = 390;
const bound = getCommonBoundWithRotation(elements);
const cardId = service.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(host, 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,150 @@
import type { SurfaceBlockComponent } from '@blocksuite/affine-block-surface';
import {
GfxPrimitiveElementModel,
type GfxSelectionManager,
} from '@blocksuite/block-std/gfx';
import type { BlockModel } from '@blocksuite/store';
import { MenuContext } from '../../../configs/toolbar.js';
import type { EdgelessRootBlockComponent } from '../../../edgeless/edgeless-root-block.js';
import type { EdgelessRootService } from '../../../edgeless/edgeless-root-service.js';
import {
isAttachmentBlock,
isBookmarkBlock,
isEmbeddedLinkBlock,
isEmbedLinkedDocBlock,
isEmbedSyncedDocBlock,
isFrameBlock,
isImageBlock,
isNoteBlock,
} from '../../../edgeless/utils/query.js';
export class ElementToolbarMoreMenuContext extends MenuContext {
#empty = true;
#includedFrame = false;
#multiple = false;
#single = false;
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
.chain()
.getSelectedModels()
.run();
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,54 @@
import { ReleaseFromGroupButtonIcon } from '@blocksuite/affine-components/icons';
import { GroupElementModel } from '@blocksuite/affine-model';
import { WithDisposable } from '@blocksuite/global/utils';
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;
// eslint-disable-next-line
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()}
>
${ReleaseFromGroupButtonIcon}
</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,102 @@
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 as BlockSuite.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,44 @@
import type { EmbedBlockComponent } from '../../../_common/components/embed-card/type.js';
import { MenuContext } from '../../configs/toolbar.js';
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: EmbedBlockComponent,
public abortController: AbortController
) {
super();
}
isEmpty() {
return false;
}
isMultiple() {
return false;
}
isSingle() {
return true;
}
}
@@ -0,0 +1,879 @@
import { getDocContentWithMaxLength } from '@blocksuite/affine-block-embed';
import {
CaptionIcon,
CenterPeekIcon,
CopyIcon,
EditIcon,
ExpandFullSmallIcon,
MoreVerticalIcon,
OpenIcon,
PaletteIcon,
SmallArrowDownIcon,
} from '@blocksuite/affine-components/icons';
import { notifyLinkedDocSwitchedToEmbed } from '@blocksuite/affine-components/notification';
import { isPeekable, peek } from '@blocksuite/affine-components/peek';
import { toast } from '@blocksuite/affine-components/toast';
import {
cloneGroups,
type MenuItem,
type MenuItemGroup,
renderGroups,
renderToolbarSeparator,
} from '@blocksuite/affine-components/toolbar';
import {
type AliasInfo,
type BookmarkBlockModel,
BookmarkStyles,
type EmbedGithubModel,
type EmbedLinkedDocModel,
type RootBlockModel,
} from '@blocksuite/affine-model';
import {
EmbedOptionProvider,
type EmbedOptions,
GenerateDocUrlProvider,
type GenerateDocUrlService,
type LinkEventType,
type TelemetryEvent,
TelemetryProvider,
ThemeProvider,
} from '@blocksuite/affine-shared/services';
import { getHostName, referenceToNode } from '@blocksuite/affine-shared/utils';
import { type BlockStdScope, WidgetComponent } from '@blocksuite/block-std';
import { type BlockModel, DocCollection } 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 { toggleEmbedCardCaptionEditModal } from '../../../_common/components/embed-card/modal/embed-card-caption-edit-modal.js';
import { toggleEmbedCardEditModal } from '../../../_common/components/embed-card/modal/embed-card-edit-modal.js';
import {
type EmbedBlockComponent,
type EmbedModel,
isEmbedCardBlockComponent,
isInternalEmbedModel,
} from '../../../_common/components/embed-card/type.js';
import type { EmbedCardStyle } from '../../../_common/types.js';
import { getEmbedCardIcons } from '../../../_common/utils/url.js';
import { getMoreMenuConfig } from '../../configs/toolbar.js';
import {
isBookmarkBlock,
isEmbedGithubBlock,
isEmbedHtmlBlock,
isEmbedLinkedDocBlock,
isEmbedSyncedDocBlock,
} from '../../edgeless/utils/query.js';
import type { RootBlockComponent } from '../../types.js';
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 _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 _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);
track(this.std, model, this._viewType, 'OpenedAliasPopup', {
control: 'edit',
});
};
private _resetAbortController = () => {
this._abortController.abort();
this._abortController = new AbortController();
};
private _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 _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 _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 _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.awarenessStore.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.collection.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.collection.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(): EmbedModel | 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 buttons: MenuItem[] = [];
if (
this.focusModel &&
(isEmbedLinkedDocBlock(this.focusModel) ||
isEmbedSyncedDocBlock(this.focusModel))
) {
buttons.push({
type: 'open-this-doc',
label: 'Open this doc',
icon: ExpandFullSmallIcon,
action: () => this.focusBlock?.open(),
});
}
// open in new tab
const element = this.focusBlock;
if (element && isPeekable(element)) {
buttons.push({
type: 'open-in-center-peek',
label: 'Open in center peek',
icon: CenterPeekIcon,
action: () => peek(element),
});
}
// open in split view
if (buttons.length === 0) {
return nothing;
}
return html`
<editor-menu-button
.contentPadding=${'8px'}
.button=${html`
<editor-icon-button
aria-label="Open"
.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 _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 DocCollection.Y.Text();
const insert = title || caption || url;
yText.insert(0, insert);
yText.format(0, insert.length, { link: url });
const text = new doc.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>
${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);
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('text');
if (hasTextSelection) {
this._hide();
return;
}
const blockSelections = this._selection.filter('block');
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 EmbedBlockComponent;
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'}>
${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: EmbedBlockComponent | 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: EmbedModel,
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,161 @@
import { whenHover } from '@blocksuite/affine-components/hover';
import {
ArrowDownIcon,
HighLightDuotoneIcon,
TextBackgroundDuotoneIcon,
TextForegroundDuotoneIcon,
} from '@blocksuite/affine-components/icons';
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 {
Foreground,
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: {
color: highlightType === HighlightType.Foreground ? color : null,
background: highlightType === HighlightType.Background ? color : null,
},
};
host.std.command
.chain()
.try(chain => [
chain.getTextSelection().formatText(payload),
chain.getBlockSelections().formatBlock(payload),
chain.formatNative(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.Foreground
);
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
@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 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 { textConversionConfigs } from '../../../../_common/configs/text-conversion.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,453 @@
import {
BoldIcon,
BulletedListIcon,
CheckBoxIcon,
CodeIcon,
CopyIcon,
DatabaseTableViewIcon20,
DeleteIcon,
DuplicateIcon,
Heading1Icon,
Heading2Icon,
Heading3Icon,
Heading4Icon,
Heading5Icon,
Heading6Icon,
ItalicIcon,
LinkedDocIcon,
LinkIcon,
MoreVerticalIcon,
NumberedListIcon,
QuoteIcon,
StrikethroughIcon,
TextIcon,
UnderlineIcon,
} from '@blocksuite/affine-components/icons';
import { toast } from '@blocksuite/affine-components/toast';
import type { MenuItemGroup } from '@blocksuite/affine-components/toolbar';
import { renderGroups } from '@blocksuite/affine-components/toolbar';
import { TelemetryProvider } from '@blocksuite/affine-shared/services';
import type {
Chain,
CommandKeyToData,
InitCommandCtx,
} from '@blocksuite/block-std';
import { tableViewMeta } from '@blocksuite/data-view/view-presets';
import { assertExists } from '@blocksuite/global/utils';
import { Slice } from '@blocksuite/store';
import { html, type TemplateResult } from 'lit';
import {
convertSelectedBlocksToLinkedDoc,
getTitleFromSelectedModels,
notifyDocCreated,
promptDocTitle,
} from '../../../_common/utils/render-linked-doc.js';
import { convertToDatabase } from '../../../database-block/data-source.js';
import { DATABASE_CONVERT_WHITE_LIST } from '../../../database-block/utils/block-utils.js';
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.toggleBold().run(),
icon: BoldIcon,
})
.addTextStyleToggle({
key: 'italic',
action: chain => chain.toggleItalic().run(),
icon: ItalicIcon,
})
.addTextStyleToggle({
key: 'underline',
action: chain => chain.toggleUnderline().run(),
icon: UnderlineIcon,
})
.addTextStyleToggle({
key: 'strike',
action: chain => chain.toggleStrike().run(),
icon: StrikethroughIcon,
})
.addTextStyleToggle({
key: 'code',
action: chain => chain.toggleCode().run(),
icon: CodeIcon,
})
.addTextStyleToggle({
key: 'link',
action: chain => chain.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: CommandKeyToData<'selectedBlocks'>,
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
.getTextSelection()
.getSelectedBlocks({
types: ['text'],
})
.inline(middleware(1))
.run();
if (result) return true;
[result] = chain
.tryAll(chain => [
chain.getBlockSelections(),
chain.getImageSelections(),
])
.getSelectedBlocks({
types: ['block', 'image'],
})
.inline(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
.getSelectedModels({
types: ['block', 'text'],
mode: 'highest',
})
.draftSelectedModels()
.run();
const { draftedModels, selectedModels } = ctx;
if (!selectedModels?.length || !draftedModels) return;
const host = formatBar.host;
host.selection.clear();
const doc = host.doc;
const autofill = getTitleFromSelectedModels(selectedModels);
void promptDocTitle(host, autofill).then(async title => {
if (title === null) return;
await convertSelectedBlocksToLinkedDoc(
host.std,
doc,
draftedModels,
title
);
notifyDocCreated(host, 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',
});
});
},
showWhen: chain => {
const [_, ctx] = chain
.getSelectedModels({
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()
.getSelectedModels()
.with({
onCopy: () => {
toast(c.host, 'Copied to clipboard');
},
})
.draftSelectedModels()
.copySelectedModels()
.run();
},
},
{
type: 'duplicate',
label: 'Duplicate',
icon: DuplicateIcon,
disabled: c => c.doc.readonly,
action: c => {
c.doc.captureSync();
c.std.command
.chain()
.try(cmd => [
cmd
.getTextSelection()
.inline<'currentSelectionPath'>((ctx, next) => {
const textSelection = ctx.currentTextSelection;
assertExists(textSelection);
const end = textSelection.to ?? textSelection.from;
next({ currentSelectionPath: end.blockId });
}),
cmd
.getBlockSelections()
.inline<'currentSelectionPath'>((ctx, next) => {
const currentBlockSelections = ctx.currentBlockSelections;
assertExists(currentBlockSelections);
const blockSelection = currentBlockSelections.at(-1);
if (!blockSelection) {
return;
}
next({ currentSelectionPath: blockSelection.blockId });
}),
])
.getBlockIndex()
.getSelectedModels()
.draftSelectedModels()
.inline((ctx, next) => {
if (!ctx.draftedModels) {
return next();
}
ctx.draftedModels
.then(models => {
const slice = Slice.fromModels(ctx.std.doc, models);
return ctx.std.clipboard.duplicateSlice(
slice,
ctx.std.doc,
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()
.getTextSelection()
.deleteText()
.run();
if (result) {
return;
}
// remove blocks
c.std.command
.chain()
.tryAll(chain => [
chain.getBlockSelections(),
chain.getImageSelections(),
])
.getSelectedModels()
.deleteSelectedModels()
.run();
c.toolbar.reset();
},
},
],
},
];
export function toolbarMoreButton(toolbar: AffineFormatBarWidget) {
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'}>
${MoreVerticalIcon}
</editor-icon-button>
`}"
>
<div data-size="large" data-orientation="vertical">${actions}</div>
</editor-menu-button>
`;
}
@@ -0,0 +1,65 @@
import { MenuContext } from '../../configs/toolbar.js';
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.getTextSelection(),
chain.getBlockSelections(),
chain.getImageSelections(),
])
.getSelectedModels({
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,604 @@
import { HoverController } from '@blocksuite/affine-components/hover';
import type { RichText } from '@blocksuite/affine-components/rich-text';
import { isFormatSupported } from '@blocksuite/affine-components/rich-text';
import {
cloneGroups,
type MenuItemGroup,
} from '@blocksuite/affine-components/toolbar';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { matchFlavours } from '@blocksuite/affine-shared/utils';
import type {
BaseSelection,
BlockComponent,
CursorSelection,
} from '@blocksuite/block-std';
import { WidgetComponent } from '@blocksuite/block-std';
import {
assertExists,
DisposableGroup,
nextTick,
} from '@blocksuite/global/utils';
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 { getMoreMenuConfig } from '../../configs/toolbar.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('text');
const blockSelections = rootComponent.selection.filter('block');
// Should not re-render format bar when only cursor selection changed in edgeless
const cursorSelection = rootComponent.selection.find('cursor');
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()
.getTextSelection()
.getSelectedBlocks({
types: ['text'],
})
.inline(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 databaseSelection = this.host.selection.find('database');
if (!databaseSelection) {
return;
}
const reset = () => {
this.reset();
this.requestUpdate();
};
const viewSelection = databaseSelection.viewSelection;
// check table selection
if (
viewSelection.type === 'table' &&
(viewSelection.selectionType !== 'area' || !viewSelection.isEditing)
)
return reset();
// check kanban selection
if (
(viewSelection.type === 'kanban' &&
viewSelection.selectionType !== 'cell') ||
!viewSelection.isEditing
)
return reset();
const range = this.nativeRange;
if (!range || 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.awarenessStore.isReadonly(
this.doc.blockCollection
);
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 (
!matchFlavours(selectedBlock.model, [
'affine:paragraph',
'affine:list',
'affine:code',
'affine:image',
])
) {
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: BlockSuite.Flavour;
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
.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.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);
return html`
<editor-toolbar class="${AFFINE_FORMAT_BAR_WIDGET}">
${items}
<editor-toolbar-separator></editor-toolbar-separator>
${toolbarMoreButton(this)}
</editor-toolbar>
`;
}
reset() {
this._displayType = 'none';
this._selectedBlocks = [];
}
override updated() {
if (!this._shouldDisplay()) {
if (this._floatDisposables) {
this._floatDisposables.dispose();
}
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,56 @@
import { css } from 'lit';
import { scrollbarStyle } from '../../../_common/components/utils.js';
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,7 @@
import { AFFINE_FRAME_TITLE, AffineFrameTitle } from './frame-title.js';
import { AFFINE_FRAME_TITLE_WIDGET, AffineFrameTitleWidget } from './index.js';
export function effects() {
customElements.define(AFFINE_FRAME_TITLE_WIDGET, AffineFrameTitleWidget);
customElements.define(AFFINE_FRAME_TITLE, AffineFrameTitle);
}
@@ -0,0 +1,285 @@
import { ColorScheme, FrameBlockModel } from '@blocksuite/affine-model';
import { ThemeProvider } from '@blocksuite/affine-shared/services';
import {
type BlockStdScope,
PropTypes,
requiredProperties,
stdContext,
} from '@blocksuite/block-std';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import {
Bound,
type SerializedXYWH,
SignalWatcher,
WithDisposable,
} from '@blocksuite/global/utils';
import { consume } from '@lit/context';
import { themeToVar } from '@toeverything/theme/v2';
import { LitElement } from 'lit';
import { property, state } from 'lit/decorators.js';
import { parseStringToRgba } from '../../edgeless/components/color-picker/utils.js';
import { isTransparent } from '../../edgeless/components/panel/color-panel.js';
import type { EdgelessRootService } from '../../edgeless/index.js';
import { frameTitleStyle, frameTitleStyleVars } from './styles.js';
export const AFFINE_FRAME_TITLE = 'affine-frame-title';
@requiredProperties({
model: PropTypes.instanceOf(FrameBlockModel),
})
export class AffineFrameTitle extends SignalWatcher(
WithDisposable(LitElement)
) {
static override styles = frameTitleStyle;
private _cachedHeight = 0;
private _cachedWidth = 0;
get colors() {
let backgroundColor = this.std
.get(ThemeProvider)
.getColorValue(this.model.background, undefined, true);
if (isTransparent(backgroundColor)) {
backgroundColor = this.std
.get(ThemeProvider)
.getCssVariableColor(themeToVar('edgeless/frame/background/white'));
}
const { r, g, b, a } = parseStringToRgba(backgroundColor);
const theme = this.std.get(ThemeProvider).theme;
let textColor: string;
{
let rPrime, gPrime, bPrime;
if (theme === ColorScheme.Light) {
rPrime = 1 - a + a * r;
gPrime = 1 - a + a * g;
bPrime = 1 - a + a * b;
} else {
rPrime = a * r;
gPrime = a * g;
bPrime = a * b;
}
// light
const L = 0.299 * rPrime + 0.587 * gPrime + 0.114 * bPrime;
textColor = L > 0.5 ? 'black' : 'white';
}
return {
background: backgroundColor,
text: textColor,
};
}
get doc() {
return this.model.doc;
}
get gfx() {
return this.std.get(GfxControllerIdentifier);
}
get rootService() {
return this.std.getService('affine:page') as EdgelessRootService;
}
private _isInsideFrame() {
return this.gfx.grid.has(
this.model.elementBound,
true,
true,
model => model !== this.model && model instanceof FrameBlockModel
);
}
private _updateFrameTitleSize() {
const { _nestedFrame, _zoom: zoom } = this;
const { elementBound } = this.model;
const width = this._cachedWidth / zoom;
const height = this._cachedHeight / zoom;
const { nestedFrameOffset } = frameTitleStyleVars;
if (width && height) {
this.model.externalXYWH = `[${
elementBound.x + (_nestedFrame ? nestedFrameOffset / zoom : 0)
},${
elementBound.y +
(_nestedFrame
? nestedFrameOffset / zoom
: -(height + nestedFrameOffset / zoom))
},${width},${height}]`;
this.gfx.grid.update(this.model);
} else {
this.model.externalXYWH = undefined;
}
}
private _updateStyle() {
if (
this._frameTitle.length === 0 ||
this._editing ||
this.gfx.tool.currentToolName$.value === 'frameNavigator'
) {
this.style.display = 'none';
return;
}
const model = this.model;
const bound = Bound.deserialize(model.xywh);
const { _zoom: zoom } = this;
const { nestedFrameOffset, height } = frameTitleStyleVars;
const nestedFrame = this._nestedFrame;
const maxWidth = nestedFrame
? bound.w * zoom - nestedFrameOffset / zoom
: bound.w * zoom;
const hidden = height / zoom >= bound.h;
const transformOperation = [
`translate(0%, ${nestedFrame ? 0 : -100}%)`,
`translate(${nestedFrame ? nestedFrameOffset : 0}px, ${
nestedFrame ? nestedFrameOffset : -nestedFrameOffset
}px)`,
];
const anchor = this.gfx.viewport.toViewCoord(bound.x, bound.y);
this.style.display = '';
this.style.setProperty('--bg-color', this.colors.background);
this.style.left = `${anchor[0]}px`;
this.style.top = `${anchor[1]}px`;
this.style.display = hidden ? 'none' : 'flex';
this.style.transform = transformOperation.join(' ');
this.style.maxWidth = `${maxWidth}px`;
this.style.transformOrigin = nestedFrame ? 'top left' : 'bottom left';
this.style.color = this.colors.text;
}
override connectedCallback() {
super.connectedCallback();
const { _disposables, doc, gfx, rootService } = this;
this._nestedFrame = this._isInsideFrame();
_disposables.add(
doc.slots.blockUpdated.on(payload => {
if (
(payload.type === 'update' &&
payload.props.key === 'xywh' &&
doc.getBlock(payload.id)?.model instanceof FrameBlockModel) ||
(payload.type === 'add' && payload.flavour === 'affine:frame')
) {
this._nestedFrame = this._isInsideFrame();
}
if (
payload.type === 'delete' &&
payload.model instanceof FrameBlockModel &&
payload.model !== this.model
) {
this._nestedFrame = this._isInsideFrame();
}
})
);
_disposables.add(
this.model.propsUpdated.on(() => {
this._xywh = this.model.xywh;
this.requestUpdate();
})
);
_disposables.add(
rootService.selection.slots.updated.on(() => {
this._editing =
rootService.selection.selectedIds[0] === this.model.id &&
rootService.selection.editing;
})
);
_disposables.add(
gfx.viewport.viewportUpdated.on(({ zoom }) => {
this._zoom = zoom;
this.requestUpdate();
})
);
this._zoom = gfx.viewport.zoom;
const updateTitle = () => {
this._frameTitle = this.model.title.toString().trim();
};
_disposables.add(() => {
this.model.title.yText.unobserve(updateTitle);
});
this.model.title.yText.observe(updateTitle);
this._frameTitle = this.model.title.toString().trim();
this._xywh = this.model.xywh;
}
override firstUpdated() {
this._cachedWidth = this.clientWidth;
this._cachedHeight = this.clientHeight;
this._updateFrameTitleSize();
}
override render() {
this._updateStyle();
return this._frameTitle;
}
override updated(_changedProperties: Map<string, unknown>) {
if (
!this.gfx.viewport.viewportBounds.contains(this.model.elementBound) &&
!this.gfx.viewport.viewportBounds.isIntersectWithBound(
this.model.elementBound
)
) {
return;
}
let sizeChanged = false;
if (
this._cachedWidth === 0 ||
this._cachedHeight === 0 ||
_changedProperties.has('_frameTitle') ||
_changedProperties.has('_nestedFrame') ||
_changedProperties.has('_xywh') ||
_changedProperties.has('_editing')
) {
this._cachedWidth = this.clientWidth;
this._cachedHeight = this.clientHeight;
sizeChanged = true;
}
if (sizeChanged || _changedProperties.has('_zoom')) {
this._updateFrameTitleSize();
}
}
@state()
private accessor _editing = false;
@state()
private accessor _frameTitle = '';
@state()
private accessor _nestedFrame = false;
@state()
private accessor _xywh: SerializedXYWH | null = null;
@state()
private accessor _zoom = 1;
@property({ attribute: false })
accessor model!: FrameBlockModel;
@consume({ context: stdContext })
accessor std!: BlockStdScope;
}
@@ -0,0 +1,40 @@
import { FrameBlockModel, type RootBlockModel } from '@blocksuite/affine-model';
import { WidgetComponent } from '@blocksuite/block-std';
import { html } from 'lit';
import { repeat } from 'lit/directives/repeat.js';
import type { EdgelessRootBlockComponent } from '../../index.js';
import type { AffineFrameTitle } from './frame-title.js';
export const AFFINE_FRAME_TITLE_WIDGET = 'affine-frame-title-widget';
export class AffineFrameTitleWidget extends WidgetComponent<
RootBlockModel,
EdgelessRootBlockComponent
> {
private get _frames() {
return Object.values(this.doc.blocks.value)
.map(({ model }) => model)
.filter(model => model instanceof FrameBlockModel);
}
getFrameTitle(frame: FrameBlockModel | string) {
const id = typeof frame === 'string' ? frame : frame.id;
const frameTitle = this.shadowRoot?.querySelector(
`affine-frame-title[data-id="${id}"]`
) as AffineFrameTitle | null;
return frameTitle;
}
override render() {
return repeat(
this._frames,
({ id }) => id,
frame =>
html`<affine-frame-title
.model=${frame}
data-id=${frame.id}
></affine-frame-title>`
);
}
}
@@ -0,0 +1,35 @@
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { css } from 'lit';
export const frameTitleStyleVars = {
nestedFrameOffset: 4,
height: 22,
fontSize: 14,
};
export const frameTitleStyle = css`
:host {
position: absolute;
display: flex;
align-items: center;
z-index: 1;
border: 1px solid ${unsafeCSSVarV2('edgeless/frame/border/default')};
border-radius: 4px;
width: fit-content;
height: ${frameTitleStyleVars.height}px;
padding: 0px 4px;
transform-origin: left bottom;
background-color: var(--bg-color);
font-family: var(--affine-font-family);
font-size: ${frameTitleStyleVars.fontSize}px;
cursor: default;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
:hover {
background-color: color-mix(in srgb, var(--bg-color), #000000 7%);
}
`;
@@ -0,0 +1,141 @@
import { MoreVerticalIcon } from '@blocksuite/affine-components/icons';
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 { 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}
@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,145 @@
import {
BookmarkIcon,
CaptionIcon,
CopyIcon,
DeleteIcon,
DownloadIcon,
DuplicateIcon,
} from '@blocksuite/affine-components/icons';
import type { MenuItemGroup } from '@blocksuite/affine-components/toolbar';
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 '../../../image-block/image-block.js';
import { MenuContext } from '../../configs/toolbar.js';
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,167 @@
import { HoverController } from '@blocksuite/affine-components/hover';
import type {
AdvancedMenuItem,
MenuItemGroup,
} from '@blocksuite/affine-components/toolbar';
import { cloneGroups } from '@blocksuite/affine-components/toolbar';
import type { ImageBlockModel } from '@blocksuite/affine-model';
import { WidgetComponent } from '@blocksuite/block-std';
import { limitShift, shift } from '@floating-ui/dom';
import { html } from 'lit';
import { PAGE_HEADER_HEIGHT } from '../../../_common/consts.js';
import type { ImageBlockComponent } from '../../../image-block/image-block.js';
import { getMoreMenuConfig } from '../../configs/toolbar.js';
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 _setHoverController = () => {
this._hoverController = null;
this._hoverController = new HoverController(
this,
({ abortController }) => {
const imageBlock = this.block;
const selection = this.host.selection;
const textSelection = selection.find('text');
if (
!!textSelection &&
(!!textSelection.to || !!textSelection.from.length)
) {
return null;
}
const blockSelections = selection.filter('block');
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 {
getBlockProps,
isInsidePageEditor,
} from '@blocksuite/affine-shared/utils';
import { assertExists } from '@blocksuite/global/utils';
import type { ImageBlockComponent } from '../../../image-block/image-block.js';
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 as BlockSuite.Flavour,
duplicateProps,
parent,
index + 1
);
abortController?.abort();
const editorHost = block.host;
editorHost.updateComplete
.then(() => {
const { selection } = editorHost;
selection.setGroup('note', [
selection.create('block', {
blockId: duplicateId,
}),
]);
if (isInsidePageEditor(editorHost)) {
const duplicateElement = editorHost.view.getBlock(duplicateId);
if (duplicateElement) {
duplicateElement.scrollIntoView(true);
}
}
})
.catch(console.error);
}
@@ -0,0 +1,59 @@
export {
AFFINE_AI_PANEL_WIDGET,
AffineAIPanelWidget,
} from './ai-panel/ai-panel.js';
export {
type AffineAIPanelState,
type AffineAIPanelWidgetConfig,
} from './ai-panel/type.js';
export { AffineCodeToolbarWidget } from './code-toolbar/index.js';
export { AffineDocRemoteSelectionWidget } from './doc-remote-selection/doc-remote-selection.js';
export { AffineDragHandleWidget } from './drag-handle/drag-handle.js';
export {
AFFINE_EDGELESS_COPILOT_WIDGET,
EdgelessCopilotWidget,
} from './edgeless-copilot/index.js';
export { EdgelessCopilotToolbarEntry } from './edgeless-copilot-panel/toolbar-entry.js';
export { EdgelessRemoteSelectionWidget } from './edgeless-remote-selection/index.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 { AffineFrameTitleWidget } from './frame-title/index.js';
export { AffineImageToolbarWidget } from './image-toolbar/index.js';
export { AffineInnerModalWidget } from './inner-modal/inner-modal.js';
export * from './keyboard-toolbar/index.js';
export {
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 { AffinePieMenuWidget } from './pie-menu/index.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';
@@ -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;
}
}

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