import { assertExists } from '@blockeditor/global/utils'; import { WidgetElement } from '@blockeditor/lit'; import type { UserInfo } from '@blockeditor/store'; import { css, html, nothing } from 'lit'; import { customElement, state } from 'lit/decorators.js'; import { repeat } from 'lit/directives/repeat.js'; import { styleMap } from 'lit/directives/style-map.js'; import { RemoteCursor } from '../../../_common/icons/edgeless.js'; import type { Selectable } from '../../../_common/types.js'; import { batchToAnimationFrame } from '../../../_common/utils/event.js'; import { pickValues } from '../../../_common/utils/iterable.js'; import type { EdgelessPageBlockComponent } from '../../../page-block/edgeless/edgeless-page-block.js'; import { getSelectedRect, isTopLevelBlock, } from '../../../page-block/edgeless/utils/query.js'; import { RemoteColorManager } from '../../../page-block/remote-color-manager/remote-color-manager.js'; export const AFFINE_EDGELESS_REMOTE_SELECTION_WIDGET = 'affine-edgeless-remote-selection-widget'; @customElement(AFFINE_EDGELESS_REMOTE_SELECTION_WIDGET) export class EdgelessRemoteSelectionWidget extends WidgetElement { static enable = true; static override styles = css` :host { pointer-events: none; position: absolute; left: 0; top: 0; transform-origin: left top; contain: size layout; z-index: 1; } .remote-rect { position: absolute; top: 0; left: 0; border-radius: 4px; box-sizing: border-box; border-width: 3px; z-index: 1; transform-origin: center center; } .remote-cursor { position: absolute; top: 0; left: 0; border-radius: 50%; z-index: 1; } .remote-cursor > svg { display: block; } .remote-username { margin-left: 22px; margin-top: -2px; color: white; max-width: 160px; padding: 0px 3px; border: 1px solid var(--affine-pure-black-20); box-shadow: 0px 1px 6px 0px rgba(0, 0, 0, 0.16); border-radius: 4px; font-size: 12px; line-height: 18px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } `; get edgeless() { return this.blockElement; } @state() private _remoteRects: Map< number, { width: number; height: number; borderStyle: string; left: number; top: number; rotate: number; } > = new Map(); @state() private _remoteCursors: Map< number, { x: number; y: number; user?: UserInfo | undefined; } > = new Map(); get selection() { return this.edgeless.service.selection; } get surface() { return this.edgeless.surface; } private _remoteColorManager: RemoteColorManager | null = null; private _updateRemoteRects = () => { const { selection, blockElement } = this; const remoteSelection = selection.remoteSelection; const remoteRects: EdgelessRemoteSelectionWidget['_remoteRects'] = new Map(); remoteSelection.forEach((selection, clientId) => { if (selection.elements.length === 0) return; const elements = selection.elements .map(id => blockElement.service.getElementById(id)) .filter(element => element) as Selectable[]; const rect = getSelectedRect(elements); if (rect.width === 0 || rect.height === 0) return; const { left, top } = rect; const [width, height] = [rect.width, rect.height]; let rotate = 0; if (elements.length === 1) { const element = elements[0]; if (!isTopLevelBlock(element)) { rotate = element.rotate ?? 0; } } remoteRects.set(clientId, { width, height, borderStyle: 'solid', left, top, rotate, }); }); this._remoteRects = remoteRects; }; private _updateRemoteCursor = () => { const remoteCursors: EdgelessRemoteSelectionWidget['_remoteCursors'] = new Map(); const status = this.page.awarenessStore.getStates(); this.selection.remoteCursor.forEach((cursorSelection, clientId) => { remoteCursors.set(clientId, { x: cursorSelection.x, y: cursorSelection.y, user: status.get(clientId)?.user, }); }); this._remoteCursors = remoteCursors; }; private _updateOnElementChange = (element: string | { id: string }) => { const id = typeof element === 'string' ? element : element.id; if (this.isConnected && this.selection.hasRemote(id)) this._updateRemoteRects(); }; private _updateTransform = batchToAnimationFrame(() => { const { translateX, translateY } = this.edgeless.service.viewport; this.style.setProperty( 'transform', `translate(${translateX}px, ${translateY}px)` ); }, this); override connectedCallback() { super.connectedCallback(); if (!EdgelessRemoteSelectionWidget.enable) return; const { _disposables, page, edgeless } = this; pickValues(edgeless.service.surface, [ 'elementAdded', 'elementRemoved', 'elementUpdated', ]).forEach(slot => { _disposables.add(slot.on(this._updateOnElementChange)); }); _disposables.add(page.slots.blockUpdated.on(this._updateOnElementChange)); _disposables.add( this.selection.slots.remoteUpdated.on(this._updateRemoteRects) ); _disposables.add( this.selection.slots.remoteCursorUpdated.on(this._updateRemoteCursor) ); _disposables.add( edgeless.service.viewport.viewportUpdated.on(() => { this._updateTransform(); }) ); this._updateTransform(); this._updateRemoteRects(); this._remoteColorManager = new RemoteColorManager(this.host); } override render() { if (!EdgelessRemoteSelectionWidget.enable) return nothing; const { _remoteRects, _remoteCursors, _remoteColorManager } = this; assertExists(_remoteColorManager); const { zoom } = this.edgeless.service.viewport; const rects = repeat( _remoteRects.entries(), value => value[0], ([id, rect]) => html`
` ); const cursors = repeat( _remoteCursors.entries(), value => value[0], ([id, cursor]) => { return html`
${RemoteCursor}
${cursor.user?.name ?? 'Unknown'}
`; } ); return html`
${rects}${cursors}
`; } } declare global { interface HTMLElementTagNameMap { [AFFINE_EDGELESS_REMOTE_SELECTION_WIDGET]: EdgelessRemoteSelectionWidget; } }