mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-21 03:56:23 +08:00
dbf0f9dc20
Closes: [BS-2553](https://linear.app/affine-design/issue/BS-2553/remove-global-types-in-edgeless)
485 lines
13 KiB
TypeScript
485 lines
13 KiB
TypeScript
import { isNoteBlock } from '@blocksuite/affine-block-surface';
|
|
import { ConnectorCWithArrowIcon } from '@blocksuite/affine-components/icons';
|
|
import {
|
|
cloneGroups,
|
|
darkToolbarStyles,
|
|
getMoreMenuConfig,
|
|
lightToolbarStyles,
|
|
type MenuItemGroup,
|
|
renderToolbarSeparator,
|
|
} from '@blocksuite/affine-components/toolbar';
|
|
import type {
|
|
AttachmentBlockModel,
|
|
BrushElementModel,
|
|
BuiltInEmbedModel,
|
|
ConnectorElementModel,
|
|
EdgelessTextBlockModel,
|
|
FrameBlockModel,
|
|
ImageBlockModel,
|
|
MindmapElementModel,
|
|
NoteBlockModel,
|
|
RootBlockModel,
|
|
TextElementModel,
|
|
} from '@blocksuite/affine-model';
|
|
import {
|
|
ConnectorMode,
|
|
GroupElementModel,
|
|
ShapeElementModel,
|
|
} from '@blocksuite/affine-model';
|
|
import { ThemeProvider } from '@blocksuite/affine-shared/services';
|
|
import { requestConnectedFrame } from '@blocksuite/affine-shared/utils';
|
|
import { WidgetComponent } from '@blocksuite/block-std';
|
|
import type { GfxModel } from '@blocksuite/block-std/gfx';
|
|
import {
|
|
atLeastNMatches,
|
|
clamp,
|
|
getCommonBoundWithRotation,
|
|
groupBy,
|
|
pickValues,
|
|
} from '@blocksuite/global/utils';
|
|
import { css, html, nothing, type TemplateResult, unsafeCSS } from 'lit';
|
|
import { property, state } from 'lit/decorators.js';
|
|
import { join } from 'lit/directives/join.js';
|
|
|
|
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
|
|
import {
|
|
isAttachmentBlock,
|
|
isBookmarkBlock,
|
|
isEdgelessTextBlock,
|
|
isEmbeddedBlock,
|
|
isFrameBlock,
|
|
isImageBlock,
|
|
} from '../../edgeless/utils/query.js';
|
|
import { renderAddFrameButton } from './add-frame-button.js';
|
|
import { renderAddGroupButton } from './add-group-button.js';
|
|
import { renderAlignButton } from './align-button.js';
|
|
import { renderAttachmentButton } from './change-attachment-button.js';
|
|
import { renderChangeBrushButton } from './change-brush-button.js';
|
|
import { renderConnectorButton } from './change-connector-button.js';
|
|
import { renderChangeEdgelessTextButton } from './change-edgeless-text-button.js';
|
|
import { renderEmbedButton } from './change-embed-card-button.js';
|
|
import { renderFrameButton } from './change-frame-button.js';
|
|
import { renderGroupButton } from './change-group-button.js';
|
|
import { renderChangeImageButton } from './change-image-button.js';
|
|
import { renderMindmapButton } from './change-mindmap-button.js';
|
|
import { renderNoteButton } from './change-note-button.js';
|
|
import { renderChangeShapeButton } from './change-shape-button.js';
|
|
import { renderChangeTextButton } from './change-text-button.js';
|
|
import { BUILT_IN_GROUPS } from './more-menu/config.js';
|
|
import type { ElementToolbarMoreMenuContext } from './more-menu/context.js';
|
|
import { renderReleaseFromGroupButton } from './release-from-group-button.js';
|
|
|
|
type CategorizedElements = {
|
|
shape?: ShapeElementModel[];
|
|
brush?: BrushElementModel[];
|
|
text?: TextElementModel[];
|
|
group?: GroupElementModel[];
|
|
connector?: ConnectorElementModel[];
|
|
note?: NoteBlockModel[];
|
|
frame?: FrameBlockModel[];
|
|
image?: ImageBlockModel[];
|
|
attachment?: AttachmentBlockModel[];
|
|
mindmap?: MindmapElementModel[];
|
|
embedCard?: BuiltInEmbedModel[];
|
|
edgelessText?: EdgelessTextBlockModel[];
|
|
};
|
|
|
|
type CustomEntry = {
|
|
render: (edgeless: EdgelessRootBlockComponent) => TemplateResult | null;
|
|
when: (model: GfxModel[]) => boolean;
|
|
};
|
|
|
|
export const EDGELESS_ELEMENT_TOOLBAR_WIDGET =
|
|
'edgeless-element-toolbar-widget';
|
|
|
|
export class EdgelessElementToolbarWidget extends WidgetComponent<
|
|
RootBlockModel,
|
|
EdgelessRootBlockComponent
|
|
> {
|
|
static override styles = css`
|
|
:host {
|
|
position: absolute;
|
|
z-index: 3;
|
|
transform: translateZ(0);
|
|
will-change: transform;
|
|
-webkit-user-select: none;
|
|
user-select: none;
|
|
}
|
|
editor-toolbar[data-app-theme='light'] {
|
|
${unsafeCSS(lightToolbarStyles.join('\n'))}
|
|
}
|
|
editor-toolbar[data-app-theme='dark'] {
|
|
${unsafeCSS(darkToolbarStyles.join('\n'))}
|
|
}
|
|
`;
|
|
|
|
private readonly _quickConnect = ({ x, y }: MouseEvent) => {
|
|
const element = this.selection.selectedElements[0];
|
|
const point = this.edgeless.service.viewport.toViewCoordFromClientCoord([
|
|
x,
|
|
y,
|
|
]);
|
|
this.edgeless.doc.captureSync();
|
|
this.edgeless.gfx.tool.setTool('connector', {
|
|
mode: ConnectorMode.Curve,
|
|
});
|
|
|
|
const ctc = this.edgeless.gfx.tool.get('connector');
|
|
ctc.quickConnect(point, element);
|
|
};
|
|
|
|
private readonly _updateOnSelectedChange = (
|
|
element: string | { id: string }
|
|
) => {
|
|
const id = typeof element === 'string' ? element : element.id;
|
|
|
|
if (this.isConnected && !this._dragging && this.selection.has(id)) {
|
|
this._recalculatePosition();
|
|
this.requestUpdate();
|
|
}
|
|
};
|
|
|
|
/*
|
|
* Caches the more menu items.
|
|
* Currently only supports configuring more menu.
|
|
*/
|
|
moreGroups: MenuItemGroup<ElementToolbarMoreMenuContext>[] =
|
|
cloneGroups(BUILT_IN_GROUPS);
|
|
|
|
get edgeless() {
|
|
return this.block as EdgelessRootBlockComponent;
|
|
}
|
|
|
|
get selection() {
|
|
return this.edgeless.service.selection;
|
|
}
|
|
|
|
get slots() {
|
|
return this.edgeless.slots;
|
|
}
|
|
|
|
get surface() {
|
|
return this.edgeless.surface;
|
|
}
|
|
|
|
private _groupSelected(): CategorizedElements {
|
|
const result = groupBy(this.selection.selectedElements, model => {
|
|
if (isNoteBlock(model)) {
|
|
return 'note';
|
|
} else if (isFrameBlock(model)) {
|
|
return 'frame';
|
|
} else if (isImageBlock(model)) {
|
|
return 'image';
|
|
} else if (isAttachmentBlock(model)) {
|
|
return 'attachment';
|
|
} else if (isBookmarkBlock(model) || isEmbeddedBlock(model)) {
|
|
return 'embedCard';
|
|
} else if (isEdgelessTextBlock(model)) {
|
|
return 'edgelessText';
|
|
}
|
|
|
|
return model.type;
|
|
});
|
|
return result as CategorizedElements;
|
|
}
|
|
|
|
private _recalculatePosition() {
|
|
const { selection, viewport } = this.edgeless.service;
|
|
const elements = selection.selectedElements;
|
|
|
|
if (elements.length === 0) {
|
|
this.style.transform = 'translate3d(0, 0, 0)';
|
|
return;
|
|
}
|
|
|
|
const bound = getCommonBoundWithRotation(elements);
|
|
|
|
const { width, height } = viewport;
|
|
const { x, y, w } = viewport.toViewBound(bound);
|
|
|
|
let left = x;
|
|
let top = y;
|
|
|
|
const hasLocked = elements.some(e => e.isLocked());
|
|
|
|
let offset = 37 + 12;
|
|
// frame, group, shape
|
|
let hasFrame = false;
|
|
let hasGroup = false;
|
|
if (
|
|
(hasFrame = elements.some(ele => isFrameBlock(ele))) ||
|
|
(hasGroup = elements.some(ele => ele instanceof GroupElementModel))
|
|
) {
|
|
offset += 16 + 4;
|
|
if (hasFrame) {
|
|
offset += 8;
|
|
}
|
|
} else if (
|
|
elements.length === 1 &&
|
|
elements[0] instanceof ShapeElementModel
|
|
) {
|
|
offset += 22 + 4;
|
|
}
|
|
|
|
top = y - offset;
|
|
if (top < 0) {
|
|
top = y + bound.h * viewport.zoom + offset - 37;
|
|
if (hasFrame || hasGroup) {
|
|
top -= 16 + 4;
|
|
if (hasFrame) {
|
|
top -= 8;
|
|
}
|
|
}
|
|
}
|
|
|
|
requestConnectedFrame(() => {
|
|
const rect = this.getBoundingClientRect();
|
|
|
|
if (hasLocked) {
|
|
left += 0.5 * (w - rect.width);
|
|
}
|
|
|
|
left = clamp(left, 10, width - rect.width - 10);
|
|
top = clamp(top, 10, height - rect.height - 150);
|
|
|
|
this.style.transform = `translate3d(${left}px, ${top}px, 0)`;
|
|
}, this);
|
|
}
|
|
|
|
private _renderButtons() {
|
|
if (this.doc.readonly || this._dragging || !this.toolbarVisible) {
|
|
return [];
|
|
}
|
|
const { selectedElements } = this.selection;
|
|
if (selectedElements.some(e => e.isLocked())) {
|
|
return [
|
|
html`<edgeless-lock-button
|
|
.edgeless=${this.edgeless}
|
|
></edgeless-lock-button>`,
|
|
];
|
|
}
|
|
|
|
const groupedSelected = this._groupSelected();
|
|
const { edgeless, selection } = this;
|
|
const {
|
|
shape,
|
|
brush,
|
|
connector,
|
|
note,
|
|
text,
|
|
frame,
|
|
group,
|
|
embedCard,
|
|
attachment,
|
|
image,
|
|
edgelessText,
|
|
mindmap: mindmaps,
|
|
} = groupedSelected;
|
|
const selectedAtLeastTwoTypes = atLeastNMatches(
|
|
Object.values(groupedSelected),
|
|
e => !!e.length,
|
|
2
|
|
);
|
|
|
|
const quickConnectButton =
|
|
selectedElements.length === 1 && !connector?.length
|
|
? this._renderQuickConnectButton()
|
|
: undefined;
|
|
|
|
const generalButtons =
|
|
selectedElements.length !== connector?.length
|
|
? [
|
|
renderAddFrameButton(edgeless, selectedElements),
|
|
renderAddGroupButton(edgeless, selectedElements),
|
|
renderAlignButton(edgeless, selectedElements),
|
|
]
|
|
: [];
|
|
|
|
const buttons: (symbol | TemplateResult)[] = selectedAtLeastTwoTypes
|
|
? generalButtons
|
|
: [
|
|
...generalButtons,
|
|
renderMindmapButton(edgeless, mindmaps),
|
|
renderMindmapButton(edgeless, shape),
|
|
renderChangeShapeButton(edgeless, shape),
|
|
renderChangeBrushButton(edgeless, brush),
|
|
renderConnectorButton(edgeless, connector),
|
|
renderNoteButton(edgeless, note, quickConnectButton),
|
|
renderChangeTextButton(edgeless, text),
|
|
renderChangeEdgelessTextButton(edgeless, edgelessText),
|
|
renderFrameButton(edgeless, frame),
|
|
renderGroupButton(edgeless, group),
|
|
renderEmbedButton(edgeless, embedCard, quickConnectButton),
|
|
renderAttachmentButton(edgeless, attachment),
|
|
renderChangeImageButton(edgeless, image),
|
|
];
|
|
|
|
if (selectedElements.length === 1) {
|
|
if (selection.firstElement.group instanceof GroupElementModel) {
|
|
buttons.unshift(renderReleaseFromGroupButton(this.edgeless));
|
|
}
|
|
|
|
if (!connector?.length) {
|
|
buttons.push(quickConnectButton?.pop() ?? nothing);
|
|
}
|
|
}
|
|
|
|
buttons.push(
|
|
html`<edgeless-lock-button
|
|
.edgeless=${this.edgeless}
|
|
></edgeless-lock-button>`
|
|
);
|
|
|
|
this._registeredEntries
|
|
.filter(entry => entry.when(selectedElements))
|
|
.map(entry => entry.render(this.edgeless))
|
|
.forEach(entry => entry && buttons.unshift(entry));
|
|
|
|
buttons.push(html`
|
|
<edgeless-more-button
|
|
.elements=${selectedElements}
|
|
.edgeless=${edgeless}
|
|
.groups=${this.moreGroups}
|
|
.vertical=${true}
|
|
></edgeless-more-button>
|
|
`);
|
|
|
|
return buttons;
|
|
}
|
|
|
|
private _renderQuickConnectButton() {
|
|
return [
|
|
html`
|
|
<editor-icon-button
|
|
aria-label="Draw connector"
|
|
.tooltip=${'Draw connector'}
|
|
.activeMode=${'background'}
|
|
@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: GfxModel[]) => boolean;
|
|
}[] = [];
|
|
|
|
@property({ attribute: false })
|
|
accessor enableNoteSlicer!: boolean;
|
|
|
|
@state({
|
|
hasChanged: (value: string[], oldValue: string[]) => {
|
|
if (value.length !== oldValue?.length) {
|
|
return true;
|
|
}
|
|
|
|
return value.some((id, index) => id !== oldValue[index]);
|
|
},
|
|
})
|
|
accessor selectedIds: string[] = [];
|
|
|
|
@state()
|
|
accessor toolbarVisible = false;
|
|
}
|