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