Files
AFFiNE-Mirror/blocksuite/affine/widget-scroll-anchoring/src/scroll-anchoring.ts
T
2024-12-20 16:48:10 +00:00

270 lines
7.1 KiB
TypeScript

import '@blocksuite/affine-shared/selection';
import type { DocMode } from '@blocksuite/affine-model';
import { WidgetComponent } from '@blocksuite/block-std';
import {
GfxControllerIdentifier,
type GfxModel,
} from '@blocksuite/block-std/gfx';
import { Bound, deserializeXYWH } from '@blocksuite/global/utils';
import { computed, signal } from '@preact/signals-core';
import { cssVarV2 } from '@toeverything/theme/v2';
import { css, html, nothing, unsafeCSS } from 'lit';
import { classMap } from 'lit/directives/class-map.js';
import { styleMap } from 'lit/directives/style-map.js';
type Anchor = {
id: string;
mode: DocMode;
};
export const AFFINE_SCROLL_ANCHORING_WIDGET = 'affine-scroll-anchoring-widget';
export class AffineScrollAnchoringWidget extends WidgetComponent {
static override styles = css`
:host {
pointer-events: none;
position: absolute;
left: 0px;
top: 0px;
transform-origin: left top;
contain: size layout;
z-index: 1;
& .highlight {
position: absolute;
box-sizing: border-box;
&.edgeless {
border-width: 1.39px;
border-style: solid;
border-color: ${unsafeCSS(
cssVarV2('layer/insideBorder/primaryBorder')
)};
box-shadow: var(--affine-active-shadow);
}
&.page {
border-radius: 5px;
background-color: var(--affine-hover-color);
}
}
}
`;
#listened = false;
readonly #requestUpdateFn = () => this.requestUpdate();
readonly #resizeObserver: ResizeObserver = new ResizeObserver(
this.#requestUpdateFn
);
anchor$ = signal<Anchor | null>(null);
anchorBounds$ = signal<Bound | null>(null);
highlighted$ = computed(() =>
this.service.selectionManager.find('highlight')
);
#getBoundsInEdgeless() {
const controller = this.std.getOptional(GfxControllerIdentifier);
if (!controller) return;
const bounds = this.anchorBounds$.peek();
if (!bounds) return;
const { x, y, w, h } = bounds;
const zoom = controller.viewport.zoom;
const [vx, vy] = controller.viewport.toViewCoord(x, y);
return new Bound(vx, vy, w * zoom, h * zoom);
}
#getBoundsInPage(id: string) {
const blockComponent = this.std.view.getBlock(id);
if (!blockComponent) return;
const container = this.host;
const containerRect = container.getBoundingClientRect();
const { left, top, width, height } = blockComponent.getBoundingClientRect();
const offsetX = containerRect.left - container.offsetLeft;
const offsetY = containerRect.top - container.offsetTop;
return new Bound(left - offsetX, top - offsetY, width, height);
}
#moveToAnchorInEdgeless(id: string) {
const controller = this.std.getOptional(GfxControllerIdentifier);
if (!controller) return;
const surface = controller.surface;
if (!surface) return;
const xywh = controller.getElementById<GfxModel>(id)?.xywh;
if (!xywh) {
if (!this.#listened) return;
// listen for document updates
this.disposables.add(
this.std.doc.slots.blockUpdated
.filter(v => v.type === 'add' && v.id === id)
.once(() => this.#moveToAnchorInEdgeless(id))
);
this.disposables.add(
surface.elementAdded
.filter(v => v.id === id && v.local === false)
.once(() => this.#moveToAnchorInEdgeless(id))
);
return;
}
let bounds = Bound.fromXYWH(deserializeXYWH(xywh));
const viewport = controller.viewport;
const blockComponent = this.std.view.getBlock(id);
const parentComponent = blockComponent?.parentComponent;
if (parentComponent && parentComponent.flavour === 'affine:note') {
const { left: x, width: w } = parentComponent.getBoundingClientRect();
const { top: y, height: h } = blockComponent.getBoundingClientRect();
const coord = viewport.toModelCoordFromClientCoord([x, y]);
bounds = new Bound(
coord[0],
coord[1],
w / viewport.zoom,
h / viewport.zoom
);
}
const { zoom, centerX, centerY } = viewport.getFitToScreenData(
bounds,
[20, 20, 100, 20]
);
viewport.setZoom(zoom);
viewport.setCenter(centerX, centerY);
this.#listened = false;
this.anchorBounds$.value = bounds;
}
#moveToAnchorInPage(id: string) {
const blockComponent = this.std.view.getBlock(id);
if (!blockComponent) {
if (!this.#listened) return;
// listen for document updates
this.disposables.add(
this.std.doc.slots.blockUpdated
.filter(v => v.type === 'add' && v.id === id)
.once(() => this.#moveToAnchorInPage(id))
);
return;
}
// use `requestAnimationFrame` to better scroll to the target
// because sometimes it is impossible to scroll to the target
requestAnimationFrame(() => {
blockComponent.scrollIntoView({
behavior: 'instant',
block: 'center',
});
});
this.#listened = false;
this.anchorBounds$.value = Bound.fromDOMRect(
blockComponent.getBoundingClientRect()
);
}
override connectedCallback() {
super.connectedCallback();
this.#resizeObserver.observe(this.host);
this.handleEvent('wheel', this.#requestUpdateFn);
this.disposables.addFromEvent(window, 'resize', this.#requestUpdateFn);
// Clears highlight
this.disposables.addFromEvent(this.host, 'pointerdown', () => {
this.#listened = false;
this.anchor$.value = null;
this.anchorBounds$.value = null;
});
// In edgeless
const controler = this.std.getOptional(GfxControllerIdentifier);
if (controler) {
this.disposables.add(
controler.viewport.viewportUpdated.on(this.#requestUpdateFn)
);
}
this.disposables.add(
this.anchor$.subscribe(anchor => {
if (!anchor) return;
const { mode, id } = anchor;
if (mode === 'page') {
this.#moveToAnchorInPage(id);
return;
}
this.#moveToAnchorInEdgeless(id);
})
);
this.disposables.add(
this.highlighted$.subscribe(highlighted => {
if (!highlighted) return;
const {
mode,
blockIds: [bid],
elementIds: [eid],
} = highlighted;
const id = mode === 'page' ? bid : eid || bid;
if (!id) return;
// Consumes highlight selection
this.std.selection.clear(['highlight']);
this.anchor$.value = { mode, id };
this.#listened = true;
})
);
}
override disconnectedCallback() {
super.disconnectedCallback();
this.#resizeObserver.disconnect();
}
override render() {
const anchor = this.anchor$.value;
if (!anchor) return nothing;
const { mode, id } = anchor;
const bounds =
mode === 'page' ? this.#getBoundsInPage(id) : this.#getBoundsInEdgeless();
if (!bounds) return nothing;
const classes = { highlight: true, [mode]: true };
const style = {
left: `${bounds.x}px`,
top: `${bounds.y}px`,
width: `${bounds.w}px`,
height: `${bounds.h}px`,
};
return html`<div
class=${classMap(classes)}
style=${styleMap(style)}
></div>`;
}
}