Skip to content

Commit

Permalink
feat(blocks): minimap widget
Browse files Browse the repository at this point in the history
  • Loading branch information
fundon committed May 6, 2024
1 parent 8b9a263 commit 0ab4608
Show file tree
Hide file tree
Showing 5 changed files with 306 additions and 1 deletion.
2 changes: 2 additions & 0 deletions packages/blocks/src/_specs/_specs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { PageRootService } from '../root-block/page/page-root-service.js';
import { RootBlockSchema } from '../root-block/root-model.js';
import { AFFINE_DOC_REMOTE_SELECTION_WIDGET } from '../root-block/widgets/doc-remote-selection/doc-remote-selection.js';
import { AFFINE_DRAG_HANDLE_WIDGET } from '../root-block/widgets/drag-handle/drag-handle.js';
import { AFFINE_EDGELESS_MINIMAP_WIDGET } from '../root-block/widgets/edgeless-minimap/index.js';
import { AFFINE_EDGELESS_REMOTE_SELECTION_WIDGET } from '../root-block/widgets/edgeless-remote-selection/index.js';
import { AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET } from '../root-block/widgets/edgeless-zoom-toolbar/index.js';
import { EDGELESS_ELEMENT_TOOLBAR_WIDGET } from '../root-block/widgets/element-toolbar/index.js';
Expand Down Expand Up @@ -125,6 +126,7 @@ const EdgelessPageSpec: BlockSpec<EdgelessRootBlockWidgetName> = {
AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET
)}`,
[EDGELESS_ELEMENT_TOOLBAR_WIDGET]: literal`${unsafeStatic(EDGELESS_ELEMENT_TOOLBAR_WIDGET)}`,
[AFFINE_EDGELESS_MINIMAP_WIDGET]: literal`${unsafeStatic(AFFINE_EDGELESS_MINIMAP_WIDGET)}`,
},
},
};
Expand Down
4 changes: 3 additions & 1 deletion packages/blocks/src/root-block/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { EdgelessRootBlockComponent } from './edgeless/edgeless-root-block.
import type { PageRootBlockComponent } from './page/page-root-block.js';
import type { AFFINE_DOC_REMOTE_SELECTION_WIDGET } from './widgets/doc-remote-selection/doc-remote-selection.js';
import type { AFFINE_DRAG_HANDLE_WIDGET } from './widgets/drag-handle/drag-handle.js';
import type { AFFINE_EDGELESS_MINIMAP_WIDGET } from './widgets/edgeless-minimap/index.js';
import type { AFFINE_EDGELESS_REMOTE_SELECTION_WIDGET } from './widgets/edgeless-remote-selection/index.js';
import type { AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET } from './widgets/edgeless-zoom-toolbar/index.js';
import type { EDGELESS_ELEMENT_TOOLBAR_WIDGET } from './widgets/element-toolbar/index.js';
Expand Down Expand Up @@ -39,7 +40,8 @@ export type EdgelessRootBlockWidgetName =
| typeof AFFINE_DOC_REMOTE_SELECTION_WIDGET
| typeof AFFINE_EDGELESS_REMOTE_SELECTION_WIDGET
| typeof AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET
| typeof EDGELESS_ELEMENT_TOOLBAR_WIDGET;
| typeof EDGELESS_ELEMENT_TOOLBAR_WIDGET
| typeof AFFINE_EDGELESS_MINIMAP_WIDGET;

export type RootBlockComponent =
| PageRootBlockComponent
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
## Edgeless Minimap Widget
299 changes: 299 additions & 0 deletions packages/blocks/src/root-block/widgets/edgeless-minimap/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,299 @@
import { WidgetElement } from '@blocksuite/block-std';
import { css, html } from 'lit';
import { customElement, query, state } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';

import { on, once, stopPropagation } from '../../../_common/utils/event.js';
import { Bound } from '../../../surface-block/index.js';
import { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
import { edgelessElementsBound } from '../../edgeless/utils/bound-utils.js';

export const AFFINE_EDGELESS_MINIMAP_WIDGET = 'affine-edgeless-minimap-widget';

const DEFAULT_WIDTH = 160;
const DEFAULT_HEIGHT = 90;

@customElement(AFFINE_EDGELESS_MINIMAP_WIDGET)
export class AffineEdgelessMinimapWidget extends WidgetElement {
static override styles = css`
:host {
display: flex;
position: absolute;
left: 12px;
bottom: 66px;
z-index: 2;
padding: 8px;
border-radius: 8px;
border: 1px solid var(--affine-border-color);
background-color: var(--affine-background-overlay-panel-color);
box-shadow: var(--affine-shadow-2);
overflow: hidden;
user-select: none;
}
canvas[aria-label='minimap'] {
display: flex;
flex: 1;
width: 100%;
}
.slider {
position: absolute;
transform: translate3d(0px, 0px, 0px);
background: rgba(100, 100, 100, 0.2);
contain: strict;
&:hover {
background: rgba(100, 100, 100, 0.35);
}
&.active {
background: rgba(0, 0, 0, 0.3);
}
}
`;

get isEdgeless() {
return this.blockElement instanceof EdgelessRootBlockComponent;
}

@query('canvas')
canvas!: HTMLCanvasElement;

@query('.slider')
slider!: HTMLDivElement;

@state()
zoom: number = 1;

@state()
viewportBounds: Bound = new Bound();

bounds: Bound = new Bound();

scale: number = 1;

// Dragging slider
dragging: boolean = false;

private _draw(width: number, height: number) {
const edgeless = this.blockElement as EdgelessRootBlockComponent;
const bounds = edgelessElementsBound(edgeless.service.edgelessElements);

// @TODO(fundon): offset should be checked, prev bounds
this.bounds = bounds;
this.scale = Math.min(
width / (bounds.w || width),
height / (bounds.h || height)
);

const ctx = this.canvas.getContext('2d')!;
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
ctx.save();
ctx.fillStyle = 'rgba(127.5, 127.5, 127.5, 0.6)';

const dpr = window.devicePixelRatio;
const matrix = new DOMMatrix()
.scaleSelf(dpr)
.scaleSelf(this.scale)
.translateSelf(
-bounds.x + (width / this.scale - bounds.w) / 2,
-bounds.y + (height / this.scale - bounds.h) / 2
);

ctx.setTransform(matrix);

edgeless.service.edgelessElements.forEach(ele => {
const { x, y, w, h } = ele.elementBound;
const m = matrix.translate(x, y);

ctx.save();
ctx.setTransform(m);
// const cx = w / 2;
// const cy = h / 2;
// ctx.setTransform(m.translateSelf(cx, cy).translateSelf(-cx, -cy));

ctx.fillRect(0, 0, w, h);
ctx.fill();

ctx.restore();
});

ctx.setTransform(matrix.inverse());
}

override firstUpdated() {
const dpr = window.devicePixelRatio;
const width = DEFAULT_WIDTH;
const height = DEFAULT_HEIGHT;
this.canvas.style.width = `${DEFAULT_WIDTH}px`;
this.canvas.style.height = `${DEFAULT_HEIGHT}px`;
this.canvas.width = DEFAULT_WIDTH * dpr;
this.canvas.height = DEFAULT_HEIGHT * dpr;

if (this.isEdgeless) {
const edgeless = this.blockElement as EdgelessRootBlockComponent;

this.disposables.add(
edgeless.service.viewport.sizeUpdated.on(rect => {
this._draw(width, height);

this.viewportBounds.w = rect.width * this.scale;
this.viewportBounds.h = rect.height * this.scale;
this.viewportBounds.x = (width - this.viewportBounds.w) / 2;
this.viewportBounds.y = (height - this.viewportBounds.h) / 2;
this.requestUpdate();
})
);
this.disposables.add(
edgeless.service.viewport.viewportUpdated.on(({ zoom, center }) => {
if (this.dragging) return;

const cx = (width - this.viewportBounds.w) / 2;
const cy = (height - this.viewportBounds.h) / 2;
const dx = center[0] - this.bounds.w / 2 - this.bounds.x;
const dy = center[1] - this.bounds.h / 2 - this.bounds.y;

this.zoom = zoom;
this.viewportBounds.x = cx + dx * this.scale;
this.viewportBounds.y = cy + dy * this.scale;
this.requestUpdate();
})
);

/*
this.disposables.add(
edgeless.service.viewport.viewportMoved.on(delta => {
if (this.dragging) return;
this.viewportBounds.x += delta[0] * this.scale;
this.viewportBounds.y += delta[1] * this.scale;
this.requestUpdate();
})
);
*/

this.disposables.addFromEvent(this, 'pointerdown', stopPropagation);
// this.disposables.addFromEvent(this, 'wheel', stopPropagation);
this.disposables.addFromEvent(this.canvas, 'click', (e: MouseEvent) => {
e.stopPropagation();

const box = this.canvas.getBoundingClientRect();
const x = e.clientX - box.left - this.viewportBounds.w / 2;
const y = e.clientY - box.top - this.viewportBounds.h / 2;
const dx = x - this.viewportBounds.x;
const dy = y - this.viewportBounds.y;

this.viewportBounds.x = x;
this.viewportBounds.y = y;
this.requestUpdate();

edgeless.service.viewport.applyDeltaCenter(
dx / this.scale,
dy / this.scale
);
});
this.disposables.addFromEvent(
this.slider,
'pointerdown',
(e: PointerEvent) => {
e.stopPropagation();
e.preventDefault();

this.slider.classList.add('active');

const point = [e.clientX, e.clientY];
const stopDragging = on(
this.slider.ownerDocument,
'pointermove',
(e: PointerEvent) => {
e.stopPropagation();
this.dragging = true;

const { clientX, clientY } = e;
const dx = clientX - point[0];
const dy = clientY - point[1];

point[0] = clientX;
point[1] = clientY;

this.viewportBounds.x += dx;
this.viewportBounds.y += dy;
this.requestUpdate();

edgeless.service.viewport.applyDeltaCenter(
dx / this.scale,
dy / this.scale
);
}
);

once(
this.slider.ownerDocument,
'pointerup',
(e: PointerEvent) => {
e.stopPropagation();
stopDragging();
this.dragging = false;
this.slider.classList.remove('active');
},
false
);
}
);

this.disposables.add(
edgeless.surfaceBlockModel.elementAdded.on(() => {
this._draw(width, height);
})
);
this.disposables.add(
edgeless.surfaceBlockModel.elementRemoved.on(() => {
this._draw(width, height);
})
);
this.disposables.add(
edgeless.surfaceBlockModel.elementUpdated.on(() => {
this._draw(width, height);
})
);
this.disposables.add(
edgeless.doc.slots.blockUpdated.on(() => {
this._draw(width, height);
})
);
}
}

override connectedCallback() {
super.connectedCallback();
}

override disconnectedCallback() {
super.disconnectedCallback();
this.disposables.dispose();
}

override render() {
const { viewportBounds, zoom } = this;

return html`<canvas aria-label="minimap"></canvas>
<div
class="slider"
style=${styleMap({
width: `${viewportBounds.w}px`,
height: `${viewportBounds.h}px`,
transform: `translate3d(${viewportBounds.x}px,${viewportBounds.y}px, 0px) scale(${1 / zoom})`,
})}
></div>`;
}
}

declare global {
interface HTMLElementTagNameMap {
[AFFINE_EDGELESS_MINIMAP_WIDGET]: AffineEdgelessMinimapWidget;
}
}
1 change: 1 addition & 0 deletions packages/blocks/src/root-block/widgets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export {
EdgelessCopilotWidget,
} from './edgeless-copilot/index.js';
export { EdgelessCopilotToolbarEntry } from './edgeless-copilot-panel/toolbar-entry.js';
export { AffineEdgelessMinimapWidget } from './edgeless-minimap/index.js';
export { EdgelessRemoteSelectionWidget } from './edgeless-remote-selection/index.js';
export { AffineEdgelessZoomToolbarWidget } from './edgeless-zoom-toolbar/index.js';
export {
Expand Down

0 comments on commit 0ab4608

Please sign in to comment.