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