feat(editor): gfx connector package (#11091)

This commit is contained in:
Saul-Mirone
2025-03-22 14:39:05 +00:00
parent d398ee4dfa
commit 1f0fc9d47a
38 changed files with 284 additions and 137 deletions
@@ -1,164 +0,0 @@
import {
type ConnectionOverlay,
OverlayIdentifier,
} from '@blocksuite/affine-block-surface';
import type { ConnectorElementModel } from '@blocksuite/affine-model';
import {
type BlockStdScope,
docContext,
stdContext,
} from '@blocksuite/block-std';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import { DisposableGroup } from '@blocksuite/global/disposable';
import { Vec } from '@blocksuite/global/gfx';
import { WithDisposable } from '@blocksuite/global/lit';
import type { Store } from '@blocksuite/store';
import { consume } from '@lit/context';
import { css, html, LitElement } from 'lit';
import { property, query } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import type { EdgelessRootBlockComponent } from '../../edgeless-root-block.js';
const SIZE = 12;
const HALF_SIZE = SIZE / 2;
export class EdgelessConnectorHandle extends WithDisposable(LitElement) {
static override styles = css`
.line-controller {
position: absolute;
width: ${SIZE}px;
height: ${SIZE}px;
box-sizing: border-box;
border-radius: 50%;
border: 2px solid var(--affine-text-emphasis-color);
background-color: var(--affine-background-primary-color);
cursor: pointer;
z-index: 10;
pointer-events: all;
/**
* Fix: pointerEvent stops firing after a short time.
* When a gesture is started, the browser intersects the touch-action values of the touched element and its ancestors,
* up to the one that implements the gesture (in other words, the first containing scrolling element)
* https://developer.mozilla.org/en-US/docs/Web/CSS/touch-action
*/
touch-action: none;
}
.line-controller-hidden {
display: none;
}
`;
private _lastZoom = 1;
get connectionOverlay() {
return this.std.get(OverlayIdentifier('connection')) as ConnectionOverlay;
}
get gfx() {
return this.std.get(GfxControllerIdentifier);
}
private _bindEvent() {
const edgeless = this.edgeless;
this._disposables.addFromEvent(this._startHandler, 'pointerdown', e => {
edgeless.slots.elementResizeStart.next();
this._capPointerDown(e, 'source');
});
this._disposables.addFromEvent(this._endHandler, 'pointerdown', e => {
edgeless.slots.elementResizeStart.next();
this._capPointerDown(e, 'target');
});
this._disposables.add(() => {
this.connectionOverlay.clear();
});
}
private _capPointerDown(e: PointerEvent, connection: 'target' | 'source') {
const { edgeless, connector, _disposables } = this;
const { service } = edgeless;
e.stopPropagation();
_disposables.addFromEvent(document, 'pointermove', e => {
const point = service.viewport.toModelCoordFromClientCoord([e.x, e.y]);
const isStartPointer = connection === 'source';
const otherSideId = connector[isStartPointer ? 'target' : 'source'].id;
connector[connection] = this.connectionOverlay.renderConnector(
point,
otherSideId ? [otherSideId] : []
);
this.requestUpdate();
});
_disposables.addFromEvent(document, 'pointerup', () => {
this.doc.captureSync();
_disposables.dispose();
this._disposables = new DisposableGroup();
this._bindEvent();
edgeless.slots.elementResizeEnd.next();
});
}
override firstUpdated() {
const { edgeless } = this;
const { viewport } = edgeless.service;
this._lastZoom = viewport.zoom;
edgeless.service.viewport.viewportUpdated.subscribe(() => {
if (viewport.zoom !== this._lastZoom) {
this._lastZoom = viewport.zoom;
this.requestUpdate();
}
});
this._bindEvent();
}
override render() {
const { service } = this.edgeless;
// path is relative to the element's xywh
const { path } = this.connector;
const zoom = service.viewport.zoom;
const startPoint = Vec.subScalar(Vec.mul(path[0], zoom), HALF_SIZE);
const endPoint = Vec.subScalar(
Vec.mul(path[path.length - 1], zoom),
HALF_SIZE
);
const startStyle = {
transform: `translate3d(${startPoint[0]}px,${startPoint[1]}px,0)`,
};
const endStyle = {
transform: `translate3d(${endPoint[0]}px,${endPoint[1]}px,0)`,
};
return html`
<div
class="line-controller line-start"
style=${styleMap(startStyle)}
></div>
<div class="line-controller line-end" style=${styleMap(endStyle)}></div>
`;
}
@query('.line-end')
private accessor _endHandler!: HTMLDivElement;
@query('.line-start')
private accessor _startHandler!: HTMLDivElement;
@property({ attribute: false })
accessor connector!: ConnectorElementModel;
@consume({
context: docContext,
})
accessor doc!: Store;
@property({ attribute: false })
accessor edgeless!: EdgelessRootBlockComponent;
@consume({
context: stdContext,
})
accessor std!: BlockStdScope;
}
@@ -1,84 +0,0 @@
import { html } from 'lit';
export const NoteNoShadowIcon = html`
<svg
width="60"
height="72"
viewBox="0 0 60 72"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect width="60" height="72" />
<rect
x="0.5"
y="0.5"
width="58.0769"
height="71"
stroke="black"
stroke-opacity="0.1"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M21.9576 26.8962L38.6423 43.5809C42.5269 38.9268 42.2845 31.993 37.9149 27.6235C33.5454 23.254 26.6117 23.0115 21.9576 26.8962ZM37.1193 45.1038L20.4346 28.4192C16.55 33.0732 16.7924 40.007 21.162 44.3765C25.5315 48.746 32.4652 48.9885 37.1193 45.1038ZM19.639 26.1005C25.1063 20.6332 33.9706 20.6332 39.4379 26.1005C44.9053 31.5678 44.9053 40.4322 39.4379 45.8995C33.9706 51.3668 25.1063 51.3668 19.639 45.8995C14.1716 40.4322 14.1716 31.5678 19.639 26.1005Z"
fill="black"
fill-opacity="0.1"
/>
</svg>
`;
export const NoteShadowSampleIcon = html`
<svg
width="60"
height="72"
viewBox="0 0 60 72"
xmlns="http://www.w3.org/2000/svg"
>
<rect width="60" height="72" />
<rect
x="9.23071"
y="12.0771"
width="32.3077"
height="4.61538"
rx="2"
fill="black"
fill-opacity="0.1"
/>
<rect
x="9.23071"
y="25.8462"
width="40.6154"
height="2.76923"
rx="1.38462"
fill="black"
fill-opacity="0.1"
/>
<rect
x="9.23071"
y="35.6152"
width="40.6154"
height="2.76923"
rx="1.38462"
fill="black"
fill-opacity="0.1"
/>
<rect
x="9.23071"
y="45.3843"
width="40.6154"
height="2.76923"
rx="1.38462"
fill="black"
fill-opacity="0.1"
/>
<rect
x="9.23071"
y="55.1533"
width="13.8462"
height="2.76923"
rx="1.38462"
fill="black"
fill-opacity="0.1"
/>
</svg>
`;
@@ -1,104 +0,0 @@
import { NoteDisplayMode } from '@blocksuite/affine-model';
import { stopPropagation } from '@blocksuite/affine-shared/utils';
import { WithDisposable } from '@blocksuite/global/lit';
import { EdgelessIcon, PageIcon } from '@blocksuite/icons/lit';
import { css, html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
export class NoteDisplayModePanel extends WithDisposable(LitElement) {
static override styles = css`
:host {
display: flex;
flex-direction: column;
align-items: flex-start;
min-width: 180px;
width: var(--panel-width);
gap: 4px;
}
.item {
display: flex;
align-items: center;
width: 100%;
height: 30px;
padding: 4px 12px;
border-radius: 4px;
gap: 4px;
box-sizing: border-box;
cursor: pointer;
}
.item-label {
flex: 1 1 0;
}
.item-icon {
display: flex;
justify-content: center;
align-items: center;
gap: 4px;
color: var(--affine-icon-color);
svg {
width: 20px;
height: 20px;
}
}
.item:hover,
.item.selected {
background-color: var(--affine-hover-color);
}
`;
private _DisplayModeIcon(mode: NoteDisplayMode) {
switch (mode) {
case NoteDisplayMode.DocAndEdgeless:
return html`${PageIcon()} ${EdgelessIcon()}`;
case NoteDisplayMode.DocOnly:
return html`${PageIcon()}`;
case NoteDisplayMode.EdgelessOnly:
return html`${EdgelessIcon()}`;
}
}
private _DisplayModeLabel(mode: NoteDisplayMode) {
switch (mode) {
case NoteDisplayMode.DocAndEdgeless:
return 'In Both';
case NoteDisplayMode.DocOnly:
return 'In Page Only';
case NoteDisplayMode.EdgelessOnly:
return 'In Edgeless Only';
}
}
override render() {
this.style.setProperty('--panel-width', `${this.panelWidth}px`);
return repeat(
Object.keys(NoteDisplayMode),
mode => mode,
mode => {
const displayMode =
NoteDisplayMode[mode as keyof typeof NoteDisplayMode];
const isSelected = displayMode === this.displayMode;
return html`<div
class="item ${isSelected ? 'selected' : ''} ${displayMode}"
@click=${() => this.onSelect(displayMode)}
@dblclick=${stopPropagation}
@pointerdown=${stopPropagation}
>
<div class="item-label">${this._DisplayModeLabel(displayMode)}</div>
<div class="item-icon">${this._DisplayModeIcon(displayMode)}</div>
</div>`;
}
);
}
@property({ attribute: false })
accessor displayMode!: NoteDisplayMode;
@property({ attribute: false })
accessor onSelect!: (displayMode: NoteDisplayMode) => void;
@property({ attribute: false })
accessor panelWidth = 240;
}
@@ -1,144 +0,0 @@
import { ColorScheme, NoteShadow } from '@blocksuite/affine-model';
import { WithDisposable } from '@blocksuite/global/lit';
import { css, html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js';
import { NoteNoShadowIcon, NoteShadowSampleIcon } from './icons';
const SHADOWS = [
{
type: NoteShadow.None,
styles: {
light: '',
dark: '',
},
tooltip: 'No shadow',
},
{
type: NoteShadow.Box,
styles: {
light:
'0px 0.2px 4.8px 0px rgba(66, 65, 73, 0.2), 0px 0px 1.6px 0px rgba(66, 65, 73, 0.2)',
dark: '0px 0.2px 6px 0px rgba(0, 0, 0, 0.44), 0px 0px 2px 0px rgba(0, 0, 0, 0.66)',
},
tooltip: 'Box shadow',
},
{
type: NoteShadow.Sticker,
styles: {
light:
'0px 9.6px 10.4px -4px rgba(66, 65, 73, 0.07), 0px 10.4px 7.2px -8px rgba(66, 65, 73, 0.22)',
dark: '0px 9.6px 10.4px -4px rgba(0, 0, 0, 0.66), 0px 10.4px 7.2px -8px rgba(0, 0, 0, 0.44)',
},
tooltip: 'Sticker shadow',
},
{
type: NoteShadow.Paper,
styles: {
light:
'0px 0px 0px 4px rgba(255, 255, 255, 1), 0px 1.2px 2.4px 4.8px rgba(66, 65, 73, 0.16)',
dark: '0px 1.2px 2.4px 4.8px rgba(0, 0, 0, 0.36), 0px 0px 0px 3.4px rgba(75, 75, 75, 1)',
},
tooltip: 'Paper shadow',
},
{
type: NoteShadow.Float,
styles: {
light:
'0px 5.2px 12px 0px rgba(66, 65, 73, 0.13), 0px 0px 0.4px 1px rgba(0, 0, 0, 0.06)',
dark: '0px 5.2px 12px 0px rgba(0, 0, 0, 0.66), 0px 0px 0.4px 1px rgba(0, 0, 0, 0.44)',
},
tooltip: 'Floation shadow',
},
{
type: NoteShadow.Film,
styles: {
light:
'0px 0px 0px 1.4px rgba(0, 0, 0, 1), 2.4px 2.4px 0px 1px rgba(0, 0, 0, 1)',
dark: '0px 0px 0px 1.4px rgba(178, 178, 178, 1), 2.4px 2.4px 0px 1px rgba(178, 178, 178, 1)',
},
tooltip: 'Film shadow',
},
];
export class EdgelessNoteShadowPanel extends WithDisposable(LitElement) {
static override styles = css`
:host {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.item {
padding: 8px;
border-radius: 4px;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
}
.item-icon {
display: flex;
justify-content: center;
align-items: center;
}
.item:hover {
background-color: var(--affine-hover-color);
}
`;
override render() {
return repeat(
SHADOWS,
shadow => shadow,
(shadow, index) =>
html`<style>
.item-icon svg rect:first-of-type {
fill: ${this.background.startsWith('--')
? `var(${this.background})`
: this.background};
}
</style>
<div
class="item"
@click=${() => this.onSelect(shadow.type)}
style=${styleMap({
border:
this.value === shadow.type
? '1px solid var(--affine-brand-color)'
: 'none',
})}
>
<edgeless-tool-icon-button
class="item-icon"
data-testid=${shadow.type.replace('--', '')}
.tooltip=${shadow.tooltip}
.tipPosition=${'bottom'}
.iconContainerPadding=${0}
style=${styleMap({
boxShadow: `${this.theme === ColorScheme.Dark ? shadow.styles.dark : shadow.styles.light}`,
})}
>
${index === 0 ? NoteNoShadowIcon : NoteShadowSampleIcon}
</edgeless-tool-icon-button>
</div>`
);
}
@property({ attribute: false })
accessor background!: string;
@property({ attribute: false })
accessor onSelect!: (value: string) => void;
@property({ attribute: false })
accessor theme!: ColorScheme;
@property({ attribute: false })
accessor value!: string;
}
@@ -1,66 +0,0 @@
import {
ShapeComponentConfig,
type ShapeTool,
} from '@blocksuite/affine-gfx-shape';
import { ShapeStyle } from '@blocksuite/affine-model';
import { css, html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import { Subject } from 'rxjs';
export class EdgelessShapePanel extends LitElement {
static override styles = css`
:host {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
`;
slots = {
select: new Subject<ShapeTool['activatedOption']['shapeName']>(),
};
private _onSelect(value: ShapeTool['activatedOption']['shapeName']) {
this.selectedShape = value;
this.slots.select.next(value);
}
override disconnectedCallback(): void {
this.slots.select.complete();
super.disconnectedCallback();
}
override render() {
return repeat(
ShapeComponentConfig,
item => item.name,
({ name, generalIcon, scribbledIcon, tooltip, disabled }) =>
html`<edgeless-tool-icon-button
.disabled=${disabled}
.tooltip=${tooltip}
.active=${this.selectedShape === name}
.activeMode=${'background'}
.iconSize=${'20px'}
@click=${() => {
if (disabled) return;
this._onSelect(name);
}}
>
${this.shapeStyle === ShapeStyle.General
? generalIcon
: scribbledIcon}
</edgeless-tool-icon-button>`
);
}
@property({ attribute: false })
accessor selectedShape:
| ShapeTool['activatedOption']['shapeName']
| null
| undefined = undefined;
@property({ attribute: false })
accessor shapeStyle: ShapeStyle = ShapeStyle.Scribbled;
}
@@ -1,59 +0,0 @@
import { ShapeStyle } from '@blocksuite/affine-model';
import { StyleGeneralIcon, StyleScribbleIcon } from '@blocksuite/icons/lit';
import { css, html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
const SHAPE_STYLE_LIST = [
{
value: ShapeStyle.General,
icon: StyleGeneralIcon(),
},
{
value: ShapeStyle.Scribbled,
icon: StyleScribbleIcon(),
},
];
export class EdgelessShapeStylePanel extends LitElement {
static override styles = css`
:host {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
`;
private _onSelect(value: ShapeStyle) {
this.value = value;
if (this.onSelect) {
this.onSelect(value);
}
}
override render() {
return repeat(
SHAPE_STYLE_LIST,
item => item.value,
({ value, icon }) =>
html`<edgeless-tool-icon-button
.tipPosition=${'top'}
.activeMode=${'background'}
aria-label=${value}
.tooltip=${value}
.active=${this.value === value}
.iconSize=${'20px'}
@click=${() => this._onSelect(value)}
>
${icon}
</edgeless-tool-icon-button>`
);
}
@property({ attribute: false })
accessor onSelect: undefined | ((value: ShapeStyle) => void) = undefined;
@property({ attribute: false })
accessor value!: ShapeStyle;
}
@@ -1,334 +0,0 @@
import {
EdgelessCRUDIdentifier,
getSurfaceBlock,
TextUtils,
} from '@blocksuite/affine-block-surface';
import type { ConnectorElementModel } from '@blocksuite/affine-model';
import type { RichText } from '@blocksuite/affine-rich-text';
import { ThemeProvider } from '@blocksuite/affine-shared/services';
import { almostEqual } from '@blocksuite/affine-shared/utils';
import { type BlockComponent, ShadowlessElement } from '@blocksuite/block-std';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import { RANGE_SYNC_EXCLUDE_ATTR } from '@blocksuite/block-std/inline';
import { Bound, Vec } from '@blocksuite/global/gfx';
import { WithDisposable } from '@blocksuite/global/lit';
import { css, html, nothing } from 'lit';
import { property, query } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import * as Y from 'yjs';
const HORIZONTAL_PADDING = 2;
const VERTICAL_PADDING = 2;
const BORDER_WIDTH = 1;
export class EdgelessConnectorLabelEditor extends WithDisposable(
ShadowlessElement
) {
static override styles = css`
.edgeless-connector-label-editor {
position: absolute;
left: 0;
top: 0;
transform-origin: center;
z-index: 10;
padding: ${VERTICAL_PADDING}px ${HORIZONTAL_PADDING}px;
border: ${BORDER_WIDTH}px solid var(--affine-primary-color, #1e96eb);
background: var(--affine-background-primary-color, #fff);
border-radius: 2px;
box-shadow: 0px 0px 0px 2px rgba(30, 150, 235, 0.3);
box-sizing: border-box;
overflow: visible;
.inline-editor {
white-space: pre-wrap !important;
outline: none;
}
.inline-editor span {
word-break: normal !important;
overflow-wrap: anywhere !important;
}
.edgeless-connector-label-editor-placeholder {
pointer-events: none;
color: var(--affine-text-disable-color);
white-space: nowrap;
}
}
`;
get crud() {
return this.edgeless.std.get(EdgelessCRUDIdentifier);
}
get gfx() {
return this.edgeless.std.get(GfxControllerIdentifier);
}
get selection() {
return this.gfx.selection;
}
private _isComposition = false;
private _keeping = false;
private _resizeObserver: ResizeObserver | null = null;
private readonly _updateLabelRect = () => {
const { connector, edgeless } = this;
if (!connector || !edgeless) return;
if (!this.inlineEditorContainer) return;
const newWidth = this.inlineEditorContainer.scrollWidth;
const newHeight = this.inlineEditorContainer.scrollHeight;
const center = connector.getPointByOffsetDistance(
connector.labelOffset.distance
);
const bounds = Bound.fromCenter(center, newWidth, newHeight);
const labelXYWH = bounds.toXYWH();
if (
!connector.labelXYWH ||
labelXYWH.some((p, i) => !almostEqual(p, connector.labelXYWH![i]))
) {
this.crud.updateElement(connector.id, {
labelXYWH,
});
}
};
get inlineEditor() {
return this.richText.inlineEditor;
}
get inlineEditorContainer() {
return this.inlineEditor?.rootElement;
}
override connectedCallback() {
super.connectedCallback();
this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true');
}
override disconnectedCallback() {
super.disconnectedCallback();
this._resizeObserver?.disconnect();
this._resizeObserver = null;
}
override firstUpdated() {
const { edgeless, connector, selection } = this;
const dispatcher = edgeless.std.event;
const store = edgeless.std.store;
this._resizeObserver = new ResizeObserver(() => {
this._updateLabelRect();
this.requestUpdate();
});
this._resizeObserver.observe(this.richText);
this.updateComplete
.then(() => {
if (!this.inlineEditor) return;
this.inlineEditor.selectAll();
this.inlineEditor.slots.renderComplete.subscribe(() => {
this.requestUpdate();
});
this.disposables.add(
dispatcher.add('keyDown', ctx => {
const state = ctx.get('keyboardState');
const { key, ctrlKey, metaKey, altKey, shiftKey, isComposing } =
state.raw;
const onlyCmd = (ctrlKey || metaKey) && !altKey && !shiftKey;
const isModEnter = onlyCmd && key === 'Enter';
const isEscape = key === 'Escape';
if (!isComposing && (isModEnter || isEscape)) {
this.inlineEditorContainer?.blur();
selection.set({
elements: [connector.id],
editing: false,
});
return true;
}
return false;
})
);
const surface = getSurfaceBlock(store);
if (surface) {
this.disposables.add(
surface.elementUpdated.subscribe(({ id }) => {
if (id === connector.id) this.requestUpdate();
})
);
}
this.disposables.add(
this.gfx.viewport.viewportUpdated.subscribe(() => {
this.requestUpdate();
})
);
this.disposables.add(dispatcher.add('click', () => true));
this.disposables.add(dispatcher.add('doubleClick', () => true));
this.disposables.add(() => {
if (connector.text) {
const text = connector.text.toString();
const trimed = text.trim();
const len = trimed.length;
if (len === 0) {
// reset
this.crud.updateElement(connector.id, {
text: undefined,
labelXYWH: undefined,
labelOffset: undefined,
});
} else if (len < text.length) {
this.crud.updateElement(connector.id, {
// @TODO: trim in Y.Text?
text: new Y.Text(trimed),
});
}
}
connector.lableEditing = false;
selection.set({
elements: [],
editing: false,
});
});
if (!this.inlineEditorContainer) return;
this.disposables.addFromEvent(
this.inlineEditorContainer,
'blur',
() => {
if (this._keeping) return;
this.remove();
}
);
this.disposables.addFromEvent(
this.inlineEditorContainer,
'compositionstart',
() => {
this._isComposition = true;
this.requestUpdate();
}
);
this.disposables.addFromEvent(
this.inlineEditorContainer,
'compositionend',
() => {
this._isComposition = false;
this.requestUpdate();
}
);
connector.lableEditing = true;
})
.catch(console.error);
}
override async getUpdateComplete(): Promise<boolean> {
const result = await super.getUpdateComplete();
await this.richText?.updateComplete;
return result;
}
override render() {
const { connector } = this;
const {
labelOffset: { distance },
labelStyle: {
fontFamily,
fontSize,
fontStyle,
fontWeight,
textAlign,
color: labelColor,
},
labelConstraints: { hasMaxWidth, maxWidth },
} = connector;
const lineHeight = TextUtils.getLineHeight(
fontFamily,
fontSize,
fontWeight
);
const { translateX, translateY, zoom } = this.gfx.viewport;
const [x, y] = Vec.mul(connector.getPointByOffsetDistance(distance), zoom);
const transformOperation = [
'translate(-50%, -50%)',
`translate(${translateX}px, ${translateY}px)`,
`translate(${x}px, ${y}px)`,
`scale(${zoom})`,
];
const isEmpty = !connector.text?.length && !this._isComposition;
const color = this.edgeless.std
.get(ThemeProvider)
.generateColorProperty(labelColor, '#000000');
return html`
<div
class="edgeless-connector-label-editor"
style=${styleMap({
fontFamily: `"${fontFamily}"`,
fontSize: `${fontSize}px`,
fontStyle,
fontWeight,
textAlign,
lineHeight: `${lineHeight}px`,
maxWidth: hasMaxWidth
? `${maxWidth + BORDER_WIDTH * 2 + HORIZONTAL_PADDING * 2}px`
: 'initial',
color,
transform: transformOperation.join(' '),
})}
>
<rich-text
.yText=${connector.text}
.enableFormat=${false}
style=${isEmpty
? styleMap({
position: 'absolute',
left: 0,
top: 0,
padding: `${VERTICAL_PADDING}px ${HORIZONTAL_PADDING}px`,
})
: nothing}
></rich-text>
${isEmpty
? html`
<span class="edgeless-connector-label-editor-placeholder">
Add text
</span>
`
: nothing}
</div>
`;
}
setKeeping(keeping: boolean) {
this._keeping = keeping;
}
@property({ attribute: false })
accessor connector!: ConnectorElementModel;
@property({ attribute: false })
accessor edgeless!: BlockComponent;
@query('rich-text')
accessor richText!: RichText;
}
@@ -1,56 +0,0 @@
import { menu } from '@blocksuite/affine-components/context-menu';
import { ConnectorMode } from '@blocksuite/affine-model';
import { EditPropsStore } from '@blocksuite/affine-shared/services';
import type { DenseMenuBuilder } from '@blocksuite/affine-widget-edgeless-toolbar';
import {
ConnectorCIcon,
ConnectorEIcon,
ConnectorLIcon,
} from '@blocksuite/icons/lit';
export const buildConnectorDenseMenu: DenseMenuBuilder = (edgeless, gfx) => {
const prevMode =
edgeless.std.get(EditPropsStore).lastProps$.value.connector.mode;
const isSelected = gfx.tool.currentToolName$.peek() === 'connector';
const createSelect =
(mode: ConnectorMode, record = true) =>
() => {
gfx.tool.setTool('connector', {
mode,
});
record &&
edgeless.std.get(EditPropsStore).recordLastProps('connector', { mode });
};
const iconSize = { width: '20', height: '20' };
return menu.subMenu({
name: 'Connector',
prefix: ConnectorCIcon(iconSize),
select: createSelect(prevMode, false),
isSelected,
options: {
items: [
menu.action({
name: 'Curve',
prefix: ConnectorCIcon(iconSize),
select: createSelect(ConnectorMode.Curve),
isSelected: isSelected && prevMode === ConnectorMode.Curve,
}),
menu.action({
name: 'Elbowed',
prefix: ConnectorEIcon(iconSize),
select: createSelect(ConnectorMode.Orthogonal),
isSelected: isSelected && prevMode === ConnectorMode.Orthogonal,
}),
menu.action({
name: 'Straight',
prefix: ConnectorLIcon(iconSize),
select: createSelect(ConnectorMode.Straight),
isSelected: isSelected && prevMode === ConnectorMode.Straight,
}),
],
},
});
};
@@ -1,152 +0,0 @@
import {
ConnectorMode,
DefaultTheme,
type LineWidth,
} from '@blocksuite/affine-model';
import {
EditPropsStore,
FeatureFlagService,
ThemeProvider,
} from '@blocksuite/affine-shared/services';
import type { ColorEvent } from '@blocksuite/affine-shared/utils';
import { EdgelessToolbarToolMixin } from '@blocksuite/affine-widget-edgeless-toolbar';
import type { GfxToolsFullOptionValue } from '@blocksuite/block-std/gfx';
import { SignalWatcher } from '@blocksuite/global/lit';
import {
ConnectorCIcon,
ConnectorEIcon,
ConnectorLIcon,
} from '@blocksuite/icons/lit';
import { computed } from '@preact/signals-core';
import { css, html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
function ConnectorModeButtonGroup(
mode: ConnectorMode,
setConnectorMode: (props: Record<string, unknown>) => void
) {
/**
* There is little hacky on rendering tooltip.
* We don't want either tooltip overlap the top button or tooltip on left.
* So we put the lower button's tooltip as the first element of the button group container
*/
return html`
<div class="connector-mode-button-group">
<edgeless-tool-icon-button
.active=${mode === ConnectorMode.Curve}
.activeMode=${'background'}
.tooltip=${'Curve'}
.iconSize=${'20px'}
@click=${() => setConnectorMode({ mode: ConnectorMode.Curve })}
>
${ConnectorCIcon()}
</edgeless-tool-icon-button>
<edgeless-tool-icon-button
.active=${mode === ConnectorMode.Orthogonal}
.activeMode=${'background'}
.tooltip=${'Elbowed'}
.iconSize=${'20px'}
@click=${() => setConnectorMode({ mode: ConnectorMode.Orthogonal })}
>
${ConnectorEIcon()}
</edgeless-tool-icon-button>
<edgeless-tool-icon-button
.active=${mode === ConnectorMode.Straight}
.activeMode=${'background'}
.tooltip=${'Straight'}
.iconSize=${'20px'}
@click=${() => setConnectorMode({ mode: ConnectorMode.Straight })}
>
${ConnectorLIcon()}
</edgeless-tool-icon-button>
</div>
`;
}
export class EdgelessConnectorMenu extends EdgelessToolbarToolMixin(
SignalWatcher(LitElement)
) {
static override styles = css`
:host {
position: absolute;
display: flex;
z-index: -1;
}
.connector-submenu-content {
display: flex;
height: 24px;
align-items: center;
justify-content: center;
}
.connector-mode-button-group {
display: flex;
justify-content: center;
align-items: center;
gap: 14px;
}
.connector-mode-button-group > edgeless-tool-icon-button svg {
fill: var(--affine-icon-color);
}
.submenu-divider {
width: 1px;
height: 24px;
margin: 0 16px;
background-color: var(--affine-border-color);
display: inline-block;
}
`;
private readonly _props$ = computed(() => {
const { mode, stroke, strokeWidth } =
this.edgeless.std.get(EditPropsStore).lastProps$.value.connector;
return { mode, stroke, strokeWidth };
});
private readonly _theme$ = computed(() => {
return this.edgeless.std.get(ThemeProvider).theme$.value;
});
override type: GfxToolsFullOptionValue['type'] = 'connector';
override render() {
const { stroke, strokeWidth, mode } = this._props$.value;
const connectorModeButtonGroup = ConnectorModeButtonGroup(
mode,
this.onChange
);
return html`
<edgeless-slide-menu>
<div class="connector-submenu-content">
${connectorModeButtonGroup}
<div class="submenu-divider"></div>
<edgeless-line-width-panel
.selectedSize=${strokeWidth}
@select=${(e: CustomEvent<LineWidth>) =>
this.onChange({ strokeWidth: e.detail })}
>
</edgeless-line-width-panel>
<div class="submenu-divider"></div>
<edgeless-color-panel
class="one-way"
.value=${stroke}
.theme=${this._theme$.value}
.palettes=${DefaultTheme.StrokeColorShortPalettes}
.hasTransparent=${!this.edgeless.doc
.get(FeatureFlagService)
.getFlag('enable_color_picker')}
@select=${(e: ColorEvent) =>
this.onChange({ stroke: e.detail.value })}
></edgeless-color-panel>
</div>
</edgeless-slide-menu>
`;
}
@property({ attribute: false })
accessor onChange!: (props: Record<string, unknown>) => void;
}
@@ -1,77 +0,0 @@
import { ConnectorMode, getConnectorModeName } from '@blocksuite/affine-model';
import { EditPropsStore } from '@blocksuite/affine-shared/services';
import { QuickToolMixin } from '@blocksuite/affine-widget-edgeless-toolbar';
import { SignalWatcher } from '@blocksuite/global/lit';
import {
ConnectorCIcon,
ConnectorEIcon,
ConnectorLIcon,
} from '@blocksuite/icons/lit';
import { computed } from '@preact/signals-core';
import { css, html, LitElement } from 'lit';
const IcomMap = {
[ConnectorMode.Straight]: ConnectorLIcon(),
[ConnectorMode.Orthogonal]: ConnectorEIcon(),
[ConnectorMode.Curve]: ConnectorCIcon(),
};
export class EdgelessConnectorToolButton extends QuickToolMixin(
SignalWatcher(LitElement)
) {
static override styles = css`
:host {
display: flex;
}
`;
private readonly _mode$ = computed(() => {
return this.edgeless.std.get(EditPropsStore).lastProps$.value.connector
.mode;
});
override type = 'connector' as const;
private _toggleMenu() {
if (this.tryDisposePopper()) return;
const menu = this.createPopper('edgeless-connector-menu', this);
menu.element.edgeless = this.edgeless;
menu.element.onChange = (props: Record<string, unknown>) => {
this.edgeless.std.get(EditPropsStore).recordLastProps('connector', props);
this.setEdgelessTool(this.type, {
mode: this._mode$.value,
});
};
}
override render() {
const { active } = this;
const mode = this._mode$.value;
return html`
<edgeless-tool-icon-button
class="edgeless-connector-button"
.tooltip=${this.popper
? ''
: html`<affine-tooltip-content-with-shortcut
data-tip="${getConnectorModeName(mode)}"
data-shortcut="${'C'}"
></affine-tooltip-content-with-shortcut>`}
.tooltipOffset=${17}
.active=${active}
.iconContainerPadding=${6}
.iconSize=${'24px'}
@click=${() => {
// don't update tool before toggling menu
this._toggleMenu();
this.gfx.tool.setTool('connector', {
mode,
});
}}
>
${IcomMap[mode]}
<toolbar-arrow-up-icon></toolbar-arrow-up-icon>
</edgeless-tool-icon-button>
`;
}
}
@@ -1,4 +1,5 @@
import { frameQuickTool } from '@blocksuite/affine-block-frame';
import { connectorQuickTool } from '@blocksuite/affine-gfx-connector';
import { noteSeniorTool } from '@blocksuite/affine-gfx-note';
import { shapeSeniorTool } from '@blocksuite/affine-gfx-shape';
import {
@@ -18,15 +19,6 @@ const defaultQuickTool = QuickToolExtension('default', ({ block }) => {
};
});
const connectorQuickTool = QuickToolExtension('connector', ({ block }) => {
return {
type: 'connector',
content: html`<edgeless-connector-tool-button
.edgeless=${block}
></edgeless-connector-tool-button>`,
};
});
const linkQuickTool = QuickToolExtension('link', ({ block, gfx }) => {
return {
content: html`<edgeless-link-tool-button
@@ -1,419 +0,0 @@
import {
ConnectorUtils,
EdgelessCRUDIdentifier,
TextUtils,
} from '@blocksuite/affine-block-surface';
import {
packColor,
type PickColorEvent,
} from '@blocksuite/affine-components/color-picker';
import type { LineDetailType } from '@blocksuite/affine-components/edgeless-line-styles-panel';
import {
ConnectorElementModel,
type ConnectorElementProps,
type ConnectorLabelProps,
ConnectorMode,
DEFAULT_FRONT_ENDPOINT_STYLE,
DEFAULT_REAR_ENDPOINT_STYLE,
DefaultTheme,
LineWidth,
PointStyle,
resolveColor,
StrokeStyle,
} from '@blocksuite/affine-model';
import type {
ToolbarContext,
ToolbarGenericAction,
ToolbarModuleConfig,
} from '@blocksuite/affine-shared/services';
import {
getMostCommonResolvedValue,
getMostCommonValue,
} from '@blocksuite/affine-shared/utils';
import type { MenuItem } from '@blocksuite/affine-widget-edgeless-toolbar';
import {
createTextActions,
getRootBlock,
LINE_STYLE_LIST,
renderMenu,
} from '@blocksuite/affine-widget-edgeless-toolbar';
import { Bound } from '@blocksuite/global/gfx';
import {
AddTextIcon,
ConnectorCIcon,
ConnectorEIcon,
ConnectorLIcon,
EndPointArrowIcon,
EndPointCircleIcon,
EndPointDiamondIcon,
EndPointTriangleIcon,
FlipDirectionIcon,
StartPointArrowIcon,
StartPointCircleIcon,
StartPointDiamondIcon,
StartPointIcon,
StartPointTriangleIcon,
} from '@blocksuite/icons/lit';
import { html } from 'lit';
import { styleMap } from 'lit/directives/style-map.js';
import { mountConnectorLabelEditor } from '../../utils/text';
const FRONT_ENDPOINT_STYLE_LIST = [
{
value: PointStyle.None,
icon: StartPointIcon(),
},
{
value: PointStyle.Arrow,
icon: StartPointArrowIcon(),
},
{
value: PointStyle.Triangle,
icon: StartPointTriangleIcon(),
},
{
value: PointStyle.Circle,
icon: StartPointCircleIcon(),
},
{
value: PointStyle.Diamond,
icon: StartPointDiamondIcon(),
},
] as const satisfies MenuItem<PointStyle>[];
const REAR_ENDPOINT_STYLE_LIST = [
{
value: PointStyle.Diamond,
icon: EndPointDiamondIcon(),
},
{
value: PointStyle.Circle,
icon: EndPointCircleIcon(),
},
{
value: PointStyle.Triangle,
icon: EndPointTriangleIcon(),
},
{
value: PointStyle.Arrow,
icon: EndPointArrowIcon(),
},
{
value: PointStyle.None,
icon: StartPointIcon(),
},
] as const satisfies MenuItem<PointStyle>[];
const CONNECTOR_MODE_LIST = [
{
key: 'Curve',
value: ConnectorMode.Curve,
icon: ConnectorCIcon(),
},
{
key: 'Elbowed',
value: ConnectorMode.Orthogonal,
icon: ConnectorEIcon(),
},
{
key: 'Straight',
value: ConnectorMode.Straight,
icon: ConnectorLIcon(),
},
] as const satisfies MenuItem<ConnectorMode>[];
export const builtinConnectorToolbarConfig = {
actions: [
{
id: 'a.stroke-color',
content(ctx) {
const models = ctx.getSurfaceModelsByType(ConnectorElementModel);
if (!models.length) return null;
const enableCustomColor = ctx.features.getFlag('enable_color_picker');
const theme = ctx.theme.edgeless$.value;
const field = 'stroke';
const firstModel = models[0];
const strokeWidth =
getMostCommonValue(models, 'strokeWidth') ?? LineWidth.Four;
const strokeStyle =
getMostCommonValue(models, 'strokeStyle') ?? StrokeStyle.Solid;
const stroke =
getMostCommonResolvedValue(models, field, stroke =>
resolveColor(stroke, theme)
) ?? resolveColor(DefaultTheme.connectorColor, theme);
const onPickColor = (e: PickColorEvent) => {
if (e.type === 'pick') {
const color = e.detail.value;
for (const model of models) {
const props = packColor(field, color);
ctx.std
.get(EdgelessCRUDIdentifier)
.updateElement(model.id, props);
}
return;
}
for (const model of models) {
model[e.type === 'start' ? 'stash' : 'pop'](field);
}
};
const onPickStrokeStyle = (e: CustomEvent<LineDetailType>) => {
e.stopPropagation();
const { type, value } = e.detail;
if (type === 'size') {
updateModelsWith(ctx, models, 'strokeWidth', value);
return;
}
updateModelsWith(ctx, models, 'strokeStyle', value);
};
return html`
<edgeless-color-picker-button
class="stroke-color"
.label="${'Stroke style'}"
.pick=${onPickColor}
.color=${stroke}
.theme=${theme}
.hollowCircle=${true}
.originalColor=${firstModel.stroke}
.enableCustomColor=${enableCustomColor}
>
<edgeless-line-styles-panel
slot="other"
style=${styleMap({
display: 'flex',
alignSelf: 'stretch',
gap: '8px',
})}
@select=${onPickStrokeStyle}
.lineSize=${strokeWidth}
.lineStyle=${strokeStyle}
></edgeless-line-styles-panel>
<editor-toolbar-separator
slot="separator"
data-orientation="horizontal"
></editor-toolbar-separator>
</edgeless-color-picker-button>
`;
},
},
{
id: 'b.style',
// TODO(@fundon): should add a feature flag
when: false,
content(ctx) {
const models = ctx.getSurfaceModelsByType(ConnectorElementModel);
if (!models.length) return null;
const field = 'rough';
const rough = getMostCommonValue(models, field) ?? false;
const onPick = (value: boolean) => {
updateModelsWith(ctx, models, field, value);
};
return renderMenu({
label: 'Style',
items: LINE_STYLE_LIST,
currentValue: rough,
onPick,
});
},
},
{
id: 'c.endpoint-style',
actions: [
{
id: 'a.start-point-style',
content(ctx) {
const models = ctx.getSurfaceModelsByType(ConnectorElementModel);
if (!models.length) return null;
const field = 'frontEndpointStyle';
const pointStyle =
getMostCommonValue(models, field) ?? DEFAULT_FRONT_ENDPOINT_STYLE;
const onPick = (value: PointStyle) => {
updateModelsWith(ctx, models, field, value);
};
return renderMenu({
label: 'Start point style',
items: FRONT_ENDPOINT_STYLE_LIST,
currentValue: pointStyle,
onPick,
});
},
},
{
id: 'b.flip-direction',
icon: FlipDirectionIcon(),
tooltip: 'Flip direction',
run(ctx) {
const models = ctx.getSurfaceModelsByType(ConnectorElementModel);
if (!models.length) return;
const frontEndpointStyle =
getMostCommonValue(models, 'frontEndpointStyle') ??
DEFAULT_FRONT_ENDPOINT_STYLE;
const rearEndpointStyle =
getMostCommonValue(models, 'rearEndpointStyle') ??
DEFAULT_REAR_ENDPOINT_STYLE;
if (frontEndpointStyle === rearEndpointStyle) return;
for (const model of models) {
ctx.std.get(EdgelessCRUDIdentifier).updateElement(model.id, {
frontEndpointStyle: rearEndpointStyle,
rearEndpointStyle: frontEndpointStyle,
});
}
},
},
{
id: 'c.end-point-style',
content(ctx) {
const models = ctx.getSurfaceModelsByType(ConnectorElementModel);
if (!models.length) return null;
const field = 'rearEndpointStyle';
const pointStyle =
getMostCommonValue(models, field) ?? DEFAULT_REAR_ENDPOINT_STYLE;
const onPick = (value: PointStyle) => {
updateModelsWith(ctx, models, field, value);
};
return renderMenu({
label: 'End point style',
items: REAR_ENDPOINT_STYLE_LIST,
currentValue: pointStyle,
onPick,
});
},
},
{
id: 'd.connector-shape',
content(ctx) {
const models = ctx.getSurfaceModelsByType(ConnectorElementModel);
if (!models.length) return null;
const field = 'mode';
const mode =
getMostCommonValue(models, field) ?? ConnectorMode.Orthogonal;
const onPick = (value: ConnectorMode) => {
updateModelsWith(ctx, models, field, value);
};
return renderMenu({
label: 'Shape',
tooltip: 'Connector shape',
items: CONNECTOR_MODE_LIST,
currentValue: mode,
onPick,
});
},
},
],
},
{
id: 'g.text',
tooltip: 'Add text',
icon: AddTextIcon(),
when(ctx) {
const models = ctx.getSurfaceModelsByType(ConnectorElementModel);
return models.length === 1 && !models[0].text;
},
run(ctx) {
const model = ctx.getCurrentModelByType(ConnectorElementModel);
if (!model) return;
const rootModel = ctx.store.root;
if (!rootModel) return;
const rootBlock = getRootBlock(ctx);
if (!rootBlock) return;
mountConnectorLabelEditor(model, rootBlock);
},
},
// id: `g.text`
...createTextActions(
ConnectorElementModel,
'connector',
(ctx, model, props) => {
if (!ConnectorUtils.isConnectorWithLabel(model)) return;
const labelStyle = { ...model.labelStyle, ...props };
// No need to adjust element bounds
if (props['textAlign']) {
ctx.std
.get(EdgelessCRUDIdentifier)
.updateElement(model.id, { labelStyle });
return;
}
const { fontFamily, fontStyle, fontSize, fontWeight } = labelStyle;
const {
text,
labelXYWH,
labelConstraints: { hasMaxWidth, maxWidth },
} = model;
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;
ctx.std.get(EdgelessCRUDIdentifier).updateElement(model.id, {
labelStyle,
labelXYWH: bounds.toXYWH(),
});
},
model => model.labelStyle,
(model, type, _) => model[type]('labelStyle')
).map<ToolbarGenericAction>(action => ({
...action,
id: `g.text-${action.id}`,
when(ctx) {
const models = ctx.getSurfaceModelsByType(ConnectorElementModel);
return models.length > 0 && models.every(model => model.hasLabel());
},
})),
],
when: ctx => ctx.getSurfaceModelsByType(ConnectorElementModel).length > 0,
} as const satisfies ToolbarModuleConfig;
function updateModelsWith<
T extends keyof Omit<ConnectorElementProps, keyof ConnectorLabelProps>,
>(
ctx: ToolbarContext,
models: ConnectorElementModel[],
field: T,
value: ConnectorElementProps[T]
) {
ctx.store.captureSync();
for (const model of models) {
ctx.std
.get(EdgelessCRUDIdentifier)
.updateElement(model.id, { [field]: value });
}
}
@@ -1,12 +1,12 @@
import { edgelessTextToolbarConfig } from '@blocksuite/affine-block-edgeless-text';
import { frameToolbarExtension } from '@blocksuite/affine-block-frame';
import { connectorToolbarConfig } from '@blocksuite/affine-gfx-connector';
import { textToolbarConfig } from '@blocksuite/affine-gfx-text';
import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services';
import { BlockFlavourIdentifier } from '@blocksuite/block-std';
import type { ExtensionType } from '@blocksuite/store';
import { builtinBrushToolbarConfig } from './brush';
import { builtinConnectorToolbarConfig } from './connector';
import { builtinGroupToolbarConfig } from './group';
import { builtinMindmapToolbarConfig } from './mindmap';
import { builtinLockedToolbarConfig, builtinMiscToolbarConfig } from './misc';
@@ -27,7 +27,7 @@ export const EdgelessElementToolbarExtension: ExtensionType[] = [
ToolbarModuleExtension({
id: BlockFlavourIdentifier('affine:surface:connector'),
config: builtinConnectorToolbarConfig,
config: connectorToolbarConfig,
}),
ToolbarModuleExtension({
@@ -4,6 +4,7 @@ import {
PresentTool,
} from '@blocksuite/affine-block-frame';
import { ConnectionOverlay } from '@blocksuite/affine-block-surface';
import { ConnectorTool } from '@blocksuite/affine-gfx-connector';
import { NoteTool } from '@blocksuite/affine-gfx-note';
import { ShapeTool } from '@blocksuite/affine-gfx-shape';
import { TextTool } from '@blocksuite/affine-gfx-text';
@@ -20,7 +21,6 @@ import { MindMapDragExtension } from './element-transform/mind-map-drag.js';
import { SnapExtension } from './element-transform/snap-manager.js';
import { MindMapIndicatorOverlay } from './element-transform/utils/indicator-overlay.js';
import { BrushTool } from './gfx-tool/brush-tool.js';
import { ConnectorTool } from './gfx-tool/connector-tool.js';
import { DefaultTool } from './gfx-tool/default-tool.js';
import { EmptyTool } from './gfx-tool/empty-tool.js';
import { EraserTool } from './gfx-tool/eraser-tool.js';
@@ -2,6 +2,7 @@ import { insertLinkByQuickSearchCommand } from '@blocksuite/affine-block-bookmar
import { EdgelessTextBlockComponent } from '@blocksuite/affine-block-edgeless-text';
import { isNoteBlock } from '@blocksuite/affine-block-surface';
import { toast } from '@blocksuite/affine-components/toast';
import { mountConnectorLabelEditor } from '@blocksuite/affine-gfx-connector';
import { mountShapeTextEditor, ShapeTool } from '@blocksuite/affine-gfx-shape';
import {
ConnectorElementModel,
@@ -48,7 +49,6 @@ import {
isSingleMindMapNode,
} from './utils/mindmap.js';
import { isCanvasElement } from './utils/query.js';
import { mountConnectorLabelEditor } from './utils/text.js';
export class EdgelessPageKeyboardManager extends PageKeyboardManager {
get gfx() {
@@ -1,231 +0,0 @@
import {
calculateNearestLocation,
CanvasElementType,
type ConnectionOverlay,
ConnectorEndpointLocations,
ConnectorEndpointLocationsOnTriangle,
OverlayIdentifier,
} from '@blocksuite/affine-block-surface';
import type {
Connection,
ConnectorElementModel,
ConnectorMode,
} from '@blocksuite/affine-model';
import {
GroupElementModel,
ShapeElementModel,
ShapeType,
} from '@blocksuite/affine-model';
import { TelemetryProvider } from '@blocksuite/affine-shared/services';
import type { PointerEventState } from '@blocksuite/block-std';
import { BaseTool, type GfxModel } from '@blocksuite/block-std/gfx';
import type { IBound, IVec } from '@blocksuite/global/gfx';
import { Bound } from '@blocksuite/global/gfx';
enum ConnectorToolMode {
// Dragging connect
Dragging,
// Quick connect
Quick,
}
export type ConnectorToolOptions = {
mode: ConnectorMode;
};
export class ConnectorTool extends BaseTool<ConnectorToolOptions> {
static override toolName: string = 'connector';
// Likes pressing `ESC`
private _allowCancel = false;
private _connector: ConnectorElementModel | null = null;
private _mode: ConnectorToolMode = ConnectorToolMode.Dragging;
private _source: Connection | null = null;
private _sourceBounds: IBound | null = null;
private _sourceLocations: IVec[] = ConnectorEndpointLocations;
private _startPoint: IVec | null = null;
private get _overlay() {
return this.std.get(OverlayIdentifier('connection')) as ConnectionOverlay;
}
private _createConnector() {
if (!(this._source && this._startPoint) || !this.gfx.surface) {
this._source = null;
this._startPoint = null;
return;
}
this.doc.captureSync();
const id = this.gfx.surface.addElement({
type: CanvasElementType.CONNECTOR,
mode: this.activatedOption.mode,
controllers: [],
source: this._source,
target: { position: this._startPoint },
});
this.gfx.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', {
control: 'canvas:draw',
page: 'whiteboard editor',
module: 'toolbar',
segment: 'toolbar',
type: CanvasElementType.CONNECTOR,
});
const connector = this.gfx.getElementById(id);
if (!connector) {
this._source = null;
this._startPoint = null;
return;
}
this._connector = connector as ConnectorElementModel;
}
override click() {
if (this._mode === ConnectorToolMode.Dragging) return;
if (!this._connector) return;
const { id, source, target } = this._connector;
let focusedId = id;
if (source?.id && !target?.id) {
focusedId = source.id;
this._allowCancel = true;
}
this.gfx.tool.setTool('default');
this.gfx.selection.set({ elements: [focusedId] });
}
override deactivate() {
const id = this._connector?.id;
if (this._allowCancel && id) {
this.gfx.surface?.deleteElement(id);
}
this._overlay?.clear();
this._mode = ConnectorToolMode.Dragging;
this._connector = null;
this._source = null;
this._sourceBounds = null;
this._startPoint = null;
this._allowCancel = false;
}
override dragEnd() {
if (this._mode === ConnectorToolMode.Quick) return;
if (!this._connector) return;
const connector = this._connector;
this.doc.captureSync();
this.gfx.tool.setTool('default');
this.gfx.selection.set({ elements: [connector.id] });
}
override dragMove(e: PointerEventState) {
this.findTargetByPoint([e.x, e.y]);
}
override dragStart() {
if (this._mode === ConnectorToolMode.Quick) return;
this._createConnector();
}
findTargetByPoint(point: IVec) {
if (!this._connector || !this.gfx.surface) return;
const { _connector } = this;
point = this.gfx.viewport.toModelCoord(point[0], point[1]);
const excludedIds = [];
if (_connector.source?.id) {
excludedIds.push(_connector.source.id);
}
const target = this._overlay?.renderConnector(point, excludedIds);
this.gfx.updateElement(_connector, { target });
}
override pointerDown(e: PointerEventState) {
this._startPoint = this.gfx.viewport.toModelCoord(e.x, e.y);
this._source = this._overlay?.renderConnector(this._startPoint) ?? null;
}
override pointerMove(e: PointerEventState) {
if (this._mode === ConnectorToolMode.Dragging) return;
if (!this._sourceBounds) return;
if (!this._connector) return;
const sourceId = this._connector.source?.id;
if (!sourceId) return;
const point = this.gfx.viewport.toModelCoord(e.x, e.y);
const target = this._overlay!.renderConnector(point, [sourceId]);
this._allowCancel = !target.id;
this._connector.source.position = calculateNearestLocation(
point,
this._sourceBounds,
this._sourceLocations
);
this.gfx.updateElement(this._connector, {
target,
source: this._connector.source,
});
}
override pointerUp(_: PointerEventState): void {
this._overlay?.clear();
}
quickConnect(point: IVec, element: GfxModel) {
this._startPoint = this.gfx.viewport.toModelCoord(point[0], point[1]);
this._mode = ConnectorToolMode.Quick;
this._sourceBounds = Bound.deserialize(element.xywh);
this._sourceBounds.rotate = element.rotate;
this._sourceLocations =
element instanceof ShapeElementModel &&
element.shapeType === ShapeType.Triangle
? ConnectorEndpointLocationsOnTriangle
: ConnectorEndpointLocations;
this._source = {
id: element.id,
position: calculateNearestLocation(
this._startPoint,
this._sourceBounds,
this._sourceLocations
),
};
this._allowCancel = true;
this._createConnector();
if (element instanceof GroupElementModel && this._overlay) {
this._overlay.sourceBounds = this._sourceBounds;
}
this.findTargetByPoint(point);
}
}
declare module '@blocksuite/block-std/gfx' {
interface GfxToolsMap {
connector: ConnectorTool;
}
interface GfxToolsOption {
connector: ConnectorToolOptions;
}
}
@@ -8,6 +8,7 @@ import {
isNoteBlock,
OverlayIdentifier,
} from '@blocksuite/affine-block-surface';
import { mountConnectorLabelEditor } from '@blocksuite/affine-gfx-connector';
import { mountShapeTextEditor } from '@blocksuite/affine-gfx-shape';
import { addText, mountTextElementEditor } from '@blocksuite/affine-gfx-text';
import type {
@@ -51,10 +52,7 @@ import type { EdgelessRootBlockComponent } from '../index.js';
import { prepareCloneData } from '../utils/clone-utils.js';
import { calPanDelta } from '../utils/panning-utils.js';
import { isCanvasElement, isEdgelessTextBlock } from '../utils/query.js';
import {
mountConnectorLabelEditor,
mountGroupTitleEditor,
} from '../utils/text.js';
import { mountGroupTitleEditor } from '../utils/text.js';
import { DefaultModeDragType } from './default-tool-ext/ext.js';
export class DefaultTool extends BaseTool {
@@ -1,5 +1,4 @@
export { BrushTool } from './brush-tool.js';
export { ConnectorTool, type ConnectorToolOptions } from './connector-tool.js';
export { DefaultTool } from './default-tool.js';
export { EmptyTool } from './empty-tool.js';
export { EraserTool } from './eraser-tool.js';
@@ -1,16 +1,8 @@
import { EdgelessCRUDIdentifier } from '@blocksuite/affine-block-surface';
import type {
ConnectorElementModel,
GroupElementModel,
} from '@blocksuite/affine-model';
import type { GroupElementModel } from '@blocksuite/affine-model';
import type { BlockComponent } from '@blocksuite/block-std';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import type { IVec } from '@blocksuite/global/gfx';
import { Bound } from '@blocksuite/global/gfx';
import * as Y from 'yjs';
import { EdgelessConnectorLabelEditor } from '../components/text/edgeless-connector-label-editor.js';
import { EdgelessGroupTitleEditor } from '../components/text/edgeless-group-title-editor.js';
export function mountGroupTitleEditor(
@@ -39,57 +31,3 @@ export function mountGroupTitleEditor(
mountElm.append(groupEditor);
}
export function mountConnectorLabelEditor(
connector: ConnectorElementModel,
edgeless: BlockComponent,
point?: IVec
) {
const mountElm = edgeless.querySelector('.edgeless-mount-point');
if (!mountElm) {
throw new BlockSuiteError(
ErrorCode.ValueNotExists,
"edgeless block's mount point does not exist"
);
}
const gfx = edgeless.std.get(GfxControllerIdentifier);
gfx.tool.setTool('default');
gfx.selection.set({
elements: [connector.id],
editing: true,
});
if (!connector.text) {
const text = new Y.Text();
const labelOffset = connector.labelOffset;
let labelXYWH = connector.labelXYWH ?? [0, 0, 16, 16];
if (point) {
const center = connector.getNearestPoint(point);
const distance = connector.getOffsetDistanceByPoint(center as IVec);
const bounds = Bound.fromXYWH(labelXYWH);
bounds.center = center;
labelOffset.distance = distance;
labelXYWH = bounds.toXYWH();
}
edgeless.std.get(EdgelessCRUDIdentifier).updateElement(connector.id, {
text,
labelXYWH,
labelOffset: { ...labelOffset },
});
}
const editor = new EdgelessConnectorLabelEditor();
editor.connector = connector;
editor.edgeless = edgeless;
mountElm.append(editor);
editor.updateComplete
.then(() => {
editor.inlineEditor?.focusEnd();
})
.catch(console.error);
}
@@ -1,3 +1,4 @@
import { effects as gfxConnectorEffects } from '@blocksuite/affine-gfx-connector/effects';
import { effects as gfxNoteEffects } from '@blocksuite/affine-gfx-note/effects';
import { effects as gfxShapeEffects } from '@blocksuite/affine-gfx-shape/effects';
import { effects as gfxCanvasTextEffects } from '@blocksuite/affine-gfx-text/effects';
@@ -5,18 +6,13 @@ import { effects as widgetEdgelessToolbarEffects } from '@blocksuite/affine-widg
import { EdgelessAutoCompletePanel } from './edgeless/components/auto-complete/auto-complete-panel.js';
import { EdgelessAutoComplete } from './edgeless/components/auto-complete/edgeless-auto-complete.js';
import { EdgelessConnectorHandle } from './edgeless/components/connector/connector-handle.js';
import {
NOTE_SLICER_WIDGET,
NoteSlicer,
} from './edgeless/components/note-slicer/index.js';
import { EdgelessFontFamilyPanel } from './edgeless/components/panel/font-family-panel.js';
import { EdgelessFontWeightAndStylePanel } from './edgeless/components/panel/font-weight-and-style-panel.js';
import { NoteDisplayModePanel } from './edgeless/components/panel/note-display-mode-panel.js';
import { EdgelessNoteShadowPanel } from './edgeless/components/panel/note-shadow-panel.js';
import { EdgelessScalePanel } from './edgeless/components/panel/scale-panel.js';
import { EdgelessShapePanel } from './edgeless/components/panel/shape-panel.js';
import { EdgelessShapeStylePanel } from './edgeless/components/panel/shape-style-panel.js';
import { EdgelessSizePanel } from './edgeless/components/panel/size-panel.js';
import { StrokeStylePanel } from './edgeless/components/panel/stroke-style-panel.js';
import {
@@ -27,14 +23,11 @@ import {
EDGELESS_SELECTED_RECT_WIDGET,
EdgelessSelectedRectWidget,
} from './edgeless/components/rects/edgeless-selected-rect.js';
import { EdgelessConnectorLabelEditor } from './edgeless/components/text/edgeless-connector-label-editor.js';
import { EdgelessGroupTitleEditor } from './edgeless/components/text/edgeless-group-title-editor.js';
import { EdgelessBrushMenu } from './edgeless/components/toolbar/brush/brush-menu.js';
import { EdgelessBrushToolButton } from './edgeless/components/toolbar/brush/brush-tool-button.js';
import { EdgelessSlideMenu } from './edgeless/components/toolbar/common/slide-menu.js';
import { ToolbarArrowUpIcon } from './edgeless/components/toolbar/common/toolbar-arrow-up-icon.js';
import { EdgelessConnectorMenu } from './edgeless/components/toolbar/connector/connector-menu.js';
import { EdgelessConnectorToolButton } from './edgeless/components/toolbar/connector/connector-tool-button.js';
import { EdgelessDefaultToolButton } from './edgeless/components/toolbar/default/default-tool-button.js';
import { EdgelessEraserToolButton } from './edgeless/components/toolbar/eraser/eraser-tool-button.js';
import { EdgelessLassoToolButton } from './edgeless/components/toolbar/lasso/lasso-tool-button.js';
@@ -114,6 +107,7 @@ function registerGfxEffects() {
gfxCanvasTextEffects();
gfxShapeEffects();
gfxNoteEffects();
gfxConnectorEffects();
}
function registerWidgets() {
@@ -137,10 +131,6 @@ function registerWidgets() {
function registerEdgelessToolbarComponents() {
// Tool buttons
customElements.define('edgeless-brush-tool-button', EdgelessBrushToolButton);
customElements.define(
'edgeless-connector-tool-button',
EdgelessConnectorToolButton
);
customElements.define(
'edgeless-default-tool-button',
EdgelessDefaultToolButton
@@ -159,7 +149,6 @@ function registerEdgelessToolbarComponents() {
// Menus
customElements.define('edgeless-brush-menu', EdgelessBrushMenu);
customElements.define('edgeless-connector-menu', EdgelessConnectorMenu);
customElements.define('edgeless-mindmap-menu', EdgelessMindmapMenu);
customElements.define('edgeless-slide-menu', EdgelessSlideMenu);
@@ -172,21 +161,13 @@ function registerEdgelessPanelComponents() {
'edgeless-font-weight-and-style-panel',
EdgelessFontWeightAndStylePanel
);
customElements.define('edgeless-note-shadow-panel', EdgelessNoteShadowPanel);
customElements.define('edgeless-size-panel', EdgelessSizePanel);
customElements.define('edgeless-scale-panel', EdgelessScalePanel);
customElements.define('edgeless-font-family-panel', EdgelessFontFamilyPanel);
customElements.define('edgeless-shape-panel', EdgelessShapePanel);
customElements.define('note-display-mode-panel', NoteDisplayModePanel);
customElements.define('stroke-style-panel', StrokeStylePanel);
customElements.define('edgeless-shape-style-panel', EdgelessShapeStylePanel);
}
function registerEdgelessEditorComponents() {
customElements.define(
'edgeless-connector-label-editor',
EdgelessConnectorLabelEditor
);
customElements.define(
'edgeless-group-title-editor',
EdgelessGroupTitleEditor
@@ -236,9 +217,6 @@ function registerMiscComponents() {
// Mindmap components
customElements.define('mindmap-import-placeholder', MindMapPlaceholder);
// Connector components
customElements.define('edgeless-connector-handle', EdgelessConnectorHandle);
}
declare global {
@@ -247,28 +225,20 @@ declare global {
'affine-edgeless-root-preview': EdgelessRootPreviewBlockComponent;
'edgeless-auto-complete-panel': EdgelessAutoCompletePanel;
'edgeless-auto-complete': EdgelessAutoComplete;
'edgeless-connector-handle': EdgelessConnectorHandle;
'note-slicer': NoteSlicer;
'edgeless-font-family-panel': EdgelessFontFamilyPanel;
'edgeless-font-weight-and-style-panel': EdgelessFontWeightAndStylePanel;
'note-display-mode-panel': NoteDisplayModePanel;
'edgeless-note-shadow-panel': EdgelessNoteShadowPanel;
'edgeless-scale-panel': EdgelessScalePanel;
'edgeless-shape-panel': EdgelessShapePanel;
'edgeless-shape-style-panel': EdgelessShapeStylePanel;
'edgeless-size-panel': EdgelessSizePanel;
'stroke-style-panel': StrokeStylePanel;
'edgeless-navigator-black-background': EdgelessNavigatorBlackBackgroundWidget;
'edgeless-dragging-area-rect': EdgelessDraggingAreaRectWidget;
'edgeless-selected-rect': EdgelessSelectedRectWidget;
'edgeless-connector-label-editor': EdgelessConnectorLabelEditor;
'edgeless-group-title-editor': EdgelessGroupTitleEditor;
'edgeless-brush-menu': EdgelessBrushMenu;
'edgeless-brush-tool-button': EdgelessBrushToolButton;
'edgeless-slide-menu': EdgelessSlideMenu;
'toolbar-arrow-up-icon': ToolbarArrowUpIcon;
'edgeless-connector-menu': EdgelessConnectorMenu;
'edgeless-connector-tool-button': EdgelessConnectorToolButton;
'edgeless-default-tool-button': EdgelessDefaultToolButton;
'edgeless-eraser-tool-button': EdgelessEraserToolButton;
'edgeless-lasso-tool-button': EdgelessLassoToolButton;