Skip to content

Commit

Permalink
feat(blocks): impl scroll anchoring widget and highlight selection
Browse files Browse the repository at this point in the history
  • Loading branch information
fundon committed Sep 18, 2024
1 parent 59d4403 commit 694747b
Show file tree
Hide file tree
Showing 22 changed files with 497 additions and 56 deletions.
59 changes: 59 additions & 0 deletions packages/affine/shared/src/selection/hightlight.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import {
type ReferenceParams,
ReferenceParamsSchema,
} from '@blocksuite/affine-model';
import { BaseSelection } from '@blocksuite/block-std';

export class HighlightSelection extends BaseSelection {
static override group = 'scene';

static override type = 'highlight';

readonly blockIds: string[] = [];

readonly elementIds: string[] = [];

readonly mode: 'page' | 'edgeless' = 'page';

constructor({ mode, blockIds, elementIds }: ReferenceParams) {
super({ blockId: '[scene-highlight]' });

this.mode = mode ?? 'page';
this.blockIds = blockIds ?? [];
this.elementIds = elementIds ?? [];
}

static override fromJSON(json: Record<string, unknown>): HighlightSelection {
const result = ReferenceParamsSchema.parse(json);
return new HighlightSelection(result);
}

override equals(other: HighlightSelection): boolean {
return (
this.mode === other.mode &&
this.blockId === other.blockId &&
this.blockIds.length === other.blockIds.length &&
this.elementIds.length === other.elementIds.length &&
this.blockIds.every((id, n) => id === other.blockIds[n]) &&
this.elementIds.every((id, n) => id === other.elementIds[n])
);
}

override toJSON(): Record<string, unknown> {
return {
type: 'highlight',
mode: this.mode,
blockId: this.blockId,
blockIds: this.blockIds,
elementIds: this.elementIds,
};
}
}

declare global {
namespace BlockSuite {
interface Selection {
highlight: typeof HighlightSelection;
}
}
}
6 changes: 2 additions & 4 deletions packages/affine/shared/src/selection/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,8 @@ export class ImageSelection extends BaseSelection {
static override type = 'image';

static override fromJSON(json: Record<string, unknown>): ImageSelection {
ImageSelectionSchema.parse(json);
return new ImageSelection({
blockId: json.blockId as string,
});
const result = ImageSelectionSchema.parse(json);
return new ImageSelection(result);
}

override equals(other: BaseSelection): boolean {
Expand Down
1 change: 1 addition & 0 deletions packages/affine/shared/src/selection/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { HighlightSelection } from './hightlight.js';
export { ImageSelection } from './image.js';
51 changes: 51 additions & 0 deletions packages/affine/widget-scroll-anchoring/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
{
"name": "@blocksuite/affine-widget-scroll-anchoring",
"version": "0.17.10",
"description": "Affine scroll anchroing widget.",
"type": "module",
"repository": "toeverything/blocksuite",
"scripts": {
"build": "tsc",
"test:unit": "nx vite:test --run --passWithNoTests",
"test:unit:coverage": "nx vite:test --run --coverage",
"test:e2e": "playwright test"
},
"sideEffects": false,
"keywords": [],
"author": "toeverything",
"license": "MPL-2.0",
"dependencies": {
"@blocksuite/affine-model": "workspace:*",
"@blocksuite/affine-shared": "workspace:*",
"@blocksuite/block-std": "workspace:*",
"@blocksuite/global": "workspace:*",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.0.8",
"lit": "^3.2.0"
},
"exports": {
".": "./src/index.ts",
"./effects": "./src/effects.ts"
},
"publishConfig": {
"access": "public",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./effects": {
"import": "./dist/effects.js",
"types": "./dist/effects.d.ts"
}
}
},
"files": [
"src",
"dist",
"!src/__tests__",
"!dist/__tests__"
]
}
17 changes: 17 additions & 0 deletions packages/affine/widget-scroll-anchoring/src/effects.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import {
AFFINE_SCROLL_ANCHORING_WIDGET,
AffineScrollAnchoringWidget,
} from './scroll-anchoring.js';

export function effects() {
customElements.define(
AFFINE_SCROLL_ANCHORING_WIDGET,
AffineScrollAnchoringWidget
);
}

declare global {
interface HTMLElementTagNameMap {
[AFFINE_SCROLL_ANCHORING_WIDGET]: AffineScrollAnchoringWidget;
}
}
1 change: 1 addition & 0 deletions packages/affine/widget-scroll-anchoring/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './scroll-anchoring.js';
235 changes: 235 additions & 0 deletions packages/affine/widget-scroll-anchoring/src/scroll-anchoring.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
import type { DocMode } from '@blocksuite/affine-model';

import { HighlightSelection } from '@blocksuite/affine-shared/selection';
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';

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-radius: 8px;
border-color: ${unsafeCSS(
cssVarV2('layer/insideBorder/primaryBorder')
)};
box-shadow: var(--affine-active-shadow);
}
&.page {
border-radius: 5px;
background-color: var(--affine-hover-color);
}
}
}
`;

#requestUpdateFn = () => this.requestUpdate();

#resizeObserver: ResizeObserver = new ResizeObserver(this.#requestUpdateFn);

anchor = signal<{ mode: DocMode; id: string } | 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 { left, top, width, height } = blockComponent.getBoundingClientRect();
const container = this.offsetParent;
const containerRect = container?.getBoundingClientRect();

const offsetX = (containerRect?.left ?? 0) + (container?.scrollLeft ?? 0);
const offsetY = (containerRect?.top ?? 0) + (container?.scrollTop ?? 0);

return new Bound(left - offsetX, top - offsetY, width, height);
}

#moveToAnchorInEdgeless(id: string) {
const controller = this.std.getOptional(GfxControllerIdentifier);
if (!controller) return;

const model = controller.getElementById<GfxModel>(id);
if (!model) return;

const xywh = model.xywh;
if (!xywh) 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.setCenter(centerX, centerY);
viewport.setZoom(zoom);

this.anchorBounds.value = bounds;
}

#moveToAnchorInPage(id: string) {
const blockComponent = this.std.view.getBlock(id);
if (!blockComponent) {
return;
}

blockComponent.scrollIntoView({
behavior: 'instant',
block: 'center',
});

this.anchorBounds.value = Bound.fromDOMRect(
blockComponent.getBoundingClientRect()
);
}

override connectedCallback() {
super.connectedCallback();

this.std.selection.register(HighlightSelection);

this.#resizeObserver.observe(this.offsetParent!);
this.handleEvent('wheel', this.#requestUpdateFn);
this.disposables.addFromEvent(window, 'resize', this.#requestUpdateFn);

// Clears highlight
this.disposables.addFromEvent(this.host, 'pointerdown', () => {
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;

requestAnimationFrame(() => {
const { mode, id } = anchor;

if (mode === 'edgeless') {
this.#moveToAnchorInEdgeless(id);
return;
}

this.#moveToAnchorInPage(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 };
})
);
}

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 === 'edgeless'
? this.#getBoundsInEdgeless()
: this.#getBoundsInPage(id);
if (!bounds) return;

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>`;
}
}
Loading

0 comments on commit 694747b

Please sign in to comment.