import { ShadowlessElement, WithDisposable } from '@blockeditor/lit';
import { css, html, nothing, type TemplateResult } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js';
import {
AutoConnectLeftIcon,
AutoConnectRightIcon,
HiddenIcon,
} from '../../../../_common/icons/edgeless.js';
import { SmallPageIcon } from '../../../../_common/icons/text.js';
import { requestConnectedFrame } from '../../../../_common/utils/event.js';
import type { NoteBlockModel } from '../../../../models.js';
import { Bound } from '../../../../surface-block/index.js';
import type { SurfaceBlockComponent } from '../../../../surface-block/surface-block.js';
import type { EdgelessPageBlockComponent } from '../../edgeless-page-block.js';
import { isNoteBlock } from '../../utils/query.js';
import type { AutoConnectElement } from '../block-portal/edgeless-block-portal.js';
const PAGE_VISIBLE_INDEX_LABEL_WIDTH = 44;
const PAGE_VISIBLE_INDEX_LABEL_HEIGHT = 24;
const EDGELESS_ONLY_INDEX_LABEL_WIDTH = 24;
const EDGELESS_ONLY_INDEX_LABEL_HEIGHT = 24;
const INDEX_LABEL_OFFSET = 16;
function calculatePosition(gap: number, count: number, iconWidth: number) {
const positions = [];
if (count === 1) {
positions.push([0, 10]);
return positions;
}
const middleIndex = (count - 1) / 2;
const isEven = count % 2 === 0;
const middleOffset = (gap + iconWidth) / 2;
function getSign(num: number) {
return num - middleIndex > 0 ? 1 : -1;
}
for (let j = 0; j < count; j++) {
let left = 10;
if (isEven) {
if (Math.abs(j - middleIndex) < 1 && isEven) {
left = 10 + middleOffset * getSign(j);
} else {
left =
10 +
((Math.ceil(Math.abs(j - middleIndex)) - 1) * (gap + 24) +
middleOffset) *
getSign(j);
}
} else {
const offset = gap + iconWidth;
left = 10 + Math.ceil(Math.abs(j - middleIndex)) * offset * getSign(j);
}
positions.push([0, left]);
}
return positions;
}
function getIndexLabelTooltip(icon: TemplateResult, content: string) {
const styles = css`
.index-label-tooltip {
display: flex;
align-items: center;
flex-wrap: nowrap;
gap: 10px;
}
.index-label-tooltip-icon {
display: flex;
align-items: center;
justify-content: center;
}
.index-label-tooltip-content {
font-size: var(--affine-font-sm);
display: flex;
height: 16px;
line-height: 16px;
}
`;
return html`
${icon}
${content}
`;
}
@customElement('edgeless-index-label')
export class EdgelessIndexLabel extends WithDisposable(ShadowlessElement) {
static override styles = css`
.edgeless-index-label {
font-size: 15px;
text-align: center;
height: 24px;
min-width: 24px;
padding: 0px 6px;
width: fit-content;
border-radius: 25px;
border: 1px solid #0000001a;
background: var(--affine-primary-color);
color: var(--affine-white);
cursor: pointer;
user-select: none;
}
.navigator {
width: 48px;
padding: 4px;
border-radius: 58px;
border: 1px solid rgba(227, 226, 228, 1);
transition: opacity 0.5s ease-in-out;
background: rgba(251, 251, 252, 1);
display: flex;
align-items: center;
justify-content: space-between;
opacity: 0;
}
.navigator div {
display: flex;
align-items: center;
cursor: pointer;
}
.navigator span {
display: inline-block;
height: 8px;
border: 1px solid rgba(227, 226, 228, 1);
}
.navigator div:hover {
background: var(--affine-hover-color);
}
.navigator.show {
opacity: 1;
}
`;
@property({ attribute: false })
surface!: SurfaceBlockComponent;
@property({ attribute: false })
edgeless!: EdgelessPageBlockComponent;
@property({ attribute: false })
show = false;
@property({ attribute: false })
pageVisibleElementsMap!: Map;
@property({ attribute: false })
edgelessOnlyNotesSet!: Set;
@state()
private _index = -1;
protected override firstUpdated(): void {
const { _disposables, edgeless } = this;
_disposables.add(
edgeless.service.viewport.viewportUpdated.on(() => {
this.requestUpdate();
})
);
const requestUpdate = (payload: { id: string }) => {
if (!this.isConnected) return;
const element = edgeless.service.getElementById(
payload.id
) as AutoConnectElement;
if (element && this.pageVisibleElementsMap.has(element)) {
this.requestUpdate();
}
if (isNoteBlock(element) && this.edgelessOnlyNotesSet.has(element)) {
this.requestUpdate();
}
};
_disposables.add(
edgeless.surfaceBlockModel.elementUpdated.on(requestUpdate)
);
_disposables.add(
edgeless.page.slots.blockUpdated.on(payload => {
if (payload.type === 'update') {
requestUpdate(payload);
}
})
);
_disposables.add(
edgeless.service.selection.slots.updated.on(() => {
const { elements } = edgeless.service.selection;
if (!(elements.length === 1 && isNoteBlock(elements[0]))) {
this._index = -1;
}
})
);
requestConnectedFrame(() => {
this._disposables.add(
edgeless.dispatcher.add('click', ctx => {
const event = ctx.get('pointerState');
const { raw } = event;
const target = raw.target as HTMLElement;
if (!target) return false;
if (target.closest('.edgeless-index-label')) {
const ele = target.closest('.edgeless-index-label') as Element;
const index = Number(ele.getAttribute('index'));
this._index = index === this._index ? -1 : index;
return true;
} else if (target.closest('.edgeless-auto-connect-next-button')) {
this._navigateToNext();
return true;
} else if (target.closest('.edgeless-auto-connect-previous-button')) {
this._navigateToPrev();
return true;
}
return false;
})
);
}, edgeless);
}
override connectedCallback(): void {
super.connectedCallback();
this.style.position = 'absolute';
this.style.top = '0';
this.style.left = '0';
this.style.zIndex = '1';
}
private _getElementsAndCounts() {
const elements: AutoConnectElement[] = [];
const counts: number[] = [];
for (const [key, value] of this.pageVisibleElementsMap.entries()) {
elements.push(key);
counts.push(value);
}
return { elements, counts };
}
private _navigateToNext() {
const { elements } = this._getElementsAndCounts();
if (this._index >= elements.length - 1) return;
this._index = this._index + 1;
const element = elements[this._index];
const bound = Bound.deserialize(element.xywh);
this.edgeless.service.selection.set({
elements: [element.id],
editing: false,
});
this.edgeless.service.viewport.setViewportByBound(
bound,
[80, 80, 80, 80],
true
);
}
private _navigateToPrev() {
const { elements } = this._getElementsAndCounts();
if (this._index <= 0) return;
this._index = this._index - 1;
const element = elements[this._index];
const bound = Bound.deserialize(element.xywh);
this.edgeless.service.selection.set({
elements: [element.id],
editing: false,
});
this.edgeless.service.viewport.setViewportByBound(
bound,
[80, 80, 80, 80],
true
);
}
private _NavigatorComponent(elements: AutoConnectElement[]) {
const { viewport } = this.surface.edgeless.service;
const { zoom } = viewport;
const classname = `navigator ${this._index >= 0 ? 'show' : 'hidden'}`;
const element = elements[this._index];
const bound = Bound.deserialize(element.xywh);
const [left, right] = viewport.toViewCoord(bound.x, bound.y);
const [width, height] = [bound.w * zoom, bound.h * zoom];
const navigatorStyle = styleMap({
position: 'absolute',
transform: `translate(${left + width / 2 - 26}px, ${
right + height + 16
}px)`,
});
return html`
${AutoConnectLeftIcon}
${AutoConnectRightIcon}
`;
}
private _PageVisibleIndexLabels(
elements: AutoConnectElement[],
counts: number[]
) {
const { viewport } = this.edgeless.service;
const { zoom } = viewport;
let index = 0;
return html`${repeat(
elements,
element => element.id,
(element, i) => {
const bound = Bound.deserialize(element.xywh);
const [left, right] = viewport.toViewCoord(bound.x, bound.y);
const [width, height] = [bound.w * zoom, bound.h * zoom];
const style = styleMap({
width: `${PAGE_VISIBLE_INDEX_LABEL_WIDTH}px`,
maxWidth: `${PAGE_VISIBLE_INDEX_LABEL_WIDTH}px`,
height: `${PAGE_VISIBLE_INDEX_LABEL_HEIGHT}px`,
position: 'absolute',
transform: `translate(${
left + width / 2 - PAGE_VISIBLE_INDEX_LABEL_WIDTH / 2
}px,
${right + height + INDEX_LABEL_OFFSET}px)`,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
});
const components = [];
const count = counts[i];
const initGap = 24 / count - 24;
const positions = calculatePosition(
initGap,
count,
PAGE_VISIBLE_INDEX_LABEL_HEIGHT
);
for (let j = 0; j < count; j++) {
index++;
components.push(html`
${index}
${getIndexLabelTooltip(SmallPageIcon, 'Page mode index')}
`);
}
function updateChildrenPosition(e: MouseEvent, positions: number[][]) {
if (!e.target) return;
const children = (e.target).children;
(Array.from(children)).forEach((c, index) => {
c.style.top = positions[index][0] + 'px';
c.style.left = positions[index][1] + 'px';
});
}
return html` {
const positions = calculatePosition(
5,
count,
PAGE_VISIBLE_INDEX_LABEL_HEIGHT
);
updateChildrenPosition(e, positions);
}}
@mouseleave=${(e: MouseEvent) => {
const positions = calculatePosition(
initGap,
count,
PAGE_VISIBLE_INDEX_LABEL_HEIGHT
);
updateChildrenPosition(e, positions);
}}
>
${components}
`;
}
)}`;
}
private _EdgelessOnlyLabels() {
const { edgelessOnlyNotesSet } = this;
if (!edgelessOnlyNotesSet.size) return nothing;
return html`${repeat(
edgelessOnlyNotesSet,
note => note.id,
note => {
const { viewport } = this.edgeless.service;
const { zoom } = viewport;
const bound = Bound.deserialize(note.xywh);
const [left, right] = viewport.toViewCoord(bound.x, bound.y);
const [width, height] = [bound.w * zoom, bound.h * zoom];
const style = styleMap({
width: `${EDGELESS_ONLY_INDEX_LABEL_WIDTH}px`,
height: `${EDGELESS_ONLY_INDEX_LABEL_HEIGHT}px`,
borderRadius: '50%',
backgroundColor: 'var(--affine-text-secondary-color)',
border: '1px solid var(--affine-border-color)',
color: 'var(--affine-white)',
position: 'absolute',
transform: `translate(${
left + width / 2 - EDGELESS_ONLY_INDEX_LABEL_WIDTH / 2
}px,
${right + height + INDEX_LABEL_OFFSET}px)`,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
});
return html`
${HiddenIcon}
${getIndexLabelTooltip(SmallPageIcon, 'Hidden on page')}
`;
}
)}`;
}
protected override render() {
if (!this.show) return nothing;
const { elements, counts } = this._getElementsAndCounts();
return html`${this._PageVisibleIndexLabels(elements, counts)}
${this._EdgelessOnlyLabels()}
${this._index >= 0 && this._index < elements.length
? this._NavigatorComponent(elements)
: nothing} `;
}
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-index-label': EdgelessIndexLabel;
}
}