mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-12 15:46:29 +08:00
270 lines
7.1 KiB
TypeScript
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>`;
|
|
}
|
|
}
|