chore(editor): reorg packages (#10702)

This commit is contained in:
Saul-Mirone
2025-03-08 03:57:04 +00:00
parent 334912e85b
commit 8aedef0a36
961 changed files with 837 additions and 927 deletions
@@ -0,0 +1,50 @@
{
"name": "@blocksuite/affine-widget-drag-handle",
"description": "Drag handle for BlockSuite.",
"type": "module",
"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": "MIT",
"dependencies": {
"@blocksuite/affine-block-callout": "workspace:*",
"@blocksuite/affine-block-list": "workspace:*",
"@blocksuite/affine-block-note": "workspace:*",
"@blocksuite/affine-block-paragraph": "workspace:*",
"@blocksuite/affine-block-surface": "workspace:*",
"@blocksuite/affine-components": "workspace:*",
"@blocksuite/affine-model": "workspace:*",
"@blocksuite/affine-shared": "workspace:*",
"@blocksuite/block-std": "workspace:*",
"@blocksuite/global": "workspace:*",
"@blocksuite/icons": "^2.2.1",
"@blocksuite/inline": "workspace:*",
"@blocksuite/store": "workspace:*",
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.12",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.21",
"minimatch": "^10.0.1",
"zod": "^3.23.8"
},
"exports": {
".": "./src/index.ts",
"./effects": "./src/effects.ts"
},
"files": [
"src",
"dist",
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.20.0"
}
@@ -0,0 +1,19 @@
export const DRAG_HANDLE_CONTAINER_HEIGHT = 24;
export const DRAG_HANDLE_CONTAINER_WIDTH = 16;
export const DRAG_HANDLE_CONTAINER_WIDTH_TOP_LEVEL = 8;
export const DRAG_HANDLE_CONTAINER_OFFSET_LEFT = 2;
export const DRAG_HANDLE_CONTAINER_OFFSET_LEFT_LIST = 18;
export const DRAG_HANDLE_CONTAINER_OFFSET_LEFT_TOP_LEVEL = 5;
export const DRAG_HANDLE_CONTAINER_PADDING = 8;
export const DRAG_HANDLE_GRABBER_HEIGHT = 12;
export const DRAG_HANDLE_GRABBER_WIDTH = 4;
export const DRAG_HANDLE_GRABBER_WIDTH_HOVERED = 2;
export const DRAG_HANDLE_GRABBER_BORDER_RADIUS = 4;
export const DRAG_HANDLE_GRABBER_MARGIN = 4;
export const HOVER_AREA_RECT_PADDING_TOP_LEVEL = 6;
export const NOTE_CONTAINER_PADDING = 24;
export const EDGELESS_NOTE_EXTRA_PADDING = 20;
export const DRAG_HOVER_RECT_PADDING = 4;
@@ -0,0 +1 @@
export const AFFINE_DRAG_HANDLE_WIDGET = 'affine-drag-handle-widget';
@@ -0,0 +1,252 @@
import { EdgelessCRUDIdentifier } from '@blocksuite/affine-block-surface';
import type { RootBlockModel } from '@blocksuite/affine-model';
import { DocModeProvider } from '@blocksuite/affine-shared/services';
import {
isInsideEdgelessEditor,
isInsidePageEditor,
} from '@blocksuite/affine-shared/utils';
import { type BlockComponent, WidgetComponent } from '@blocksuite/block-std';
import type { GfxModel } from '@blocksuite/block-std/gfx';
import type { IVec, Point, Rect } from '@blocksuite/global/gfx';
import { DisposableGroup } from '@blocksuite/global/slot';
import { computed, type ReadonlySignal, signal } from '@preact/signals-core';
import { html, nothing } from 'lit';
import { query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { styleMap } from 'lit/directives/style-map.js';
import type { AFFINE_DRAG_HANDLE_WIDGET } from './consts.js';
import { RectHelper } from './helpers/rect-helper.js';
import { SelectionHelper } from './helpers/selection-helper.js';
import { styles } from './styles.js';
import { updateDragHandleClassName } from './utils.js';
import { DragEventWatcher } from './watchers/drag-event-watcher.js';
import { EdgelessWatcher } from './watchers/edgeless-watcher.js';
import { HandleEventWatcher } from './watchers/handle-event-watcher.js';
import { KeyboardEventWatcher } from './watchers/keyboard-event-watcher.js';
import { PageWatcher } from './watchers/page-watcher.js';
import { PointerEventWatcher } from './watchers/pointer-event-watcher.js';
export class AffineDragHandleWidget extends WidgetComponent<RootBlockModel> {
static override styles = styles;
private _anchorModelDisposables: DisposableGroup | null = null;
/**
* Used to handle drag behavior
*/
private readonly _dragEventWatcher = new DragEventWatcher(this);
private readonly _handleEventWatcher = new HandleEventWatcher(this);
private readonly _keyboardEventWatcher = new KeyboardEventWatcher(this);
private readonly _pageWatcher = new PageWatcher(this);
private readonly _reset = () => {
this.dragging = false;
this.dragHoverRect = null;
this.anchorBlockId.value = null;
this.isDragHandleHovered = false;
this.pointerEventWatcher.reset();
};
@state()
accessor activeDragHandle: 'block' | 'gfx' | null = null;
anchorBlockId = signal<string | null>(null);
anchorBlockComponent = computed<BlockComponent | null>(() => {
if (!this.anchorBlockId.value) return null;
return this.std.view.getBlock(this.anchorBlockId.value);
});
anchorEdgelessElement: ReadonlySignal<GfxModel | null> = computed(() => {
if (!this.anchorBlockId.value) return null;
if (this.mode === 'page') return null;
const crud = this.std.get(EdgelessCRUDIdentifier);
const edgelessElement = crud.getElementById(this.anchorBlockId.value);
return edgelessElement;
});
// Single block: drag handle should show on the vertical middle of the first line of element
center: IVec = [0, 0];
dragging = false;
rectHelper = new RectHelper(this);
draggingAreaRect: ReadonlySignal<Rect | null> = computed(
this.rectHelper.getDraggingAreaRect
);
lastDragPoint: Point | null = null;
edgelessWatcher = new EdgelessWatcher(this);
handleAnchorModelDisposables = () => {
const block = this.anchorBlockComponent.peek();
if (!block) return;
const blockModel = block.model;
if (this._anchorModelDisposables) {
this._anchorModelDisposables.dispose();
this._anchorModelDisposables = null;
}
this._anchorModelDisposables = new DisposableGroup();
this._anchorModelDisposables.add(
blockModel.propsUpdated.on(() => this.hide())
);
this._anchorModelDisposables.add(blockModel.deleted.on(() => this.hide()));
};
hide = (force = false) => {
if (this.dragging && !force) return;
updateDragHandleClassName();
this.isDragHandleHovered = false;
this.anchorBlockId.value = null;
this.activeDragHandle = null;
if (this.dragHandleContainer) {
this.dragHandleContainer.removeAttribute('style');
this.dragHandleContainer.style.display = 'none';
}
if (this.dragHandleGrabber) {
this.dragHandleGrabber.removeAttribute('style');
}
if (force) {
this._reset();
}
};
isDragHandleHovered = false;
get isBlockDragHandleVisible() {
return this.activeDragHandle === 'block';
}
get isGfxDragHandleVisible() {
return this.activeDragHandle === 'gfx';
}
noteScale = signal(1);
pointerEventWatcher = new PointerEventWatcher(this);
scale = signal(1);
scaleInNote = computed(() => this.scale.value * this.noteScale.value);
selectionHelper = new SelectionHelper(this);
get dragHandleContainerOffsetParent() {
return this.dragHandleContainer.parentElement!;
}
get mode() {
return this.std.get(DocModeProvider).getEditorMode();
}
get rootComponent() {
return this.block;
}
override connectedCallback() {
super.connectedCallback();
this.pointerEventWatcher.watch();
this._keyboardEventWatcher.watch();
this._dragEventWatcher.watch();
}
override disconnectedCallback() {
this.hide(true);
this._disposables.dispose();
this._anchorModelDisposables?.dispose();
super.disconnectedCallback();
}
override firstUpdated() {
this.hide(true);
this._disposables.addFromEvent(this.host, 'pointerleave', () => {
this.hide();
});
this._handleEventWatcher.watch();
if (isInsidePageEditor(this.host)) {
this._pageWatcher.watch();
} else if (isInsideEdgelessEditor(this.host)) {
this.edgelessWatcher.watch();
}
}
override render() {
const hoverRectStyle = styleMap(
this.dragHoverRect && this.activeDragHandle
? {
width: `${this.dragHoverRect.width}px`,
height: `${this.dragHoverRect.height}px`,
top: `${this.dragHoverRect.top}px`,
left: `${this.dragHoverRect.left}px`,
}
: {
display: 'none',
}
);
const isGfx = this.activeDragHandle === 'gfx';
const classes = {
'affine-drag-handle-grabber': true,
dots: isGfx ? true : false,
};
return html`
<div class="affine-drag-handle-widget">
<div class="affine-drag-handle-container">
<div class=${classMap(classes)}>
${isGfx
? html`
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
`
: nothing}
</div>
</div>
<div class="affine-drag-hover-rect" style=${hoverRectStyle}></div>
</div>
`;
}
@query('.affine-drag-handle-container')
accessor dragHandleContainer!: HTMLDivElement;
@query('.affine-drag-handle-grabber')
accessor dragHandleGrabber!: HTMLDivElement;
@state()
accessor dragHoverRect: {
width: number;
height: number;
left: number;
top: number;
} | null = null;
}
declare global {
interface HTMLElementTagNameMap {
[AFFINE_DRAG_HANDLE_WIDGET]: AffineDragHandleWidget;
}
}
@@ -0,0 +1,6 @@
import { AFFINE_DRAG_HANDLE_WIDGET } from './consts';
import { AffineDragHandleWidget } from './drag-handle';
export function effects() {
customElements.define(AFFINE_DRAG_HANDLE_WIDGET, AffineDragHandleWidget);
}
@@ -0,0 +1,227 @@
import { SurfaceBlockModel } from '@blocksuite/affine-block-surface';
import { RootBlockModel } from '@blocksuite/affine-model';
import {
DocModeExtension,
DocModeProvider,
EditorSettingExtension,
EditorSettingProvider,
} from '@blocksuite/affine-shared/services';
import { matchModels, SpecProvider } from '@blocksuite/affine-shared/utils';
import {
type BlockComponent,
BlockStdScope,
BlockViewIdentifier,
LifeCycleWatcher,
} from '@blocksuite/block-std';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import type {
BlockModel,
BlockViewType,
ExtensionType,
Query,
SliceSnapshot,
} from '@blocksuite/store';
import { signal } from '@preact/signals-core';
import { literal } from 'lit/static-html.js';
import type { AffineDragHandleWidget } from '../drag-handle.js';
import { getSnapshotRect } from '../utils.js';
export class PreviewHelper {
private readonly _calculateQuery = (
selectedIds: string[],
mode: 'block' | 'gfx'
): Query => {
const ids: Array<{ id: string; viewType: BlockViewType }> = selectedIds.map(
id => ({
id,
viewType: 'display',
})
);
// The ancestors of the selected blocks should be rendered as Bypass
selectedIds.forEach(block => {
let parent: string | null = block;
do {
if (!selectedIds.includes(parent)) {
ids.push({ viewType: 'bypass', id: parent });
}
parent = this.widget.doc.getParent(parent)?.id ?? null;
} while (parent && !ids.map(({ id }) => id).includes(parent));
});
// The children of the selected blocks should be rendered as Display
const addChildren = (id: string) => {
const model = this.widget.doc.getBlock(id)?.model;
if (!model) {
return;
}
const children = model.children ?? [];
if (
mode === 'gfx' &&
matchModels(model, [RootBlockModel, SurfaceBlockModel])
) {
children.forEach(child => {
if (selectedIds.includes(child.id)) {
ids.push({ viewType: 'display', id: child.id });
addChildren(child.id);
}
});
} else {
children.forEach(child => {
ids.push({ viewType: 'display', id: child.id });
addChildren(child.id);
});
}
};
selectedIds.forEach(addChildren);
return {
match: ids,
mode: 'strict',
};
};
getPreviewStd = (
blockIds: string[],
snapshot: SliceSnapshot,
mode: 'block' | 'gfx'
) => {
const widget = this.widget;
const std = widget.std;
const sourceGfx = std.get(GfxControllerIdentifier);
const isEdgeless = mode === 'gfx';
blockIds = blockIds.slice();
if (isEdgeless) {
blockIds.push(sourceGfx.surface!.id, std.store.root!.id);
}
const docModeService = std.get(DocModeProvider);
const editorSetting = std.get(EditorSettingProvider).peek();
const query = this._calculateQuery(blockIds as string[], mode);
const store = widget.doc.doc.getStore({ query });
const previewSpec = SpecProvider._.getSpec(
isEdgeless ? 'preview:edgeless' : 'preview:page'
);
const settingSignal = signal({ ...editorSetting });
const extensions = [
DocModeExtension(docModeService),
EditorSettingExtension(settingSignal),
{
setup(di) {
di.override(
BlockViewIdentifier('affine:database'),
() => literal`affine-dnd-preview-database`
);
},
} as ExtensionType,
{
setup(di) {
di.override(BlockViewIdentifier('affine:image'), () => {
return (model: BlockModel) => {
const parent = model.doc.getParent(model.id);
if (parent?.flavour === 'affine:surface') {
return literal`affine-edgeless-placeholder-preview-image`;
}
return literal`affine-placeholder-preview-image`;
};
});
},
} as ExtensionType,
];
if (isEdgeless) {
class PreviewViewportInitializer extends LifeCycleWatcher {
static override key = 'preview-viewport-initializer';
override mounted(): void {
const rect = getSnapshotRect(snapshot);
if (!rect) {
return;
}
this.std.view.viewUpdated.on(payload => {
if (payload.type !== 'block') return;
if (payload.view.model.flavour === 'affine:page') {
const gfx = this.std.get(GfxControllerIdentifier);
(
payload.view as BlockComponent & { overrideBackground: string }
).overrideBackground = 'transparent';
gfx.viewport.setViewportByBound(rect);
}
});
}
}
extensions.push(PreviewViewportInitializer);
}
previewSpec.extend(extensions);
settingSignal.value = {
...settingSignal.value,
edgelessDisableScheduleUpdate: true,
};
const previewStd = new BlockStdScope({
store,
extensions: previewSpec.value,
});
let width: number = 500;
let height;
let scale = 1;
if (isEdgeless) {
const rect = getSnapshotRect(snapshot);
if (rect) {
width = rect.w;
height = rect.h;
} else {
height = 500;
}
scale = sourceGfx.viewport.zoom;
} else {
const noteBlock = this.widget.host.querySelector('affine-note');
width = noteBlock?.offsetWidth ?? noteBlock?.clientWidth ?? 500;
}
return {
scale,
previewStd,
width,
height,
};
};
renderDragPreview = (options: {
blockIds: string[];
snapshot: SliceSnapshot;
container: HTMLElement;
mode: 'block' | 'gfx';
}): void => {
const { blockIds, snapshot, container, mode } = options;
const { previewStd, width, height, scale } = this.getPreviewStd(
blockIds,
snapshot,
mode
);
const previewTemplate = previewStd.render();
container.style.transform = `scale(${scale})`;
container.style.width = `${width}px`;
if (height) {
container.style.height = `${height}px`;
}
container.append(previewTemplate);
};
constructor(readonly widget: AffineDragHandleWidget) {}
}
@@ -0,0 +1,95 @@
import { getCurrentNativeRange } from '@blocksuite/affine-shared/utils';
import type { BlockComponent } from '@blocksuite/block-std';
import { Rect } from '@blocksuite/global/gfx';
import {
DRAG_HANDLE_CONTAINER_WIDTH,
DRAG_HOVER_RECT_PADDING,
} from '../config.js';
import type { AffineDragHandleWidget } from '../drag-handle.js';
import {
containBlock,
getDragHandleLeftPadding,
includeTextSelection,
} from '../utils.js';
export class RectHelper {
private readonly _getHoveredBlocks = (): BlockComponent[] => {
if (!this.widget.isBlockDragHandleVisible || !this.widget.anchorBlockId)
return [];
const hoverBlock = this.widget.anchorBlockComponent.peek();
if (!hoverBlock) return [];
const selections = this.widget.selectionHelper.selectedBlocks;
let blocks: BlockComponent[] = [];
// When current selection is TextSelection, should cover all the blocks in native range
if (selections.length > 0 && includeTextSelection(selections)) {
const range = getCurrentNativeRange();
if (!range) return [];
const rangeManager = this.widget.std.range;
if (!rangeManager) return [];
blocks = rangeManager.getSelectedBlockComponentsByRange(range, {
match: el => el.model.role === 'content',
mode: 'highest',
});
} else {
blocks = this.widget.selectionHelper.selectedBlockComponents;
}
if (
containBlock(
blocks.map(block => block.blockId),
this.widget.anchorBlockId.peek()!
)
) {
return blocks;
}
return [hoverBlock];
};
getDraggingAreaRect = (): Rect | null => {
const block = this.widget.anchorBlockComponent.value;
if (!block) return null;
// When hover block is in selected blocks, should show hover rect on the selected blocks
// Top: the top of the first selected block
// Left: the left of the first selected block
// Right: the largest right of the selected blocks
// Bottom: the bottom of the last selected block
let { left, top, right, bottom } = block.getBoundingClientRect();
const blocks = this._getHoveredBlocks();
blocks.forEach(block => {
left = Math.min(left, block.getBoundingClientRect().left);
top = Math.min(top, block.getBoundingClientRect().top);
right = Math.max(right, block.getBoundingClientRect().right);
bottom = Math.max(bottom, block.getBoundingClientRect().bottom);
});
const offsetLeft = getDragHandleLeftPadding(blocks);
const offsetParentRect =
this.widget.dragHandleContainerOffsetParent.getBoundingClientRect();
if (!offsetParentRect) return null;
left -= offsetParentRect.left;
right -= offsetParentRect.left;
top -= offsetParentRect.top;
bottom -= offsetParentRect.top;
const scaleInNote = this.widget.scaleInNote.value;
// Add padding to hover rect
left -= (DRAG_HANDLE_CONTAINER_WIDTH + offsetLeft) * scaleInNote;
top -= DRAG_HOVER_RECT_PADDING * scaleInNote;
right += DRAG_HOVER_RECT_PADDING * scaleInNote;
bottom += DRAG_HOVER_RECT_PADDING * scaleInNote;
return new Rect(left, top, right, bottom);
};
constructor(readonly widget: AffineDragHandleWidget) {}
}
@@ -0,0 +1,67 @@
import { findNoteBlockModel } from '@blocksuite/affine-shared/utils';
import {
type BlockComponent,
BlockSelection,
SurfaceSelection,
TextSelection,
} from '@blocksuite/block-std';
import type { AffineDragHandleWidget } from '../drag-handle.js';
export class SelectionHelper {
/** Check if given block component is selected */
isBlockSelected = (block?: BlockComponent) => {
if (!block) return false;
return this.selectedBlocks.some(
selection => selection.blockId === block.model.id
);
};
setSelectedBlocks = (blocks: BlockComponent[], noteId?: string) => {
const { selection } = this;
const selections = blocks.map(block =>
selection.create(BlockSelection, {
blockId: block.blockId,
})
);
// When current page is edgeless page
// We need to remain surface selection and set editing as true
if (this.widget.mode === 'edgeless') {
const surfaceElementId = noteId
? noteId
: findNoteBlockModel(blocks[0].model)?.id;
if (!surfaceElementId) return;
const surfaceSelection = selection.create(
SurfaceSelection,
blocks[0]!.blockId,
[surfaceElementId],
true
);
selections.push(surfaceSelection);
}
selection.set(selections);
};
get selectedBlockComponents() {
return this.selectedBlocks
.map(block => this.widget.std.view.getBlock(block.blockId))
.filter((block): block is BlockComponent => !!block);
}
get selectedBlocks() {
const selection = this.selection;
return selection.find(TextSelection)
? selection.filter(TextSelection)
: selection.filter(BlockSelection);
}
get selection() {
return this.widget.std.selection;
}
constructor(readonly widget: AffineDragHandleWidget) {}
}
@@ -0,0 +1,15 @@
import { WidgetViewExtension } from '@blocksuite/block-std';
import { literal, unsafeStatic } from 'lit/static-html.js';
import { AFFINE_DRAG_HANDLE_WIDGET } from './consts';
export * from './consts';
export * from './drag-handle';
export * from './utils';
export type { DragBlockPayload } from './watchers/drag-event-watcher';
export const dragHandleWidget = WidgetViewExtension(
'affine:page',
AFFINE_DRAG_HANDLE_WIDGET,
literal`${unsafeStatic(AFFINE_DRAG_HANDLE_WIDGET)}`
);
@@ -0,0 +1,114 @@
import type { SurfaceBlockModel } from '@blocksuite/affine-block-surface';
import type { ConnectorElementModel } from '@blocksuite/affine-model';
import type { BlockStdScope } from '@blocksuite/block-std';
import {
GfxController,
type GfxModel,
isGfxGroupCompatibleModel,
} from '@blocksuite/block-std/gfx';
import type { IVec, SerializedXYWH } from '@blocksuite/global/gfx';
import { assertType } from '@blocksuite/global/utils';
import type { TransformerMiddleware } from '@blocksuite/store';
/**
* Used to filter out gfx elements that are not selected
* @param ids
* @param std
* @returns
*/
export const gfxBlocksFilter = (
ids: string[],
std: BlockStdScope
): TransformerMiddleware => {
const selectedIds = new Set<string>();
const store = std.store;
const surface = store.getBlocksByFlavour('affine:surface')[0]
.model as SurfaceBlockModel;
const idsToCheck = ids.slice();
const gfx = std.get(GfxController);
for (const id of idsToCheck) {
const blockOrElem = store.getBlock(id)?.model ?? surface.getElementById(id);
if (!blockOrElem) continue;
if (isGfxGroupCompatibleModel(blockOrElem)) {
idsToCheck.push(...blockOrElem.childIds);
}
selectedIds.add(id);
}
return ({ slots, transformerConfigs }) => {
slots.beforeExport.on(payload => {
if (payload.type !== 'block') {
return;
}
if (payload.model.flavour === 'affine:surface') {
transformerConfigs.set('selectedElements', selectedIds);
payload.model.children = payload.model.children.filter(model =>
selectedIds.has(model.id)
);
return;
}
});
slots.afterExport.on(payload => {
if (payload.type !== 'block') {
return;
}
if (payload.model.flavour === 'affine:surface') {
const { snapshot } = payload;
const elementsMap = snapshot.props.elements as Record<
string,
{ type: string }
>;
Object.entries(elementsMap).forEach(([elementId, val]) => {
if (val.type === 'connector') {
assertType<{
type: 'connector';
source: { position: IVec; id?: string };
target: { position: IVec; id?: string };
xywh: SerializedXYWH;
}>(val);
const connectorElem = gfx.getElementById(
elementId
) as ConnectorElementModel;
if (!connectorElem) {
delete elementsMap[elementId];
return;
}
// should be deleted during the import process
val.xywh = connectorElem.xywh;
['source', 'target'].forEach(key => {
const endpoint = val[key as 'source' | 'target'];
if (endpoint.id && !selectedIds.has(endpoint.id)) {
const endElem = gfx.getElementById(endpoint.id);
if (!endElem) {
delete elementsMap[elementId];
return;
}
const endElemBound = (endElem as GfxModel).elementBound;
val[key as 'source' | 'target'] = {
position: endElemBound.getRelativePoint(
endpoint.position ?? [0.5, 0.5]
),
};
}
});
}
});
}
});
};
};
@@ -0,0 +1,44 @@
import { DatabaseBlockModel } from '@blocksuite/affine-model';
import { matchModels } from '@blocksuite/affine-shared/utils';
import type { BlockStdScope } from '@blocksuite/block-std';
import type { TransformerMiddleware } from '@blocksuite/store';
export const newIdCrossDoc =
(std: BlockStdScope): TransformerMiddleware =>
({ slots }) => {
let samePage = false;
const oldToNewIdMap = new Map<string, string>();
slots.beforeImport.on(payload => {
if (payload.type === 'slice') {
samePage = payload.snapshot.pageId === std.store.id;
}
if (payload.type === 'block' && !samePage) {
const newId = std.workspace.idGenerator();
oldToNewIdMap.set(payload.snapshot.id, newId);
payload.snapshot.id = newId;
}
});
slots.afterImport.on(payload => {
if (
!samePage &&
payload.type === 'block' &&
matchModels(payload.model, [DatabaseBlockModel])
) {
const originalCells = payload.model.cells;
const newCells = {
...originalCells,
};
Object.keys(originalCells).forEach(cellId => {
if (oldToNewIdMap.has(cellId)) {
newCells[oldToNewIdMap.get(cellId)!] = originalCells[cellId];
}
});
payload.model.cells$.value = newCells;
}
});
};
@@ -0,0 +1,22 @@
import { correctNumberedListsOrderToPrev } from '@blocksuite/affine-block-list';
import { ListBlockModel } from '@blocksuite/affine-model';
import { matchModels } from '@blocksuite/affine-shared/utils';
import type { BlockStdScope } from '@blocksuite/block-std';
import type { TransformerMiddleware } from '@blocksuite/store';
export const reorderList =
(std: BlockStdScope): TransformerMiddleware =>
({ slots }) => {
slots.afterImport.on(payload => {
if (payload.type === 'block') {
const model = payload.model;
if (matchModels(model, [ListBlockModel]) && model.type === 'numbered') {
const next = std.store.getNext(model);
correctNumberedListsOrderToPrev(std.store, model);
if (next) {
correctNumberedListsOrderToPrev(std.store, next);
}
}
}
});
};
@@ -0,0 +1,86 @@
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { css } from 'lit';
import { DRAG_HANDLE_CONTAINER_WIDTH } from './config.js';
export const styles = css`
.affine-drag-handle-widget {
display: flex;
position: absolute;
left: 0;
top: 0;
contain: size layout;
}
.affine-drag-handle-container {
top: 0;
left: 0;
position: absolute;
display: flex;
justify-content: center;
width: ${DRAG_HANDLE_CONTAINER_WIDTH}px;
min-height: 12px;
pointer-events: auto;
user-select: none;
box-sizing: border-box;
}
.affine-drag-handle-container:hover {
cursor: grab;
}
.affine-drag-handle-grabber {
width: 4px;
height: 100%;
border-radius: 1px;
background: var(--affine-placeholder-color);
transition: width 0.25s ease;
}
.affine-drag-handle-grabber.dots {
width: 14px;
height: 26px;
box-sizing: border-box;
padding: 5px 2px;
border-radius: 4px;
gap: 2px;
display: flex;
flex-wrap: wrap;
background-color: transparent;
transform: translateX(-100%);
transition: unset;
}
.affine-drag-handle-grabber.dots:hover {
background-color: ${unsafeCSSVarV2('layer/background/hoverOverlay')};
}
.affine-drag-handle-grabber.dots > .dot {
width: 4px;
height: 4px;
border-radius: 50%;
flex: 0 0 4px;
background-color: ${unsafeCSSVarV2('icon/secondary')};
}
@media print {
.affine-drag-handle-widget {
display: none;
}
}
.affine-drag-hover-rect {
position: absolute;
top: 0;
left: 0;
border-radius: 6px;
background: var(--affine-hover-color);
pointer-events: none;
z-index: 2;
animation: expand 0.25s forwards;
}
@keyframes expand {
0% {
width: 0;
height: 0;
}
}
`;
@@ -0,0 +1,338 @@
import { type CalloutBlockComponent } from '@blocksuite/affine-block-callout';
import {
AFFINE_EDGELESS_NOTE,
type EdgelessNoteBlockComponent,
} from '@blocksuite/affine-block-note';
import { ParagraphBlockComponent } from '@blocksuite/affine-block-paragraph';
import {
DatabaseBlockModel,
ListBlockModel,
ParagraphBlockModel,
} from '@blocksuite/affine-model';
import { DocModeProvider } from '@blocksuite/affine-shared/services';
import {
calcDropTarget,
type DropTarget,
findClosestBlockComponent,
getBlockProps,
getClosestBlockComponentByPoint,
matchModels,
} from '@blocksuite/affine-shared/utils';
import type { BlockComponent, EditorHost } from '@blocksuite/block-std';
import {
Bound,
Point,
Rect,
type SerializedXYWH,
} from '@blocksuite/global/gfx';
import type {
BaseSelection,
BlockModel,
BlockSnapshot,
SliceSnapshot,
} from '@blocksuite/store';
import {
DRAG_HANDLE_CONTAINER_HEIGHT,
DRAG_HANDLE_CONTAINER_OFFSET_LEFT,
DRAG_HANDLE_CONTAINER_OFFSET_LEFT_LIST,
EDGELESS_NOTE_EXTRA_PADDING,
NOTE_CONTAINER_PADDING,
} from './config.js';
const heightMap: Record<string, number> = {
text: 23,
h1: 40,
h2: 36,
h3: 32,
h4: 32,
h5: 28,
h6: 26,
quote: 46,
list: 24,
database: 28,
image: 28,
divider: 36,
};
export const getDragHandleContainerHeight = (model: BlockModel) => {
const flavour = model.flavour;
const index = flavour.indexOf(':');
let key = flavour.slice(index + 1);
if (key === 'paragraph' && (model as ParagraphBlockModel).type) {
key = (model as ParagraphBlockModel).type;
}
const height = heightMap[key] ?? DRAG_HANDLE_CONTAINER_HEIGHT;
return height;
};
// To check if the block is a child block of the selected blocks
export const containChildBlock = (
blocks: BlockComponent[],
childModel: BlockModel
) => {
return blocks.some(block => {
let currentBlock: BlockModel | null = childModel;
while (currentBlock) {
if (currentBlock.id === block.model.id) {
return true;
}
currentBlock = block.doc.getParent(currentBlock.id);
}
return false;
});
};
export const containBlock = (blockIDs: string[], targetID: string) => {
return blockIDs.some(blockID => blockID === targetID);
};
export const extractIdsFromSnapshot = (snapshot: SliceSnapshot) => {
const ids: string[] = [];
const extractFromBlock = (block: BlockSnapshot) => {
ids.push(block.id);
if (block.children) {
for (const child of block.children) {
extractFromBlock(child);
}
}
};
for (const block of snapshot.content) {
extractFromBlock(block);
}
return ids;
};
// TODO: this is a hack, need to find a better way
export const insideDatabaseTable = (element: Element) => {
return !!element.closest('.affine-database-block-table');
};
export const includeTextSelection = (selections: BaseSelection[]) => {
return selections.some(selection => selection.type === 'text');
};
/**
* Check if the path of two blocks are equal
*/
export const isBlockIdEqual = (
id1: string | null | undefined,
id2: string | null | undefined
) => {
if (!id1 || !id2) {
return false;
}
return id1 === id2;
};
export const isOutOfNoteBlock = (
editorHost: EditorHost,
noteBlock: Element,
point: Point,
scale: number
) => {
// TODO: need to find a better way to check if the point is out of note block
const rect = noteBlock.getBoundingClientRect();
const insidePageEditor =
editorHost.std.get(DocModeProvider).getEditorMode() === 'page';
const padding =
(NOTE_CONTAINER_PADDING +
(insidePageEditor ? 0 : EDGELESS_NOTE_EXTRA_PADDING)) *
scale;
return rect
? insidePageEditor
? point.y < rect.top ||
point.y > rect.bottom ||
point.x > rect.right + padding
: point.y < rect.top ||
point.y > rect.bottom ||
point.x < rect.left - padding ||
point.x > rect.right + padding
: true;
};
export const getParentNoteBlock = (blockComponent: BlockComponent) => {
return blockComponent.closest('affine-note, affine-edgeless-note') ?? null;
};
export const getClosestNoteBlock = (
editorHost: EditorHost,
rootComponent: BlockComponent,
point: Point
) => {
const isInsidePageEditor =
editorHost.std.get(DocModeProvider).getEditorMode() === 'page';
return isInsidePageEditor
? findClosestBlockComponent(rootComponent, point, 'affine-note')
: getHoveringNote(point);
};
export const getClosestBlockByPoint = (
editorHost: EditorHost,
rootComponent: BlockComponent,
point: Point
) => {
const closestNoteBlock = getClosestNoteBlock(
editorHost,
rootComponent,
point
);
if (!closestNoteBlock || closestNoteBlock.closest('.affine-surface-ref')) {
return null;
}
const noteRect = Rect.fromDOM(closestNoteBlock);
const block = getClosestBlockComponentByPoint(point, {
container: closestNoteBlock,
rect: noteRect,
}) as BlockComponent | null;
const blockSelector =
'.affine-note-block-container > .affine-block-children-container > [data-block-id]';
const closestBlock = (
block && containChildBlock([closestNoteBlock], block.model)
? block
: findClosestBlockComponent(
closestNoteBlock as BlockComponent,
point.clone(),
blockSelector
)
) as BlockComponent;
if (!closestBlock || !!closestBlock.closest('.surface-ref-note-portal')) {
return null;
}
if (matchModels(closestBlock.model, [ParagraphBlockModel])) {
const callout =
closestBlock.closest<CalloutBlockComponent>('affine-callout');
if (callout) {
return callout;
}
}
return closestBlock;
};
export const getDropResult = (
event: MouseEvent,
scale: number = 1
): DropTarget | null => {
let dropIndicator = null;
const point = new Point(event.x, event.y);
const closestBlock = getClosestBlockComponentByPoint(point) as BlockComponent;
if (!closestBlock) {
return dropIndicator;
}
const model = closestBlock.model;
const isDatabase = matchModels(model, [DatabaseBlockModel]);
if (isDatabase) {
return dropIndicator;
}
const result = calcDropTarget(point, model, closestBlock, [], scale);
if (result) {
dropIndicator = result;
}
return dropIndicator;
};
export function getDragHandleLeftPadding(blocks: BlockComponent[]) {
const hasToggleList = blocks.some(
block =>
(matchModels(block.model, [ListBlockModel]) &&
block.model.children.length > 0) ||
(block instanceof ParagraphBlockComponent &&
block.model.type.startsWith('h') &&
block.collapsedSiblings.length > 0)
);
const offsetLeft = hasToggleList
? DRAG_HANDLE_CONTAINER_OFFSET_LEFT_LIST
: DRAG_HANDLE_CONTAINER_OFFSET_LEFT;
return offsetLeft;
}
let previousEle: BlockComponent[] = [];
export function updateDragHandleClassName(blocks: BlockComponent[] = []) {
const className = 'with-drag-handle';
previousEle.forEach(block => block.classList.remove(className));
previousEle = blocks;
blocks.forEach(block => block.classList.add(className));
}
export function getDuplicateBlocks(blocks: BlockModel[]) {
const duplicateBlocks = blocks.map(block => ({
flavour: block.flavour,
blockProps: getBlockProps(block),
}));
return duplicateBlocks;
}
/**
* Get hovering note with given a point in edgeless mode.
*/
function getHoveringNote(point: Point) {
return (
document
.elementsFromPoint(point.x, point.y)
.find(
(e): e is EdgelessNoteBlockComponent =>
e.tagName.toLowerCase() === AFFINE_EDGELESS_NOTE
) || null
);
}
export function getSnapshotRect(snapshot: SliceSnapshot): Bound | null {
let bound: Bound | null = null;
const getBound = (block: BlockSnapshot) => {
if (block.flavour === 'affine:surface') {
if (block.props.elements) {
Object.values(
block.props.elements as Record<
string,
{ type: string; xywh: SerializedXYWH }
>
).forEach(elem => {
if (elem.xywh) {
bound = bound
? bound.unite(Bound.deserialize(elem.xywh))
: Bound.deserialize(elem.xywh);
}
if (elem.type === 'connector') {
let connectorBound: Bound | undefined;
if (elem.xywh) {
connectorBound = Bound.deserialize(elem.xywh);
}
if (connectorBound) {
bound = bound ? bound.unite(connectorBound) : connectorBound;
}
}
});
}
block.children.forEach(getBound);
} else if (block.props.xywh) {
bound = bound
? bound.unite(Bound.deserialize(block.props.xywh as SerializedXYWH))
: Bound.deserialize(block.props.xywh as SerializedXYWH);
}
};
snapshot.content.forEach(getBound);
return bound;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,225 @@
import {
EdgelessLegacySlotIdentifier,
type SurfaceBlockComponent,
} from '@blocksuite/affine-block-surface';
import { getSelectedRect } from '@blocksuite/affine-shared/utils';
import {
GfxControllerIdentifier,
type GfxToolsFullOptionValue,
} from '@blocksuite/block-std/gfx';
import { type IVec, Rect } from '@blocksuite/global/gfx';
import { effect } from '@preact/signals-core';
import {
DRAG_HANDLE_CONTAINER_OFFSET_LEFT_TOP_LEVEL,
DRAG_HANDLE_CONTAINER_WIDTH_TOP_LEVEL,
HOVER_AREA_RECT_PADDING_TOP_LEVEL,
} from '../config.js';
import type { AffineDragHandleWidget } from '../drag-handle.js';
/**
* Used to control the drag handle visibility in edgeless mode
*
* 1. Show drag handle on every block and gfx element
* 2. Multiple selection is not supported
*/
export class EdgelessWatcher {
private readonly _handleEdgelessToolUpdated = (
newTool: GfxToolsFullOptionValue
) => {
// @ts-expect-error GfxToolsFullOptionValue is extended in other packages
if (newTool.type === 'default') {
this.updateAnchorElement();
} else {
this.widget.hide();
}
};
private readonly _handleEdgelessViewPortUpdated = ({
zoom,
center,
}: {
zoom: number;
center: IVec;
}) => {
if (this.widget.scale.peek() !== zoom) {
this.widget.scale.value = zoom;
}
if (
this.widget.center[0] !== center[0] &&
this.widget.center[1] !== center[1]
) {
this.widget.center = [...center];
}
if (this.widget.isGfxDragHandleVisible) {
this._showDragHandle().catch(console.error);
this._updateDragHoverRectTopLevelBlock();
} else if (this.widget.activeDragHandle) {
this.widget.hide();
}
};
private readonly _showDragHandle = async () => {
const surfaceModel = this.widget.doc.getBlockByFlavour('affine:surface');
const surface = this.widget.std.view.getBlock(
surfaceModel[0]!.id
) as SurfaceBlockComponent;
await surface.updateComplete;
if (!this.widget.anchorBlockId) return;
const container = this.widget.dragHandleContainer;
const grabber = this.widget.dragHandleGrabber;
if (!container || !grabber) return;
const area = this.hoveredElemArea;
if (!area) return;
container.style.transition = 'none';
container.style.paddingTop = `0px`;
container.style.paddingBottom = `0px`;
container.style.left = `${area.left}px`;
container.style.top = `${area.top}px`;
container.style.display = 'flex';
this.widget.handleAnchorModelDisposables();
this.widget.activeDragHandle = 'gfx';
};
private readonly _updateDragHoverRectTopLevelBlock = () => {
if (!this.widget.dragHoverRect) return;
this.widget.dragHoverRect = this.hoveredElemAreaRect;
};
get gfx() {
return this.widget.std.get(GfxControllerIdentifier);
}
updateAnchorElement = () => {
if (!this.widget.isConnected) return;
if (this.widget.doc.readonly || this.widget.mode === 'page') {
this.widget.hide();
return;
}
const { selection } = this.gfx;
const editing = selection.editing;
const selectedElements = selection.selectedElements;
if (editing || selectedElements.length !== 1 || this.widget.doc.readonly) {
this.widget.hide();
return;
}
const selectedElement = selectedElements[0];
this.widget.anchorBlockId.value = selectedElement.id;
this._showDragHandle().catch(console.error);
};
get hoveredElemAreaRect() {
const area = this.hoveredElemArea;
if (!area) return null;
return new Rect(area.left, area.top, area.right, area.bottom);
}
get hoveredElemArea() {
const edgelessElement = this.widget.anchorEdgelessElement.peek();
if (!edgelessElement) return null;
const { viewport } = this.gfx;
const rect = getSelectedRect([edgelessElement]);
let [left, top] = viewport.toViewCoord(rect.left, rect.top);
const scale = this.widget.scale.peek();
const width = rect.width * scale;
const height = rect.height * scale;
let [right, bottom] = [left + width, top + height];
const padding = HOVER_AREA_RECT_PADDING_TOP_LEVEL * scale;
const containerWidth = DRAG_HANDLE_CONTAINER_WIDTH_TOP_LEVEL * scale;
const offsetLeft = DRAG_HANDLE_CONTAINER_OFFSET_LEFT_TOP_LEVEL;
left -= containerWidth + offsetLeft;
right += padding;
bottom += padding;
return {
left,
top,
right,
bottom,
width,
height,
padding,
containerWidth,
};
}
constructor(readonly widget: AffineDragHandleWidget) {}
watch() {
if (this.widget.mode === 'page') {
return;
}
const { disposables, std } = this.widget;
const gfx = std.get(GfxControllerIdentifier);
const { viewport, selection, tool, surface } = gfx;
const edgelessSlots = std.get(EdgelessLegacySlotIdentifier);
disposables.add(
viewport.viewportUpdated.on(this._handleEdgelessViewPortUpdated)
);
disposables.add(
selection.slots.updated.on(() => {
this.updateAnchorElement();
})
);
disposables.add(
edgelessSlots.readonlyUpdated.on(() => {
this.updateAnchorElement();
})
);
disposables.add(
edgelessSlots.elementResizeEnd.on(() => {
this.updateAnchorElement();
})
);
disposables.add(
effect(() => {
const value = tool.currentToolOption$.value;
value && this._handleEdgelessToolUpdated(value);
})
);
disposables.add(
edgelessSlots.elementResizeStart.on(() => {
this.widget.hide();
})
);
if (surface) {
disposables.add(
surface.elementUpdated.on(() => {
if (this.widget.isGfxDragHandleVisible) {
this._showDragHandle().catch(console.error);
}
})
);
}
}
}
@@ -0,0 +1,93 @@
import {
DRAG_HANDLE_CONTAINER_PADDING,
DRAG_HANDLE_GRABBER_BORDER_RADIUS,
DRAG_HANDLE_GRABBER_WIDTH_HOVERED,
} from '../config.js';
import type { AffineDragHandleWidget } from '../drag-handle.js';
export class HandleEventWatcher {
private readonly _onDragHandlePointerDown = () => {
if (!this.widget.isBlockDragHandleVisible || !this.widget.anchorBlockId)
return;
this.widget.dragHoverRect = this.widget.draggingAreaRect.value;
};
private readonly _onDragHandlePointerEnter = () => {
const container = this.widget.dragHandleContainer;
const grabber = this.widget.dragHandleGrabber;
if (!container || !grabber) return;
if (this.widget.isBlockDragHandleVisible && this.widget.anchorBlockId) {
const block = this.widget.anchorBlockComponent;
if (!block) return;
const padding = DRAG_HANDLE_CONTAINER_PADDING * this.widget.scale.peek();
container.style.paddingTop = `${padding}px`;
container.style.paddingBottom = `${padding}px`;
container.style.transition = `padding 0.25s ease`;
grabber.style.width = `${
DRAG_HANDLE_GRABBER_WIDTH_HOVERED * this.widget.scaleInNote.peek()
}px`;
grabber.style.borderRadius = `${
DRAG_HANDLE_GRABBER_BORDER_RADIUS * this.widget.scaleInNote.peek()
}px`;
this.widget.isDragHandleHovered = true;
} else if (this.widget.isGfxDragHandleVisible) {
this.widget.dragHoverRect =
this.widget.edgelessWatcher.hoveredElemAreaRect;
this.widget.isDragHandleHovered = true;
}
};
private readonly _onDragHandlePointerLeave = () => {
this.widget.isDragHandleHovered = false;
this.widget.dragHoverRect = null;
if (this.widget.isGfxDragHandleVisible) return;
if (this.widget.dragging) return;
this.widget.pointerEventWatcher.showDragHandleOnHoverBlock();
};
private readonly _onDragHandlePointerUp = () => {
if (!this.widget.isBlockDragHandleVisible) return;
this.widget.dragHoverRect = null;
};
constructor(readonly widget: AffineDragHandleWidget) {}
watch() {
const { dragHandleContainer, disposables } = this.widget;
// When pointer enter drag handle grabber
// Extend drag handle grabber to the height of the hovered block
disposables.addFromEvent(
dragHandleContainer,
'pointerenter',
this._onDragHandlePointerEnter
);
disposables.addFromEvent(
dragHandleContainer,
'pointerdown',
this._onDragHandlePointerDown
);
disposables.addFromEvent(
dragHandleContainer,
'pointerup',
this._onDragHandlePointerUp
);
// When pointer leave drag handle grabber, should reset drag handle grabber style
disposables.addFromEvent(
dragHandleContainer,
'pointerleave',
this._onDragHandlePointerLeave
);
}
}
@@ -0,0 +1,24 @@
import type { UIEventHandler } from '@blocksuite/block-std';
import type { AffineDragHandleWidget } from '../drag-handle.js';
export class KeyboardEventWatcher {
private readonly _keyboardHandler: UIEventHandler = ctx => {
if (!this.widget.dragging) {
return;
}
const state = ctx.get('defaultState');
const event = state.event as KeyboardEvent;
event.preventDefault();
event.stopPropagation();
};
constructor(readonly widget: AffineDragHandleWidget) {}
watch() {
this.widget.handleEvent('beforeInput', () => this.widget.hide());
this.widget.handleEvent('keyDown', this._keyboardHandler, { global: true });
this.widget.handleEvent('keyUp', this._keyboardHandler, { global: true });
}
}
@@ -0,0 +1,25 @@
import { PageViewportService } from '@blocksuite/affine-shared/services';
import type { AffineDragHandleWidget } from '../drag-handle.js';
export class PageWatcher {
get pageViewportService() {
return this.widget.std.get(PageViewportService);
}
constructor(readonly widget: AffineDragHandleWidget) {}
watch() {
const { disposables } = this.widget;
disposables.add(
this.widget.doc.slots.blockUpdated.on(() => this.widget.hide())
);
disposables.add(
this.pageViewportService.on(() => {
this.widget.hide();
})
);
}
}
@@ -0,0 +1,362 @@
import type { NoteBlockComponent } from '@blocksuite/affine-block-note';
import { captureEventTarget } from '@blocksuite/affine-shared/utils';
import {
BLOCK_ID_ATTR,
type BlockComponent,
type PointerEventState,
type UIEventHandler,
} from '@blocksuite/block-std';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import { Point } from '@blocksuite/global/gfx';
import { computed } from '@preact/signals-core';
import throttle from 'lodash-es/throttle';
import {
DRAG_HANDLE_CONTAINER_WIDTH,
DRAG_HANDLE_GRABBER_BORDER_RADIUS,
DRAG_HANDLE_GRABBER_HEIGHT,
DRAG_HANDLE_GRABBER_WIDTH,
} from '../config.js';
import { AFFINE_DRAG_HANDLE_WIDGET } from '../consts.js';
import type { AffineDragHandleWidget } from '../drag-handle.js';
import {
getClosestBlockByPoint,
getClosestNoteBlock,
getDragHandleContainerHeight,
includeTextSelection,
insideDatabaseTable,
isBlockIdEqual,
isOutOfNoteBlock,
updateDragHandleClassName,
} from '../utils.js';
/**
* Used to control the drag handle visibility in page mode
*/
export class PointerEventWatcher {
private _isPointerDown = false;
private get _gfx() {
return this.widget.std.get(GfxControllerIdentifier);
}
private readonly _canEditing = (noteBlock: BlockComponent) => {
if (noteBlock.doc.id !== this.widget.doc.id) return false;
if (this.widget.mode === 'page') return true;
const selection = this._gfx.selection;
const noteBlockId = noteBlock.model.id;
return selection.editing && selection.selectedIds[0] === noteBlockId;
};
/**
* When click on drag handle
* Should select the block and show slash menu if current block is not selected
* Should clear selection if current block is the first selected block
*/
private readonly _clickHandler: UIEventHandler = ctx => {
if (!this.widget.isBlockDragHandleVisible) return;
const state = ctx.get('pointerState');
const { target } = state.raw;
const element = captureEventTarget(target);
const insideDragHandle = !!element?.closest(AFFINE_DRAG_HANDLE_WIDGET);
if (!insideDragHandle) return;
const anchorBlockId = this.widget.anchorBlockId.peek();
if (!anchorBlockId) return;
const { selection } = this.widget.std;
const selectedBlocks = this.widget.selectionHelper.selectedBlocks;
// Should clear selection if current block is the first selected block
if (
selectedBlocks.length > 0 &&
!includeTextSelection(selectedBlocks) &&
selectedBlocks[0].blockId === anchorBlockId
) {
selection.clear(['block']);
this.widget.dragHoverRect = null;
this.showDragHandleOnHoverBlock();
return;
}
// Should select the block if current block is not selected
const block = this.widget.anchorBlockComponent.peek();
if (!block) return;
if (selectedBlocks.length > 1) {
this.showDragHandleOnHoverBlock();
}
this.widget.selectionHelper.setSelectedBlocks([block]);
};
// Need to consider block padding and scale
private readonly _getTopWithBlockComponent = (block: BlockComponent) => {
const computedStyle = getComputedStyle(block);
const { top } = block.getBoundingClientRect();
const paddingTop =
parseInt(computedStyle.paddingTop) * this.widget.scale.peek();
return (
top +
paddingTop -
this.widget.dragHandleContainerOffsetParent.getBoundingClientRect().top
);
};
private readonly _containerStyle = computed(() => {
const draggingAreaRect = this.widget.draggingAreaRect.value;
if (!draggingAreaRect) return null;
const block = this.widget.anchorBlockComponent.value;
if (!block) return null;
const containerHeight = getDragHandleContainerHeight(block.model);
const posTop = this._getTopWithBlockComponent(block);
const scaleInNote = this.widget.scaleInNote.value;
const rowPaddingY =
((containerHeight - DRAG_HANDLE_GRABBER_HEIGHT) / 2 + 2) * scaleInNote;
// use padding to control grabber's height
const paddingTop = rowPaddingY + posTop - draggingAreaRect.top;
const paddingBottom =
draggingAreaRect.height -
paddingTop -
DRAG_HANDLE_GRABBER_HEIGHT * scaleInNote;
return {
paddingTop: `${paddingTop}px`,
paddingBottom: `${paddingBottom}px`,
width: `${DRAG_HANDLE_CONTAINER_WIDTH * scaleInNote}px`,
left: `${draggingAreaRect.left}px`,
top: `${draggingAreaRect.top}px`,
height: `${draggingAreaRect.height}px`,
};
});
private readonly _grabberStyle = computed(() => {
const scaleInNote = this.widget.scaleInNote.value;
return {
width: `${DRAG_HANDLE_GRABBER_WIDTH * scaleInNote}px`,
borderRadius: `${DRAG_HANDLE_GRABBER_BORDER_RADIUS * scaleInNote}px`,
};
});
private _lastHoveredBlockId: string | null = null;
private _lastShowedBlock: { id: string; el: BlockComponent } | null = null;
/**
* When pointer move on block, should show drag handle
* And update hover block id and path
*/
private readonly _pointerMoveOnBlock = (state: PointerEventState) => {
if (this.widget.isGfxDragHandleVisible) return;
const point = new Point(state.raw.x, state.raw.y);
const closestBlock = getClosestBlockByPoint(
this.widget.host,
this.widget.rootComponent,
point
);
if (!closestBlock) {
this.widget.anchorBlockId.value = null;
return;
}
const blockId = closestBlock.getAttribute(BLOCK_ID_ATTR);
if (!blockId) return;
this.widget.anchorBlockId.value = blockId;
if (insideDatabaseTable(closestBlock) || this.widget.doc.readonly) {
this.widget.hide();
return;
}
// If current block is not the last hovered block, show drag handle beside the hovered block
if (
(!this._lastHoveredBlockId ||
!isBlockIdEqual(
this.widget.anchorBlockId.peek(),
this._lastHoveredBlockId
) ||
!this.widget.isBlockDragHandleVisible) &&
!this.widget.isDragHandleHovered
) {
this.showDragHandleOnHoverBlock();
this._lastHoveredBlockId = this.widget.anchorBlockId.peek();
}
};
private readonly _pointerOutHandler: UIEventHandler = ctx => {
const state = ctx.get('pointerState');
state.raw.preventDefault();
const { target } = state.raw;
const element = captureEventTarget(target);
if (!element) return;
const { relatedTarget } = state.raw;
// TODO: when pointer out of page viewport, should hide drag handle
// But the pointer out event is not as expected
// Need to be optimized
const relatedElement = captureEventTarget(relatedTarget);
const outOfPageViewPort = element.classList.contains(
'affine-page-viewport'
);
const inPage = !!relatedElement?.closest('.affine-page-viewport');
const inDragHandle = !!relatedElement?.closest(AFFINE_DRAG_HANDLE_WIDGET);
if (outOfPageViewPort && !inDragHandle && !inPage) {
this.widget.hide();
}
};
private readonly _throttledPointerMoveHandler = throttle<UIEventHandler>(
ctx => {
if (this._isPointerDown) return;
if (
this.widget.doc.readonly ||
this.widget.dragging ||
!this.widget.isConnected
) {
this.widget.hide();
return;
}
if (this.widget.isGfxDragHandleVisible) return;
const state = ctx.get('pointerState');
// When pointer is moving, should do nothing
if (state.delta.x !== 0 && state.delta.y !== 0) return;
const { target } = state.raw;
const element = captureEventTarget(target);
// When pointer not on block or on dragging, should do nothing
if (!element) return;
// When pointer on drag handle, should do nothing
if (element.closest('.affine-drag-handle-container')) return;
// When pointer out of note block hover area or inside database, should hide drag handle
const point = new Point(state.raw.x, state.raw.y);
const closestNoteBlock = getClosestNoteBlock(
this.widget.host,
this.widget.rootComponent,
point
) as NoteBlockComponent | null;
this.widget.noteScale.value =
this.widget.mode === 'page'
? 1
: (closestNoteBlock?.model.edgeless.scale ?? 1);
if (
closestNoteBlock &&
this._canEditing(closestNoteBlock) &&
!isOutOfNoteBlock(
this.widget.host,
closestNoteBlock,
point,
this.widget.scaleInNote.peek()
)
) {
this._pointerMoveOnBlock(state);
return true;
}
if (this.widget.activeDragHandle) {
this.widget.hide();
}
return false;
},
1000 / 60
);
// Multiple blocks: drag handle should show on the vertical middle of all blocks
showDragHandleOnHoverBlock = () => {
const block = this.widget.anchorBlockComponent.peek();
if (!block) return;
const container = this.widget.dragHandleContainer;
const grabber = this.widget.dragHandleGrabber;
if (!container || !grabber) return;
this.widget.activeDragHandle = 'block';
const draggingAreaRect = this.widget.draggingAreaRect.peek();
if (!draggingAreaRect) return;
// Ad-hoc solution for list with toggle icon
updateDragHandleClassName([block]);
// End of ad-hoc solution
const applyStyle = (transition?: boolean) => {
const containerStyle = this._containerStyle.value;
if (!containerStyle) return;
container.style.transition = transition ? 'padding 0.25s ease' : 'none';
Object.assign(container.style, containerStyle);
container.style.display = 'flex';
};
if (isBlockIdEqual(block.blockId, this._lastShowedBlock?.id)) {
applyStyle(true);
} else if (this.widget.selectionHelper.selectedBlocks.length) {
if (this.widget.selectionHelper.isBlockSelected(block))
applyStyle(
this.widget.isDragHandleHovered &&
this.widget.selectionHelper.isBlockSelected(
this._lastShowedBlock?.el
)
);
else applyStyle(false);
} else {
applyStyle(false);
}
const grabberStyle = this._grabberStyle.value;
Object.assign(grabber.style, grabberStyle);
this.widget.handleAnchorModelDisposables();
if (!isBlockIdEqual(block.blockId, this._lastShowedBlock?.id)) {
this._lastShowedBlock = {
id: block.blockId,
el: block,
};
}
};
private readonly _pointerDownHandler: UIEventHandler = () => {
this._isPointerDown = true;
};
private readonly _pointerUpHandler: UIEventHandler = () => {
this._isPointerDown = false;
};
constructor(readonly widget: AffineDragHandleWidget) {}
reset() {
this._lastHoveredBlockId = null;
this._lastShowedBlock = null;
}
watch() {
this.widget.handleEvent('click', this._clickHandler);
this.widget.handleEvent('pointerMove', this._throttledPointerMoveHandler);
this.widget.handleEvent('pointerOut', this._pointerOutHandler);
this.widget.handleEvent('pointerDown', this._pointerDownHandler);
this.widget.handleEvent('pointerUp', this._pointerUpHandler);
}
}
@@ -0,0 +1,23 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src"],
"references": [
{ "path": "../../blocks/block-callout" },
{ "path": "../../blocks/block-list" },
{ "path": "../../blocks/block-note" },
{ "path": "../../blocks/block-paragraph" },
{ "path": "../../blocks/block-surface" },
{ "path": "../../components" },
{ "path": "../../model" },
{ "path": "../../shared" },
{ "path": "../../../framework/block-std" },
{ "path": "../../../framework/global" },
{ "path": "../../../framework/inline" },
{ "path": "../../../framework/store" }
]
}
@@ -0,0 +1,39 @@
{
"name": "@blocksuite/affine-widget-edgeless-auto-connect",
"description": "Affine edgeless auto connect widget.",
"type": "module",
"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": "MIT",
"dependencies": {
"@blocksuite/affine-block-note": "workspace:*",
"@blocksuite/affine-block-surface": "workspace:*",
"@blocksuite/affine-components": "workspace:*",
"@blocksuite/affine-model": "workspace:*",
"@blocksuite/affine-shared": "workspace:*",
"@blocksuite/block-std": "workspace:*",
"@blocksuite/global": "workspace:*",
"@blocksuite/icons": "^2.2.3",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.12",
"lit": "^3.2.0"
},
"exports": {
".": "./src/index.ts",
"./effects": "./src/effects.ts"
},
"files": [
"src",
"dist",
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.20.0"
}
@@ -0,0 +1,11 @@
import {
AFFINE_EDGELESS_AUTO_CONNECT_WIDGET,
EdgelessAutoConnectWidget,
} from '.';
export function effects() {
customElements.define(
AFFINE_EDGELESS_AUTO_CONNECT_WIDGET,
EdgelessAutoConnectWidget
);
}
@@ -0,0 +1,627 @@
import {
EdgelessCRUDIdentifier,
EdgelessLegacySlotIdentifier,
isNoteBlock,
} from '@blocksuite/affine-block-surface';
import { SmallDocIcon } from '@blocksuite/affine-components/icons';
import {
FrameBlockModel,
NoteBlockModel,
NoteDisplayMode,
type RootBlockModel,
SurfaceRefBlockModel,
} from '@blocksuite/affine-model';
import { FeatureFlagService } from '@blocksuite/affine-shared/services';
import { matchModels, stopPropagation } from '@blocksuite/affine-shared/utils';
import { WidgetComponent, WidgetViewExtension } from '@blocksuite/block-std';
import {
type GfxController,
GfxControllerIdentifier,
} from '@blocksuite/block-std/gfx';
import { Bound } from '@blocksuite/global/gfx';
import {
ArrowLeftSmallIcon,
ArrowRightSmallIcon,
InvisibleIcon,
} from '@blocksuite/icons/lit';
import { css, html, nothing, type TemplateResult } from 'lit';
import { state } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js';
import { literal, unsafeStatic } from 'lit/static-html.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) {
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`<style>
${styles}
</style>
<div class="index-label-tooltip">
<span class="index-label-tooltip-icon">${icon}</span>
<span class="index-label-tooltip-content">${content}</span>
</div>`;
}
type AutoConnectElement = NoteBlockModel | FrameBlockModel;
function isAutoConnectElement(element: unknown): element is AutoConnectElement {
return (
element instanceof NoteBlockModel || element instanceof FrameBlockModel
);
}
export const AFFINE_EDGELESS_AUTO_CONNECT_WIDGET =
'affine-edgeless-auto-connect-widget';
export class EdgelessAutoConnectWidget extends WidgetComponent<RootBlockModel> {
static override styles = css`
.page-visible-index-label {
box-sizing: border-box;
padding: 0px 6px;
border: 1px solid #0000001a;
width: fit-content;
height: 24px;
min-width: 24px;
color: var(--affine-white);
font-size: 15px;
line-height: 22px;
text-align: center;
cursor: pointer;
user-select: none;
border-radius: 25px;
background: var(--affine-primary-color);
}
.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;
}
`;
private get _gfx(): GfxController {
return this.std.get(GfxControllerIdentifier);
}
private get _crud() {
return this.std.get(EdgelessCRUDIdentifier);
}
private get _viewport() {
return this._gfx.viewport;
}
private get _selection() {
return this._gfx.selection;
}
private readonly _updateLabels = () => {
const service = this.service;
if (!service.doc.root) return;
const pageVisibleBlocks = new Map<AutoConnectElement, number>();
const notes = service.doc.root?.children.filter(child =>
matchModels(child, [NoteBlockModel])
);
const edgelessOnlyNotesSet = new Set<NoteBlockModel>();
notes.forEach(note => {
if (isNoteBlock(note)) {
if (note.displayMode$.value === NoteDisplayMode.EdgelessOnly) {
edgelessOnlyNotesSet.add(note);
} else if (note.displayMode$.value === NoteDisplayMode.DocAndEdgeless) {
pageVisibleBlocks.set(note, 1);
}
}
note.children.forEach(model => {
if (matchModels(model, [SurfaceRefBlockModel])) {
const reference = this._crud.getElementById(model.reference);
if (!isAutoConnectElement(reference)) return;
if (!pageVisibleBlocks.has(reference)) {
pageVisibleBlocks.set(reference, 1);
} else {
pageVisibleBlocks.set(
reference,
pageVisibleBlocks.get(reference)! + 1
);
}
}
});
});
this._edgelessOnlyNotesSet = edgelessOnlyNotesSet;
this._pageVisibleElementsMap = pageVisibleBlocks;
};
private _EdgelessOnlyLabels() {
const { _edgelessOnlyNotesSet } = this;
if (!_edgelessOnlyNotesSet.size) return nothing;
return html`${repeat(
_edgelessOnlyNotesSet,
note => note.id,
note => {
const viewport = this._viewport;
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`<div style=${style} class="edgeless-only-index-label">
${InvisibleIcon({ width: '20px', height: '20px' })}
<affine-tooltip tip-position="bottom">
${getIndexLabelTooltip(SmallDocIcon, 'Hidden on page')}
</affine-tooltip>
</div>`;
}
)}`;
}
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 _initLabels() {
const { service } = this.block;
const surfaceRefs = service.doc
.getBlocksByFlavour('affine:surface-ref')
.map(block => block.model) as SurfaceRefBlockModel[];
const getVisibility = () => {
const { selectedElements } = this._selection;
if (
selectedElements.length === 1 &&
!this._selection.editing &&
(isNoteBlock(selectedElements[0]) ||
surfaceRefs.some(ref => ref.reference === selectedElements[0].id))
) {
this._show = true;
} else {
this._show = false;
}
return this._show;
};
this._disposables.add(
this._selection.slots.updated.on(() => {
getVisibility();
})
);
this._disposables.add(
this.doc.slots.blockUpdated.on(payload => {
if (payload.flavour === 'affine:surface-ref') {
switch (payload.type) {
case 'add':
surfaceRefs.push(payload.model as SurfaceRefBlockModel);
break;
case 'delete':
{
const idx = surfaceRefs.indexOf(
payload.model as SurfaceRefBlockModel
);
if (idx >= 0) {
surfaceRefs.splice(idx, 1);
}
}
break;
case 'update':
if (payload.props.key !== 'reference') {
return;
}
}
this.requestUpdate();
}
})
);
const surface = this._gfx.surface;
if (surface) {
this._disposables.add(
surface.elementUpdated.on(payload => {
if (
payload.props['xywh'] &&
surfaceRefs.some(ref => ref.reference === payload.id)
) {
this.requestUpdate();
}
})
);
}
}
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._selection.set({
elements: [element.id],
editing: false,
});
this._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._selection.set({
elements: [element.id],
editing: false,
});
this._viewport.setViewportByBound(bound, [80, 80, 80, 80], true);
}
private _NavigatorComponent(elements: AutoConnectElement[]) {
const viewport = this._viewport;
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)`,
});
const iconStyle = {
width: '16px',
height: '16px',
style: 'color:#77757D;',
};
return html`<div class=${className} style=${navigatorStyle}>
<div
role="button"
class="edgeless-auto-connect-previous-button"
@pointerdown=${(e: PointerEvent) => {
stopPropagation(e);
this._navigateToPrev();
}}
>
${ArrowLeftSmallIcon(iconStyle)}
</div>
<span></span>
<div
role="button"
class="edgeless-auto-connect-next-button"
@pointerdown=${(e: PointerEvent) => {
stopPropagation(e);
this._navigateToNext();
}}
>
${ArrowRightSmallIcon(iconStyle)}
</div>
</div> `;
}
private _PageVisibleIndexLabels(
elements: AutoConnectElement[],
counts: number[]
) {
const viewport = this._viewport;
const { zoom } = viewport;
let index = 0;
return html`${repeat(
elements,
element => element.id,
(element, i) => {
const bound = Bound.deserialize(element.xywh$.value);
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: TemplateResult[] = [];
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`
<div
style=${styleMap({
position: 'absolute',
top: positions[j][0] + 'px',
left: positions[j][1] + 'px',
transition: 'all 0.1s linear',
})}
index=${i}
class="page-visible-index-label"
@pointerdown=${(e: PointerEvent) => {
stopPropagation(e);
this._index = this._index === i ? -1 : i;
}}
>
${index}
<affine-tooltip tip-position="bottom">
${getIndexLabelTooltip(SmallDocIcon, 'Page mode index')}
</affine-tooltip>
</div>
`);
}
function updateChildrenPosition(e: MouseEvent, positions: number[][]) {
if (!e.target) return;
const children = (e.target as HTMLElement).children;
(Array.from(children) as HTMLElement[]).forEach((c, index) => {
c.style.top = positions[index][0] + 'px';
c.style.left = positions[index][1] + 'px';
});
}
return html`<div
style=${style}
@mouseenter=${(e: MouseEvent) => {
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}
</div>`;
}
)}`;
}
private _setHostStyle() {
this.style.position = 'absolute';
this.style.top = '0';
this.style.left = '0';
this.style.zIndex = '1';
}
override connectedCallback(): void {
super.connectedCallback();
this._setHostStyle();
this._initLabels();
}
override firstUpdated(): void {
const { _disposables, std } = this;
const slots = std.get(EdgelessLegacySlotIdentifier);
const gfx = std.get(GfxControllerIdentifier);
_disposables.add(
gfx.viewport.viewportUpdated.on(() => {
this.requestUpdate();
})
);
_disposables.add(
gfx.selection.slots.updated.on(() => {
const { selectedElements } = gfx.selection;
if (
!(selectedElements.length === 1 && isNoteBlock(selectedElements[0]))
) {
this._index = -1;
}
})
);
_disposables.add(
std.event.add('dragStart', () => {
this._dragging = true;
})
);
_disposables.add(
std.event.add('dragEnd', () => {
this._dragging = false;
})
);
_disposables.add(
slots.elementResizeStart.on(() => {
this._dragging = true;
})
);
_disposables.add(
slots.elementResizeEnd.on(() => {
this._dragging = false;
})
);
}
override render() {
const advancedVisibilityEnabled = this.doc
.get(FeatureFlagService)
.getFlag('enable_advanced_block_visibility');
if (!this._show || this._dragging || !advancedVisibilityEnabled) {
return nothing;
}
this._updateLabels();
const { elements, counts } = this._getElementsAndCounts();
return html`${this._PageVisibleIndexLabels(elements, counts)}
${this._EdgelessOnlyLabels()}
${this._index >= 0 && this._index < elements.length
? this._NavigatorComponent(elements)
: nothing} `;
}
@state()
private accessor _dragging = false;
@state()
private accessor _edgelessOnlyNotesSet = new Set<NoteBlockModel>();
@state()
private accessor _index = -1;
@state()
private accessor _pageVisibleElementsMap: Map<AutoConnectElement, number> =
new Map();
@state()
private accessor _show = false;
}
export const autoConnectWidget = WidgetViewExtension(
'affine:page',
AFFINE_EDGELESS_AUTO_CONNECT_WIDGET,
literal`${unsafeStatic(AFFINE_EDGELESS_AUTO_CONNECT_WIDGET)}`
);
declare global {
interface HTMLElementTagNameMap {
'affine-edgeless-auto-connect-widget': EdgelessAutoConnectWidget;
}
}
@@ -0,0 +1,18 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src"],
"references": [
{ "path": "../../blocks/block-note" },
{ "path": "../../blocks/block-surface" },
{ "path": "../../components" },
{ "path": "../../model" },
{ "path": "../../shared" },
{ "path": "../../../framework/block-std" },
{ "path": "../../../framework/global" }
]
}
@@ -0,0 +1,37 @@
{
"name": "@blocksuite/affine-widget-frame-title",
"description": "Affine frame title widget.",
"type": "module",
"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": "MIT",
"dependencies": {
"@blocksuite/affine-components": "workspace:*",
"@blocksuite/affine-model": "workspace:*",
"@blocksuite/affine-shared": "workspace:*",
"@blocksuite/block-std": "workspace:*",
"@blocksuite/global": "workspace:*",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.12",
"lit": "^3.2.0"
},
"exports": {
".": "./src/index.ts",
"./effects": "./src/effects.ts"
},
"files": [
"src",
"dist",
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.20.0"
}
@@ -0,0 +1,7 @@
import { AFFINE_FRAME_TITLE, AffineFrameTitle } from './frame-title.js';
import { AFFINE_FRAME_TITLE_WIDGET, AffineFrameTitleWidget } from './index.js';
export function effects() {
customElements.define(AFFINE_FRAME_TITLE_WIDGET, AffineFrameTitleWidget);
customElements.define(AFFINE_FRAME_TITLE, AffineFrameTitle);
}
@@ -0,0 +1,279 @@
import { parseStringToRgba } from '@blocksuite/affine-components/color-picker';
import {
ColorScheme,
FrameBlockModel,
isTransparent,
} from '@blocksuite/affine-model';
import { ThemeProvider } from '@blocksuite/affine-shared/services';
import {
type BlockStdScope,
PropTypes,
requiredProperties,
stdContext,
} from '@blocksuite/block-std';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import { Bound, type SerializedXYWH } from '@blocksuite/global/gfx';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { consume } from '@lit/context';
import { themeToVar } from '@toeverything/theme/v2';
import { LitElement } from 'lit';
import { property, state } from 'lit/decorators.js';
import { frameTitleStyle, frameTitleStyleVars } from './styles.js';
export const AFFINE_FRAME_TITLE = 'affine-frame-title';
@requiredProperties({
model: PropTypes.instanceOf(FrameBlockModel),
})
export class AffineFrameTitle extends SignalWatcher(
WithDisposable(LitElement)
) {
static override styles = frameTitleStyle;
private _cachedHeight = 0;
private _cachedWidth = 0;
get colors() {
let backgroundColor = this.std
.get(ThemeProvider)
.getColorValue(this.model.background, undefined, true);
if (isTransparent(backgroundColor)) {
backgroundColor = this.std
.get(ThemeProvider)
.getCssVariableColor(themeToVar('edgeless/frame/background/white'));
}
const { r, g, b, a } = parseStringToRgba(backgroundColor);
const theme = this.std.get(ThemeProvider).theme;
let textColor: string;
{
let rPrime, gPrime, bPrime;
if (theme === ColorScheme.Light) {
rPrime = 1 - a + a * r;
gPrime = 1 - a + a * g;
bPrime = 1 - a + a * b;
} else {
rPrime = a * r;
gPrime = a * g;
bPrime = a * b;
}
// light
const L = 0.299 * rPrime + 0.587 * gPrime + 0.114 * bPrime;
textColor = L > 0.5 ? 'black' : 'white';
}
return {
background: backgroundColor,
text: textColor,
};
}
get doc() {
return this.model.doc;
}
get gfx() {
return this.std.get(GfxControllerIdentifier);
}
private _isInsideFrame() {
return this.gfx.grid.has(
this.model.elementBound,
true,
true,
model => model !== this.model && model instanceof FrameBlockModel
);
}
private _updateFrameTitleSize() {
const { _nestedFrame, _zoom: zoom } = this;
const { elementBound } = this.model;
const width = this._cachedWidth / zoom;
const height = this._cachedHeight / zoom;
const { nestedFrameOffset } = frameTitleStyleVars;
if (width && height) {
this.model.externalXYWH = `[${
elementBound.x + (_nestedFrame ? nestedFrameOffset / zoom : 0)
},${
elementBound.y +
(_nestedFrame
? nestedFrameOffset / zoom
: -(height + nestedFrameOffset / zoom))
},${width},${height}]`;
this.gfx.grid.update(this.model);
} else {
this.model.externalXYWH = undefined;
}
}
private _updateStyle() {
if (
this._frameTitle.length === 0 ||
this._editing ||
this.gfx.tool.currentToolName$.value === 'frameNavigator'
) {
this.style.display = 'none';
return;
}
const model = this.model;
const bound = Bound.deserialize(model.xywh);
const { _zoom: zoom } = this;
const { nestedFrameOffset, height } = frameTitleStyleVars;
const nestedFrame = this._nestedFrame;
const maxWidth = nestedFrame
? bound.w * zoom - nestedFrameOffset / zoom
: bound.w * zoom;
const hidden = height / zoom >= bound.h;
const transformOperation = [
`translate(0%, ${nestedFrame ? 0 : -100}%)`,
`translate(${nestedFrame ? nestedFrameOffset : 0}px, ${
nestedFrame ? nestedFrameOffset : -nestedFrameOffset
}px)`,
];
const anchor = this.gfx.viewport.toViewCoord(bound.x, bound.y);
this.style.display = '';
this.style.setProperty('--bg-color', this.colors.background);
this.style.left = `${anchor[0]}px`;
this.style.top = `${anchor[1]}px`;
this.style.display = hidden ? 'none' : 'flex';
this.style.transform = transformOperation.join(' ');
this.style.maxWidth = `${maxWidth}px`;
this.style.transformOrigin = nestedFrame ? 'top left' : 'bottom left';
this.style.color = this.colors.text;
}
override connectedCallback() {
super.connectedCallback();
const { _disposables, doc, gfx } = this;
this._nestedFrame = this._isInsideFrame();
_disposables.add(
doc.slots.blockUpdated.on(payload => {
if (
(payload.type === 'update' &&
payload.props.key === 'xywh' &&
doc.getBlock(payload.id)?.model instanceof FrameBlockModel) ||
(payload.type === 'add' && payload.flavour === 'affine:frame')
) {
this._nestedFrame = this._isInsideFrame();
}
if (
payload.type === 'delete' &&
payload.model instanceof FrameBlockModel &&
payload.model !== this.model
) {
this._nestedFrame = this._isInsideFrame();
}
})
);
_disposables.add(
this.model.propsUpdated.on(() => {
this._xywh = this.model.xywh;
this.requestUpdate();
})
);
_disposables.add(
gfx.selection.slots.updated.on(() => {
this._editing =
gfx.selection.selectedIds[0] === this.model.id &&
gfx.selection.editing;
})
);
_disposables.add(
gfx.viewport.viewportUpdated.on(({ zoom }) => {
this._zoom = zoom;
this.requestUpdate();
})
);
this._zoom = gfx.viewport.zoom;
const updateTitle = () => {
this._frameTitle = this.model.title.toString().trim();
};
_disposables.add(() => {
this.model.title.yText.unobserve(updateTitle);
});
this.model.title.yText.observe(updateTitle);
this._frameTitle = this.model.title.toString().trim();
this._xywh = this.model.xywh;
}
override firstUpdated() {
this._cachedWidth = this.clientWidth;
this._cachedHeight = this.clientHeight;
this._updateFrameTitleSize();
}
override render() {
this._updateStyle();
return this._frameTitle;
}
override updated(_changedProperties: Map<string, unknown>) {
if (
!this.gfx.viewport.viewportBounds.contains(this.model.elementBound) &&
!this.gfx.viewport.viewportBounds.isIntersectWithBound(
this.model.elementBound
)
) {
return;
}
let sizeChanged = false;
if (
this._cachedWidth === 0 ||
this._cachedHeight === 0 ||
_changedProperties.has('_frameTitle') ||
_changedProperties.has('_nestedFrame') ||
_changedProperties.has('_xywh') ||
_changedProperties.has('_editing')
) {
this._cachedWidth = this.clientWidth;
this._cachedHeight = this.clientHeight;
sizeChanged = true;
}
if (sizeChanged || _changedProperties.has('_zoom')) {
this._updateFrameTitleSize();
}
}
@state()
private accessor _editing = false;
@state()
private accessor _frameTitle = '';
@state()
private accessor _nestedFrame = false;
@state()
private accessor _xywh: SerializedXYWH | null = null;
@state()
private accessor _zoom = 1;
@property({ attribute: false })
accessor model!: FrameBlockModel;
@consume({ context: stdContext })
accessor std!: BlockStdScope;
}
@@ -0,0 +1,45 @@
import { FrameBlockModel, type RootBlockModel } from '@blocksuite/affine-model';
import { WidgetComponent, WidgetViewExtension } from '@blocksuite/block-std';
import { html } from 'lit';
import { repeat } from 'lit/directives/repeat.js';
import { literal, unsafeStatic } from 'lit/static-html.js';
import type { AffineFrameTitle } from './frame-title.js';
export const AFFINE_FRAME_TITLE_WIDGET = 'affine-frame-title-widget';
export class AffineFrameTitleWidget extends WidgetComponent<RootBlockModel> {
private get _frames() {
return Object.values(this.doc.blocks.value)
.map(({ model }) => model)
.filter(model => model instanceof FrameBlockModel);
}
getFrameTitle(frame: FrameBlockModel | string) {
const id = typeof frame === 'string' ? frame : frame.id;
const frameTitle = this.shadowRoot?.querySelector(
`affine-frame-title[data-id="${id}"]`
) as AffineFrameTitle | null;
return frameTitle;
}
override render() {
return repeat(
this._frames,
({ id }) => id,
frame =>
html`<affine-frame-title
.model=${frame}
data-id=${frame.id}
></affine-frame-title>`
);
}
}
export * from './styles.js';
export const frameTitleWidget = WidgetViewExtension(
'affine:page',
AFFINE_FRAME_TITLE_WIDGET,
literal`${unsafeStatic(AFFINE_FRAME_TITLE_WIDGET)}`
);
@@ -0,0 +1,35 @@
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { css } from 'lit';
export const frameTitleStyleVars = {
nestedFrameOffset: 4,
height: 22,
fontSize: 14,
};
export const frameTitleStyle = css`
:host {
position: absolute;
display: flex;
align-items: center;
z-index: 1;
border: 1px solid ${unsafeCSSVarV2('edgeless/frame/border/default')};
border-radius: 4px;
width: fit-content;
height: ${frameTitleStyleVars.height}px;
padding: 0px 4px;
transform-origin: left bottom;
background-color: var(--bg-color);
font-family: var(--affine-font-family);
font-size: ${frameTitleStyleVars.fontSize}px;
cursor: default;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
:hover {
background-color: color-mix(in srgb, var(--bg-color), #000000 7%);
}
`;
@@ -0,0 +1,16 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src"],
"references": [
{ "path": "../../components" },
{ "path": "../../model" },
{ "path": "../../shared" },
{ "path": "../../../framework/block-std" },
{ "path": "../../../framework/global" }
]
}
@@ -0,0 +1,40 @@
{
"name": "@blocksuite/affine-widget-remote-selection",
"description": "Affine remote selection widget.",
"type": "module",
"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": "MIT",
"dependencies": {
"@blocksuite/affine-block-surface": "workspace:*",
"@blocksuite/affine-components": "workspace:*",
"@blocksuite/affine-model": "workspace:*",
"@blocksuite/affine-shared": "workspace:*",
"@blocksuite/block-std": "workspace:*",
"@blocksuite/global": "workspace:*",
"@blocksuite/icons": "^2.2.3",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.12",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.21"
},
"exports": {
".": "./src/index.ts",
"./effects": "./src/effects.ts"
},
"files": [
"src",
"dist",
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.20.0"
}
@@ -0,0 +1,5 @@
import type { BlockModel } from '@blocksuite/store';
export type DocRemoteSelectionConfig = {
blockSelectionBackgroundTransparent: (block: BlockModel) => boolean;
};
@@ -0,0 +1,387 @@
import {
AttachmentBlockModel,
BookmarkBlockModel,
CodeBlockModel,
DatabaseBlockModel,
ImageBlockModel,
SurfaceRefBlockModel,
} from '@blocksuite/affine-model';
import { getSelectionRectsCommand } from '@blocksuite/affine-shared/commands';
import { EMBED_BLOCK_MODEL_LIST } from '@blocksuite/affine-shared/consts';
import { matchModels } from '@blocksuite/affine-shared/utils';
import {
BlockSelection,
TextSelection,
WidgetComponent,
} from '@blocksuite/block-std';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import type { BaseSelection, UserInfo } from '@blocksuite/store';
import { computed, effect } from '@preact/signals-core';
import { css, html, nothing, type PropertyValues } from 'lit';
import { state } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import throttle from 'lodash-es/throttle';
import { RemoteColorManager } from '../manager/remote-color-manager';
import type { DocRemoteSelectionConfig } from './config';
import { cursorStyle, selectionStyle } from './utils';
export interface SelectionRect {
width: number;
height: number;
top: number;
left: number;
transparent?: boolean;
}
export const AFFINE_DOC_REMOTE_SELECTION_WIDGET =
'affine-doc-remote-selection-widget';
export class AffineDocRemoteSelectionWidget extends WidgetComponent {
// avoid being unable to select text by mouse click or drag
static override styles = css`
:host {
pointer-events: none;
}
`;
@state()
private accessor _selections: Array<{
id: number;
selections: BaseSelection[];
rects: SelectionRect[];
user?: UserInfo;
}> = [];
private readonly _abortController = new AbortController();
private _remoteColorManager: RemoteColorManager | null = null;
private readonly _remoteSelections = computed(() => {
const status = this.doc.awarenessStore.getStates();
return [...this.std.selection.remoteSelections.entries()].map(
([id, selections]) => {
return {
id,
selections,
user: status.get(id)?.user,
};
}
);
});
private readonly _resizeObserver: ResizeObserver = new ResizeObserver(() => {
this.requestUpdate();
});
private get _config(): DocRemoteSelectionConfig {
return {
blockSelectionBackgroundTransparent: block => {
return matchModels(block, [
CodeBlockModel,
DatabaseBlockModel,
ImageBlockModel,
AttachmentBlockModel,
BookmarkBlockModel,
SurfaceRefBlockModel,
...EMBED_BLOCK_MODEL_LIST,
]);
},
};
}
private get _container() {
return this.offsetParent;
}
private get _containerRect() {
return this.offsetParent?.getBoundingClientRect();
}
private get _selectionManager() {
return this.host.selection;
}
private _getTextRange(textSelection: TextSelection): Range | null {
const toBlockId = textSelection.to
? textSelection.to.blockId
: textSelection.from.blockId;
let range = this.std.range.textSelectionToRange(
this._selectionManager.create(TextSelection, {
from: {
blockId: toBlockId,
index: textSelection.to
? textSelection.to.index + textSelection.to.length
: textSelection.from.index + textSelection.from.length,
length: 0,
},
to: null,
})
);
if (!range) {
// If no range, maybe the block is not updated yet
// We just set the range to the end of the block
const block = this.std.view.getBlock(toBlockId);
if (!block) return null;
range = this.std.range.textSelectionToRange(
this._selectionManager.create(TextSelection, {
from: {
blockId: toBlockId,
index: block.model.text?.length ?? 0,
length: 0,
},
to: null,
})
);
if (!range) return null;
}
return range;
}
private _getCursorRect(selections: BaseSelection[]): SelectionRect | null {
if (this.block.model.flavour !== 'affine:page') {
console.error('remote selection widget must be used in page component');
return null;
}
const textSelection = selections.find(
selection => selection instanceof TextSelection
) as TextSelection | undefined;
const blockSelections = selections.filter(
selection => selection instanceof BlockSelection
);
const container = this._container;
const containerRect = this._containerRect;
if (textSelection) {
const range = this._getTextRange(textSelection);
if (!range) return null;
const container = this._container;
const containerRect = this._containerRect;
const rangeRects = Array.from(range.getClientRects());
if (rangeRects.length > 0) {
const rect =
rangeRects.length === 1
? rangeRects[0]
: rangeRects[rangeRects.length - 1];
return {
width: 2,
height: rect.height,
top:
rect.top - (containerRect?.top ?? 0) + (container?.scrollTop ?? 0),
left:
rect.left -
(containerRect?.left ?? 0) +
(container?.scrollLeft ?? 0),
};
}
} else if (blockSelections.length > 0) {
const lastBlockSelection = blockSelections[blockSelections.length - 1];
const block = this.host.view.getBlock(lastBlockSelection.blockId);
if (block) {
const rect = block.getBoundingClientRect();
return {
width: 2,
height: rect.height,
top:
rect.top - (containerRect?.top ?? 0) + (container?.scrollTop ?? 0),
left:
rect.left +
rect.width -
(containerRect?.left ?? 0) +
(container?.scrollLeft ?? 0),
};
}
}
return null;
}
private readonly _getSelectionRect = (
selections: BaseSelection[]
): SelectionRect[] => {
if (this.block.model.flavour !== 'affine:page') {
console.error('remote selection widget must be used in page component');
return [];
}
const textSelection = selections.find(
selection => selection instanceof TextSelection
) as TextSelection | undefined;
const blockSelections = selections.filter(
selection => selection instanceof BlockSelection
);
if (!textSelection && !blockSelections.length) return [];
const [_, { selectionRects }] = this.std.command.exec(
getSelectionRectsCommand,
{
textSelection,
blockSelections,
}
);
if (!selectionRects) return [];
return selectionRects.map(({ blockId, ...rect }) => {
if (!blockId) return rect;
const block = this.host.view.getBlock(blockId);
if (!block) return rect;
const isTransparent = this._config.blockSelectionBackgroundTransparent(
block.model
);
return {
...rect,
transparent: isTransparent,
};
});
};
override connectedCallback() {
super.connectedCallback();
this.handleEvent('wheel', () => {
this.requestUpdate();
});
this.disposables.addFromEvent(window, 'resize', () => {
this.requestUpdate();
});
this._remoteColorManager = new RemoteColorManager(this.std);
}
override disconnectedCallback() {
super.disconnectedCallback();
this._resizeObserver.disconnect();
this._abortController.abort();
}
private readonly _updateSelections = (
selections: typeof this._remoteSelections.value
) => {
const remoteUsers = new Set<number>();
this._selections = selections.flatMap(({ selections, id, user }) => {
if (remoteUsers.has(id)) {
return [];
} else {
remoteUsers.add(id);
}
return {
id,
selections,
rects: this._getSelectionRect(selections),
user,
};
});
};
private readonly _updateSelectionsThrottled = throttle(
this._updateSelections,
60
);
protected override firstUpdated(_changedProperties: PropertyValues): void {
this.disposables.add(
effect(() => {
const selections = this._remoteSelections.value;
this._updateSelectionsThrottled(selections);
})
);
this.disposables.add(
this.std.store.slots.blockUpdated.on(() => {
this._updateSelectionsThrottled(this._remoteSelections.peek());
})
);
const gfx = this.std.get(GfxControllerIdentifier);
this.disposables.add(
gfx.viewport.viewportUpdated.on(() => {
const selections = this._remoteSelections.peek();
this._updateSelections(selections);
})
);
}
override render() {
if (this._selections.length === 0) {
return nothing;
}
const remoteColorManager = this._remoteColorManager;
if (!remoteColorManager) return nothing;
return html`<div>
${this._selections.map(selection => {
const color = remoteColorManager.get(selection.id);
if (!color) return [];
const cursorRect = this._getCursorRect(selection.selections);
return selection.rects
.map(r => html`<div style="${selectionStyle(r, color)}"></div>`)
.concat([
html`
<div
style="${cursorRect
? cursorStyle(cursorRect, color)
: styleMap({
display: 'none',
})}"
>
<div
style="${styleMap({
position: 'relative',
height: '100%',
})}"
>
<div
style="${styleMap({
position: 'absolute',
left: '-4px',
bottom: `${
cursorRect?.height ? cursorRect.height - 4 : 0
}px`,
backgroundColor: color,
color: 'white',
maxWidth: '160px',
padding: '0 3px',
border: '1px solid var(--affine-pure-black-20)',
boxShadow: '0px 1px 6px 0px rgba(0, 0, 0, 0.16)',
borderRadius: '4px',
fontSize: '12px',
lineHeight: '18px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
display: selection.user ? 'block' : 'none',
})}"
>
${selection.user?.name}
</div>
</div>
</div>
`,
]);
})}
</div>`;
}
}
declare global {
interface HTMLElementTagNameMap {
[AFFINE_DOC_REMOTE_SELECTION_WIDGET]: AffineDocRemoteSelectionWidget;
}
}
@@ -0,0 +1 @@
export * from './doc-remote-selection.js';
@@ -0,0 +1,36 @@
import type { DirectiveResult } from 'lit/directive.js';
import { styleMap, type StyleMapDirective } from 'lit/directives/style-map.js';
import type { SelectionRect } from './doc-remote-selection.js';
export function selectionStyle(
rect: SelectionRect,
color: string
): DirectiveResult<typeof StyleMapDirective> {
return styleMap({
position: 'absolute',
width: `${rect.width}px`,
height: `${rect.height}px`,
top: `${rect.top}px`,
left: `${rect.left}px`,
backgroundColor: rect.transparent ? 'transparent' : color,
pointerEvent: 'none',
opacity: '20%',
borderRadius: '3px',
});
}
export function cursorStyle(
rect: SelectionRect,
color: string
): DirectiveResult<typeof StyleMapDirective> {
return styleMap({
position: 'absolute',
width: `${rect.width}px`,
height: `${rect.height}px`,
top: `${rect.top}px`,
left: `${rect.left}px`,
backgroundColor: color,
pointerEvent: 'none',
});
}
@@ -0,0 +1,303 @@
import { EdgelessCRUDIdentifier } from '@blocksuite/affine-block-surface';
import type { RootBlockModel } from '@blocksuite/affine-model';
import {
getSelectedRect,
isTopLevelBlock,
requestThrottledConnectedFrame,
} from '@blocksuite/affine-shared/utils';
import { WidgetComponent } from '@blocksuite/block-std';
import {
GfxControllerIdentifier,
type GfxModel,
} from '@blocksuite/block-std/gfx';
import { MultiCursorDuotoneIcon } from '@blocksuite/icons/lit';
import type { UserInfo } from '@blocksuite/store';
import { css, html, nothing } from 'lit';
import { state } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js';
import { RemoteColorManager } from '../manager/remote-color-manager';
export const AFFINE_EDGELESS_REMOTE_SELECTION_WIDGET =
'affine-edgeless-remote-selection-widget';
export class EdgelessRemoteSelectionWidget extends WidgetComponent<RootBlockModel> {
static override styles = css`
:host {
pointer-events: none;
position: absolute;
left: 0;
top: 0;
transform-origin: left top;
contain: size layout;
z-index: 1;
}
.remote-rect {
position: absolute;
top: 0;
left: 0;
border-radius: 4px;
box-sizing: border-box;
border-width: 3px;
z-index: 1;
transform-origin: center center;
}
.remote-cursor {
position: absolute;
top: 0;
left: 0;
transform-origin: left top;
z-index: 1;
}
.remote-cursor > svg {
display: block;
}
.remote-username {
margin-left: 22px;
margin-top: -2px;
color: white;
max-width: 160px;
padding: 0px 3px;
border: 1px solid var(--affine-pure-black-20);
box-shadow: 0px 1px 6px 0px rgba(0, 0, 0, 0.16);
border-radius: 4px;
font-size: 12px;
line-height: 18px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
`;
private _remoteColorManager: RemoteColorManager | null = null;
private readonly _updateOnElementChange = (
element: string | { id: string }
) => {
const id = typeof element === 'string' ? element : element.id;
if (this.isConnected && this.selection.hasRemote(id))
this._updateRemoteRects();
};
private readonly _updateRemoteCursor = () => {
const remoteCursors: EdgelessRemoteSelectionWidget['_remoteCursors'] =
new Map();
const status = this.doc.awarenessStore.getStates();
this.selection.remoteCursorSelectionMap.forEach(
(cursorSelection, clientId) => {
remoteCursors.set(clientId, {
x: cursorSelection.x,
y: cursorSelection.y,
user: status.get(clientId)?.user,
});
}
);
this._remoteCursors = remoteCursors;
};
private readonly _updateRemoteRects = () => {
const { selection } = this;
const remoteSelectionsMap = selection.remoteSurfaceSelectionsMap;
const remoteRects: EdgelessRemoteSelectionWidget['_remoteRects'] =
new Map();
remoteSelectionsMap.forEach((selections, clientId) => {
selections.forEach(selection => {
if (selection.elements.length === 0) return;
const elements = selection.elements
.map(id => this.crud.getElementById(id))
.filter(element => element) as GfxModel[];
const rect = getSelectedRect(elements);
if (rect.width === 0 || rect.height === 0) return;
const { left, top } = rect;
const [width, height] = [rect.width, rect.height];
let rotate = 0;
if (elements.length === 1) {
const element = elements[0];
if (!isTopLevelBlock(element)) {
rotate = element.rotate ?? 0;
}
}
remoteRects.set(clientId, {
width,
height,
borderStyle: 'solid',
left,
top,
rotate,
});
});
});
this._remoteRects = remoteRects;
};
private readonly _updateTransform = requestThrottledConnectedFrame(() => {
const { translateX, translateY, zoom } = this.gfx.viewport;
this.style.setProperty('--v-zoom', `${zoom}`);
this.style.setProperty(
'transform',
`translate(${translateX}px, ${translateY}px) scale(var(--v-zoom))`
);
}, this);
get gfx() {
return this.std.get(GfxControllerIdentifier);
}
get crud() {
return this.std.get(EdgelessCRUDIdentifier);
}
get selection() {
return this.gfx.selection;
}
get surface() {
return this.gfx.surface;
}
override connectedCallback() {
super.connectedCallback();
const { _disposables, doc } = this;
if (this.surface) {
_disposables.add(
this.surface.elementAdded.on(this._updateOnElementChange)
);
_disposables.add(
this.surface.elementRemoved.on(this._updateOnElementChange)
);
_disposables.add(
this.surface.elementUpdated.on(this._updateOnElementChange)
);
}
_disposables.add(doc.slots.blockUpdated.on(this._updateOnElementChange));
_disposables.add(
this.selection.slots.remoteUpdated.on(this._updateRemoteRects)
);
_disposables.add(
this.selection.slots.remoteCursorUpdated.on(this._updateRemoteCursor)
);
_disposables.add(
this.gfx.viewport.viewportUpdated.on(() => {
this._updateTransform();
})
);
this._updateTransform();
this._updateRemoteRects();
this._remoteColorManager = new RemoteColorManager(this.std);
}
override render() {
const { _remoteRects, _remoteCursors, _remoteColorManager } = this;
if (!_remoteColorManager) return nothing;
const rects = repeat(
_remoteRects.entries(),
value => value[0],
([id, rect]) =>
html`<div
data-client-id=${id}
class="remote-rect"
style=${styleMap({
pointerEvents: 'none',
width: `${rect.width}px`,
height: `${rect.height}px`,
borderStyle: rect.borderStyle,
borderColor: _remoteColorManager.get(id),
transform: `translate(${rect.left}px, ${rect.top}px) rotate(${rect.rotate}deg)`,
})}
></div>`
);
const cursors = repeat(
_remoteCursors.entries(),
value => value[0],
([id, cursor]) => {
return html`<div
data-client-id=${id}
class="remote-cursor"
style=${styleMap({
pointerEvents: 'none',
transform: `translate(${cursor.x}px, ${cursor.y}px) scale(calc(1/var(--v-zoom)))`,
color: _remoteColorManager.get(id),
})}
>
${MultiCursorDuotoneIcon({
width: '24px',
height: '24px',
style: `fill: ${_remoteColorManager.get(id)}; stroke: ${_remoteColorManager.get(id)};`,
})}
<div
class="remote-username"
style=${styleMap({
backgroundColor: _remoteColorManager.get(id),
})}
>
${cursor.user?.name ?? 'Unknown'}
</div>
</div>`;
}
);
return html`
<div class="affine-edgeless-remote-selection">${rects}${cursors}</div>
`;
}
@state()
private accessor _remoteCursors: Map<
number,
{
x: number;
y: number;
user?: UserInfo | undefined;
}
> = new Map();
@state()
private accessor _remoteRects: Map<
number,
{
width: number;
height: number;
borderStyle: string;
left: number;
top: number;
rotate: number;
}
> = new Map();
}
declare global {
interface HTMLElementTagNameMap {
[AFFINE_EDGELESS_REMOTE_SELECTION_WIDGET]: EdgelessRemoteSelectionWidget;
}
}
@@ -0,0 +1,17 @@
import { AFFINE_DOC_REMOTE_SELECTION_WIDGET } from './doc';
import { AffineDocRemoteSelectionWidget } from './doc/doc-remote-selection';
import {
AFFINE_EDGELESS_REMOTE_SELECTION_WIDGET,
EdgelessRemoteSelectionWidget,
} from './edgeless';
export function effects() {
customElements.define(
AFFINE_DOC_REMOTE_SELECTION_WIDGET,
AffineDocRemoteSelectionWidget
);
customElements.define(
AFFINE_EDGELESS_REMOTE_SELECTION_WIDGET,
EdgelessRemoteSelectionWidget
);
}
@@ -0,0 +1,20 @@
import { WidgetViewExtension } from '@blocksuite/block-std';
import { literal, unsafeStatic } from 'lit/static-html.js';
import { AFFINE_DOC_REMOTE_SELECTION_WIDGET } from './doc';
import { AFFINE_EDGELESS_REMOTE_SELECTION_WIDGET } from './edgeless';
export * from './doc';
export * from './edgeless';
export const docRemoteSelectionWidget = WidgetViewExtension(
'affine:page',
AFFINE_DOC_REMOTE_SELECTION_WIDGET,
literal`${unsafeStatic(AFFINE_DOC_REMOTE_SELECTION_WIDGET)}`
);
export const edgelessRemoteSelectionWidget = WidgetViewExtension(
'affine:page',
AFFINE_EDGELESS_REMOTE_SELECTION_WIDGET,
literal`${unsafeStatic(AFFINE_EDGELESS_REMOTE_SELECTION_WIDGET)}`
);
@@ -0,0 +1,36 @@
class RandomPicker<T> {
private _copyArray: T[];
private readonly _originalArray: T[];
constructor(array: T[]) {
this._originalArray = [...array];
this._copyArray = [...array];
}
private randomIndex(max: number): number {
return Math.floor(Math.random() * max);
}
pick(): T {
if (this._copyArray.length === 0) {
this._copyArray = [...this._originalArray];
}
const index = this.randomIndex(this._copyArray.length);
const item = this._copyArray[index];
this._copyArray.splice(index, 1);
return item;
}
}
export const multiPlayersColor = new RandomPicker([
'var(--affine-multi-players-purple)',
'var(--affine-multi-players-magenta)',
'var(--affine-multi-players-red)',
'var(--affine-multi-players-orange)',
'var(--affine-multi-players-green)',
'var(--affine-multi-players-blue)',
'var(--affine-multi-players-brown)',
'var(--affine-multi-players-grey)',
]);
@@ -0,0 +1,42 @@
import { EditPropsStore } from '@blocksuite/affine-shared/services';
import type { BlockStdScope } from '@blocksuite/block-std';
import { multiPlayersColor } from './color-picker';
export class RemoteColorManager {
private get awarenessStore() {
return this.std.store.awarenessStore;
}
constructor(readonly std: BlockStdScope) {
const sessionColor = this.std.get(EditPropsStore).getStorage('remoteColor');
if (sessionColor) {
this.awarenessStore.awareness.setLocalStateField('color', sessionColor);
return;
}
const pickColor = multiPlayersColor.pick();
this.awarenessStore.awareness.setLocalStateField('color', pickColor);
this.std.get(EditPropsStore).setStorage('remoteColor', pickColor);
}
get(id: number) {
const awarenessColor = this.awarenessStore.getStates().get(id)?.color;
if (awarenessColor) {
return awarenessColor;
}
if (id !== this.awarenessStore.awareness.clientID) return null;
const sessionColor = this.std.get(EditPropsStore).getStorage('remoteColor');
if (sessionColor) {
this.awarenessStore.awareness.setLocalStateField('color', sessionColor);
return sessionColor;
}
const pickColor = multiPlayersColor.pick();
this.awarenessStore.awareness.setLocalStateField('color', pickColor);
this.std.get(EditPropsStore).setStorage('remoteColor', pickColor);
return pickColor;
}
}
@@ -0,0 +1,17 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src"],
"references": [
{ "path": "../../blocks/block-surface" },
{ "path": "../../components" },
{ "path": "../../model" },
{ "path": "../../shared" },
{ "path": "../../../framework/block-std" },
{ "path": "../../../framework/global" }
]
}
@@ -0,0 +1,35 @@
{
"name": "@blocksuite/affine-widget-scroll-anchoring",
"description": "Affine scroll anchoring widget.",
"type": "module",
"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": "MIT",
"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.1.12",
"lit": "^3.2.0"
},
"exports": {
".": "./src/index.ts",
"./effects": "./src/effects.ts"
},
"files": [
"src",
"dist",
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.20.0"
}
@@ -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;
}
}
@@ -0,0 +1,12 @@
import { WidgetViewExtension } from '@blocksuite/block-std';
import { literal, unsafeStatic } from 'lit/static-html.js';
import { AFFINE_SCROLL_ANCHORING_WIDGET } from './scroll-anchoring.js';
export * from './scroll-anchoring.js';
export const scrollAnchoringWidget = WidgetViewExtension(
'affine:page',
AFFINE_SCROLL_ANCHORING_WIDGET,
literal`${unsafeStatic(AFFINE_SCROLL_ANCHORING_WIDGET)}`
);
@@ -0,0 +1,262 @@
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/gfx';
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(HighlightSelection)
);
#getBoundsInEdgeless() {
const controller = this.std.get(GfxControllerIdentifier);
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.get(GfxControllerIdentifier);
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.store.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.store.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.get(GfxControllerIdentifier);
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>`;
}
}
@@ -0,0 +1,15 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src"],
"references": [
{ "path": "../../model" },
{ "path": "../../shared" },
{ "path": "../../../framework/block-std" },
{ "path": "../../../framework/global" }
]
}
@@ -0,0 +1 @@
# widget-slash-menu
@@ -0,0 +1,42 @@
{
"name": "@blocksuite/affine-widget-slash-menu",
"description": "Affine slash menu widget.",
"type": "module",
"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": "MIT",
"dependencies": {
"@blocksuite/affine-components": "workspace:*",
"@blocksuite/affine-rich-text": "workspace:*",
"@blocksuite/affine-shared": "workspace:*",
"@blocksuite/block-std": "workspace:*",
"@blocksuite/global": "workspace:*",
"@blocksuite/icons": "^2.2.4",
"@blocksuite/inline": "workspace:*",
"@blocksuite/store": "workspace:*",
"@floating-ui/dom": "^1.6.13",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.12",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.21"
},
"exports": {
".": "./src/index.ts",
"./effects": "./src/effects.ts"
},
"files": [
"src",
"dist",
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.20.0"
}
@@ -0,0 +1,179 @@
import { toast } from '@blocksuite/affine-components/toast';
import type { ParagraphBlockModel } from '@blocksuite/affine-model';
import { insertContent } from '@blocksuite/affine-rich-text';
import {
ArrowDownBigIcon,
ArrowUpBigIcon,
CopyIcon,
DeleteIcon,
DualLinkIcon,
NowIcon,
TodayIcon,
TomorrowIcon,
YesterdayIcon,
} from '@blocksuite/icons/lit';
import type { DeltaInsert } from '@blocksuite/inline';
import { Slice, Text } from '@blocksuite/store';
import { slashMenuToolTips } from './tooltips';
import type { SlashMenuConfig } from './types';
import { formatDate, formatTime } from './utils';
export const defaultSlashMenuConfig: SlashMenuConfig = {
items: () => {
const now = new Date();
const tomorrow = new Date();
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
tomorrow.setDate(tomorrow.getDate() + 1);
return [
{
name: 'Today',
icon: TodayIcon(),
tooltip: slashMenuToolTips['Today'],
description: formatDate(now),
group: '6_Date@0',
action: ({ std, model }) => {
insertContent(std.host, model, formatDate(now));
},
},
{
name: 'Tomorrow',
icon: TomorrowIcon(),
tooltip: slashMenuToolTips['Tomorrow'],
description: formatDate(tomorrow),
group: '6_Date@1',
action: ({ std, model }) => {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
insertContent(std.host, model, formatDate(tomorrow));
},
},
{
name: 'Yesterday',
icon: YesterdayIcon(),
tooltip: slashMenuToolTips['Yesterday'],
description: formatDate(yesterday),
group: '6_Date@2',
action: ({ std, model }) => {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
insertContent(std.host, model, formatDate(yesterday));
},
},
{
name: 'Now',
icon: NowIcon(),
tooltip: slashMenuToolTips['Now'],
description: formatTime(now),
group: '6_Date@3',
action: ({ std, model }) => {
insertContent(std.host, model, formatTime(now));
},
},
{
name: 'Move Up',
description: 'Shift this line up.',
icon: ArrowUpBigIcon(),
tooltip: slashMenuToolTips['Move Up'],
group: '8_Actions@0',
action: ({ std, model }) => {
const { host } = std;
const previousSiblingModel = host.doc.getPrev(model);
if (!previousSiblingModel) return;
const parentModel = host.doc.getParent(previousSiblingModel);
if (!parentModel) return;
host.doc.moveBlocks([model], parentModel, previousSiblingModel, true);
},
},
{
name: 'Move Down',
description: 'Shift this line down.',
icon: ArrowDownBigIcon(),
tooltip: slashMenuToolTips['Move Down'],
group: '8_Actions@1',
action: ({ std, model }) => {
const { host } = std;
const nextSiblingModel = host.doc.getNext(model);
if (!nextSiblingModel) return;
const parentModel = host.doc.getParent(nextSiblingModel);
if (!parentModel) return;
host.doc.moveBlocks([model], parentModel, nextSiblingModel, false);
},
},
{
name: 'Copy',
description: 'Copy this line to clipboard.',
icon: CopyIcon(),
tooltip: slashMenuToolTips['Copy'],
group: '8_Actions@2',
action: ({ std, model }) => {
const slice = Slice.fromModels(std.store, [model]);
std.clipboard
.copy(slice)
.then(() => {
toast(std.host, 'Copied to clipboard');
})
.catch(e => {
console.error(e);
});
},
},
{
name: 'Duplicate',
description: 'Create a duplicate of this line.',
icon: DualLinkIcon(),
tooltip: slashMenuToolTips['Copy'],
group: '8_Actions@3',
action: ({ std, model }) => {
if (!model.text || !(model.text instanceof Text)) {
console.error("Can't duplicate a block without text");
return;
}
const { host } = std;
const parent = host.doc.getParent(model);
if (!parent) {
console.error(
'Failed to duplicate block! Parent not found: ' +
model.id +
'|' +
model.flavour
);
return;
}
const index = parent.children.indexOf(model);
// TODO add clone model util
host.doc.addBlock(
model.flavour as never,
{
type: (model as ParagraphBlockModel).type,
text: new Text(model.text.toDelta() as DeltaInsert[]),
// @ts-expect-error FIXME: ts error
checked: model.checked,
},
host.doc.getParent(model),
index
);
},
},
{
name: 'Delete',
description: 'Remove this line permanently.',
searchAlias: ['remove'],
icon: DeleteIcon(),
tooltip: slashMenuToolTips['Delete'],
group: '8_Actions@4',
action: ({ std, model }) => {
std.host.doc.deleteBlock(model);
},
},
];
},
};
@@ -0,0 +1,4 @@
export const AFFINE_SLASH_MENU_WIDGET = 'affine-slash-menu-widget';
export const AFFINE_SLASH_MENU_TRIGGER_KEY = '/';
export const AFFINE_SLASH_MENU_TOOLTIP_TIMEOUT = 800;
export const AFFINE_SLASH_MENU_MAX_HEIGHT = 334;
@@ -0,0 +1,15 @@
import { AFFINE_SLASH_MENU_WIDGET } from './consts';
import { InnerSlashMenu, SlashMenu } from './slash-menu-popover';
import { AffineSlashMenuWidget } from './widget';
export function effects() {
customElements.define(AFFINE_SLASH_MENU_WIDGET, AffineSlashMenuWidget);
customElements.define('affine-slash-menu', SlashMenu);
customElements.define('inner-slash-menu', InnerSlashMenu);
}
declare global {
interface HTMLElementTagNameMap {
[AFFINE_SLASH_MENU_WIDGET]: AffineSlashMenuWidget;
}
}
@@ -0,0 +1,51 @@
import {
type BlockStdScope,
StdIdentifier,
WidgetViewExtension,
} from '@blocksuite/block-std';
import { type Container, createIdentifier } from '@blocksuite/global/di';
import { Extension, type ExtensionType } from '@blocksuite/store';
import { literal, unsafeStatic } from 'lit/static-html.js';
import { defaultSlashMenuConfig } from './config';
import { AFFINE_SLASH_MENU_WIDGET } from './consts';
import type { SlashMenuConfig } from './types';
import { mergeSlashMenuConfigs } from './utils';
export class SlashMenuExtension extends Extension {
config: SlashMenuConfig;
static override setup(di: Container) {
WidgetViewExtension(
'affine:page',
AFFINE_SLASH_MENU_WIDGET,
literal`${unsafeStatic(AFFINE_SLASH_MENU_WIDGET)}`
).setup(di);
di.add(this, [StdIdentifier]);
SlashMenuConfigExtension('default', defaultSlashMenuConfig).setup(di);
}
constructor(readonly std: BlockStdScope) {
super();
this.config = mergeSlashMenuConfigs(
this.std.provider.getAll(SlashMenuConfigIdentifier)
);
}
}
export const SlashMenuConfigIdentifier = createIdentifier<SlashMenuConfig>(
`${AFFINE_SLASH_MENU_WIDGET}-config`
);
export function SlashMenuConfigExtension(
id: string,
config: SlashMenuConfig
): ExtensionType {
return {
setup: di => {
di.addImpl(SlashMenuConfigIdentifier(id), config);
},
};
}
@@ -0,0 +1,3 @@
export { AFFINE_SLASH_MENU_WIDGET } from './consts';
export * from './extensions';
export * from './types';
@@ -0,0 +1,620 @@
import { createLitPortal } from '@blocksuite/affine-components/portal';
import type { AffineInlineEditor } from '@blocksuite/affine-rich-text';
import {
cleanSpecifiedTail,
getInlineEditorByModel,
getTextContentFromInlineRange,
} from '@blocksuite/affine-rich-text';
import {
createKeydownObserver,
getCurrentNativeRange,
getPopperPosition,
isControlledKeyboardEvent,
isFuzzyMatch,
substringMatchScore,
} from '@blocksuite/affine-shared/utils';
import { WithDisposable } from '@blocksuite/global/lit';
import { ArrowDownSmallIcon } from '@blocksuite/icons/lit';
import { autoPlacement, offset } from '@floating-ui/dom';
import { html, LitElement, nothing, type PropertyValues } from 'lit';
import { property, state } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { styleMap } from 'lit/directives/style-map.js';
import { when } from 'lit/directives/when.js';
import groupBy from 'lodash-es/groupBy';
import throttle from 'lodash-es/throttle';
import {
AFFINE_SLASH_MENU_MAX_HEIGHT,
AFFINE_SLASH_MENU_TOOLTIP_TIMEOUT,
AFFINE_SLASH_MENU_TRIGGER_KEY,
} from './consts.js';
import { slashItemToolTipStyle, styles } from './styles.js';
import type {
SlashMenuActionItem,
SlashMenuContext,
SlashMenuItem,
SlashMenuSubMenu,
} from './types.js';
import {
isActionItem,
isSubMenuItem,
parseGroup,
slashItemClassName,
} from './utils.js';
type InnerSlashMenuContext = SlashMenuContext & {
onClickItem: (item: SlashMenuActionItem) => void;
searching: boolean;
};
export class SlashMenu extends WithDisposable(LitElement) {
static override styles = styles;
private readonly _handleClickItem = (item: SlashMenuActionItem) => {
// Need to remove the search string
// We must to do clean the slash string before we do the action
// Otherwise, the action may change the model and cause the slash string to be changed
cleanSpecifiedTail(
this.host,
this.context.model,
AFFINE_SLASH_MENU_TRIGGER_KEY + (this._query || '')
);
this.inlineEditor
.waitForUpdate()
.then(() => {
item.action(this.context);
this.abortController.abort();
})
.catch(console.error);
};
private readonly _initItemPathMap = () => {
const traverse = (item: SlashMenuItem, path: number[]) => {
this._itemPathMap.set(item, [...path]);
if (isSubMenuItem(item)) {
item.subMenu.forEach((subItem, index) =>
traverse(subItem, [...path, index])
);
}
};
this.items.forEach((item, index) => traverse(item, [index]));
};
private _innerSlashMenuContext!: InnerSlashMenuContext;
private readonly _itemPathMap = new Map<SlashMenuItem, number[]>();
private _queryState: 'off' | 'on' | 'no_result' = 'off';
private readonly _startRange = this.inlineEditor.getInlineRange();
private readonly _updateFilteredItems = () => {
const query = this._query;
if (query === null) {
this.abortController.abort();
return;
}
this._filteredItems = [];
const searchStr = query.toLowerCase();
if (searchStr === '' || searchStr.endsWith(' ')) {
this._queryState = searchStr === '' ? 'off' : 'no_result';
this._innerSlashMenuContext.searching = false;
return;
}
// Layer order traversal
let depth = 0;
let queue = this.items;
while (queue.length !== 0) {
// remove the sub menu item from the previous layer result
this._filteredItems = this._filteredItems.filter(
item => !isSubMenuItem(item)
);
this._filteredItems = this._filteredItems.concat(
queue.filter(({ name, searchAlias = [] }) =>
[name, ...searchAlias].some(str => isFuzzyMatch(str, searchStr))
)
);
// We search first and second layer
if (this._filteredItems.length !== 0 && depth >= 1) break;
queue = queue
.map<typeof queue>(item => {
if (isSubMenuItem(item)) {
return item.subMenu;
} else {
return [];
}
})
.flat();
depth++;
}
this._filteredItems.sort((a, b) => {
return -(
substringMatchScore(a.name, searchStr) -
substringMatchScore(b.name, searchStr)
);
});
this._queryState = this._filteredItems.length === 0 ? 'no_result' : 'on';
this._innerSlashMenuContext.searching = true;
};
private get _query() {
return getTextContentFromInlineRange(this.inlineEditor, this._startRange);
}
get host() {
return this.context.std.host;
}
constructor(
private readonly inlineEditor: AffineInlineEditor,
private readonly abortController = new AbortController()
) {
super();
}
override connectedCallback() {
super.connectedCallback();
this._innerSlashMenuContext = {
...this.context,
onClickItem: this._handleClickItem,
searching: false,
};
this._initItemPathMap();
this._disposables.addFromEvent(this, 'mousedown', e => {
// Prevent input from losing focus
e.preventDefault();
});
const inlineEditor = this.inlineEditor;
if (!inlineEditor || !inlineEditor.eventSource) {
console.error('inlineEditor or eventSource is not found');
return;
}
/**
* Handle arrow key
*
* The slash menu will be closed in the following keyboard cases:
* - Press the space key
* - Press the backspace key and the search string is empty
* - Press the escape key
* - When the search item is empty, the slash menu will be hidden temporarily,
* and if the following key is not the backspace key, the slash menu will be closed
*/
createKeydownObserver({
target: inlineEditor.eventSource,
signal: this.abortController.signal,
interceptor: (event, next) => {
const { key, isComposing, code } = event;
if (key === AFFINE_SLASH_MENU_TRIGGER_KEY) {
// Can not stopPropagation here,
// otherwise the rich text will not be able to trigger a new the slash menu
return;
}
if (key === 'Process' && !isComposing && code === 'Slash') {
// The IME case of above
return;
}
if (key !== 'Backspace' && this._queryState === 'no_result') {
// if the following key is not the backspace key,
// the slash menu will be closed
this.abortController.abort();
return;
}
if (key === 'ArrowRight' || key === 'ArrowLeft' || key === 'Escape') {
return;
}
next();
},
onInput: isComposition => {
if (isComposition) {
this._updateFilteredItems();
} else {
this.inlineEditor.slots.renderComplete.once(
this._updateFilteredItems
);
}
},
onPaste: () => {
setTimeout(() => {
this._updateFilteredItems();
}, 50);
},
onDelete: () => {
const curRange = this.inlineEditor.getInlineRange();
if (!this._startRange || !curRange) {
return;
}
if (curRange.index < this._startRange.index) {
this.abortController.abort();
}
this.inlineEditor.slots.renderComplete.once(this._updateFilteredItems);
},
onAbort: () => this.abortController.abort(),
});
}
protected override willUpdate() {
if (!this.hasUpdated) {
const currRage = getCurrentNativeRange();
if (!currRage) {
this.abortController.abort();
return;
}
// Handle position
const updatePosition = throttle(() => {
this._position = getPopperPosition(this, currRage);
}, 10);
this.disposables.addFromEvent(window, 'resize', updatePosition);
updatePosition();
}
}
override render() {
const slashMenuStyles = this._position
? {
transform: `translate(${this._position.x}, ${this._position.y})`,
maxHeight: `${Math.min(this._position.height, AFFINE_SLASH_MENU_MAX_HEIGHT)}px`,
}
: {
visibility: 'hidden',
};
return html`${this._queryState !== 'no_result'
? html` <div
class="overlay-mask"
@click="${() => this.abortController.abort()}"
></div>`
: nothing}
<inner-slash-menu
.context=${this._innerSlashMenuContext}
.menu=${this._queryState === 'off' ? this.items : this._filteredItems}
.mainMenuStyle=${slashMenuStyles}
.abortController=${this.abortController}
>
</inner-slash-menu>`;
}
@state()
private accessor _filteredItems: (SlashMenuActionItem | SlashMenuSubMenu)[] =
[];
@state()
private accessor _position: {
x: string;
y: string;
height: number;
} | null = null;
@property({ attribute: false })
accessor items!: SlashMenuItem[];
@property({ attribute: false })
accessor context!: SlashMenuContext;
}
export class InnerSlashMenu extends WithDisposable(LitElement) {
static override styles = styles;
private readonly _closeSubMenu = () => {
this._subMenuAbortController?.abort();
this._subMenuAbortController = null;
this._currentSubMenu = null;
};
private _currentSubMenu: SlashMenuSubMenu | null = null;
private readonly _openSubMenu = (item: SlashMenuSubMenu) => {
if (item === this._currentSubMenu) return;
const itemElement = this.shadowRoot?.querySelector(
`.${slashItemClassName(item)}`
);
if (!itemElement) return;
this._closeSubMenu();
this._currentSubMenu = item;
this._subMenuAbortController = new AbortController();
this._subMenuAbortController.signal.addEventListener('abort', () => {
this._closeSubMenu();
});
const subMenuElement = createLitPortal({
shadowDom: false,
template: html`<inner-slash-menu
.context=${this.context}
.menu=${item.subMenu}
.depth=${this.depth + 1}
.abortController=${this._subMenuAbortController}
>
${item.subMenu.map(this._renderItem)}
</inner-slash-menu>`,
computePosition: {
referenceElement: itemElement,
autoUpdate: true,
middleware: [
offset(12),
autoPlacement({
allowedPlacements: ['right-start', 'right-end'],
}),
],
},
abortController: this._subMenuAbortController,
});
subMenuElement.style.zIndex = `calc(var(--affine-z-index-popover) + ${this.depth})`;
subMenuElement.focus();
};
private readonly _renderActionItem = (item: SlashMenuActionItem) => {
const { name, icon, description, tooltip } = item;
const hover = item === this._activeItem;
return html`<icon-button
class="slash-menu-item ${slashItemClassName(item)}"
width="100%"
height="44px"
text=${name}
subText=${ifDefined(description)}
data-testid="${name}"
hover=${hover}
@mousemove=${() => {
this._activeItem = item;
this._closeSubMenu();
}}
@click=${() => this.context.onClickItem(item)}
>
${icon && html`<div class="slash-menu-item-icon">${icon}</div>`}
${tooltip &&
html`<affine-tooltip
tip-position="right"
.offset=${22}
.tooltipStyle=${slashItemToolTipStyle}
.hoverOptions=${{
enterDelay: AFFINE_SLASH_MENU_TOOLTIP_TIMEOUT,
allowMultiple: false,
}}
>
<div class="tooltip-figure">${tooltip.figure}</div>
<div class="tooltip-caption">${tooltip.caption}</div>
</affine-tooltip>`}
</icon-button>`;
};
private readonly _renderGroup = (
groupName: string,
items: SlashMenuItem[]
) => {
return html`<div class="slash-menu-group">
${when(
!this.context.searching,
() => html`<div class="slash-menu-group-name">${groupName}</div>`
)}
${items.map(this._renderItem)}
</div>`;
};
private readonly _renderItem = (item: SlashMenuItem) => {
if (isActionItem(item)) return this._renderActionItem(item);
if (isSubMenuItem(item)) return this._renderSubMenuItem(item);
return nothing;
};
private readonly _renderSubMenuItem = (item: SlashMenuSubMenu) => {
const { name, icon, description } = item;
const hover = item === this._activeItem;
return html`<icon-button
class="slash-menu-item ${slashItemClassName(item)}"
width="100%"
height="44px"
text=${name}
subText=${ifDefined(description)}
data-testid="${name}"
hover=${hover}
@mousemove=${() => {
this._activeItem = item;
this._openSubMenu(item);
}}
@touchstart=${() => {
isSubMenuItem(item) &&
(this._currentSubMenu === item
? this._closeSubMenu()
: this._openSubMenu(item));
}}
>
${icon && html`<div class="slash-menu-item-icon">${icon}</div>`}
<div slot="suffix" style="transform: rotate(-90deg);">
${ArrowDownSmallIcon()}
</div>
</icon-button>`;
};
private _subMenuAbortController: AbortController | null = null;
private _scrollToItem(item: SlashMenuItem) {
const shadowRoot = this.shadowRoot;
if (!shadowRoot) {
return;
}
const ele = shadowRoot.querySelector(`icon-button[text="${item.name}"]`);
if (!ele) {
return;
}
ele.scrollIntoView({
block: 'nearest',
});
}
override connectedCallback() {
super.connectedCallback();
// close all sub menus
this.abortController?.signal?.addEventListener('abort', () => {
this._subMenuAbortController?.abort();
});
this.addEventListener('wheel', event => {
if (this._currentSubMenu) {
event.preventDefault();
}
});
const inlineEditor = getInlineEditorByModel(
this.context.std.host,
this.context.model
);
if (!inlineEditor || !inlineEditor.eventSource) {
console.error('inlineEditor or eventSource is not found');
return;
}
inlineEditor.eventSource.addEventListener(
'keydown',
event => {
if (this._currentSubMenu) return;
if (event.isComposing) return;
const { key, ctrlKey, metaKey, altKey, shiftKey } = event;
const onlyCmd = (ctrlKey || metaKey) && !altKey && !shiftKey;
const onlyShift = shiftKey && !isControlledKeyboardEvent(event);
const notControlShift = !(ctrlKey || metaKey || altKey || shiftKey);
let moveStep = 0;
if (
(key === 'ArrowUp' && notControlShift) ||
(key === 'Tab' && onlyShift) ||
(key === 'P' && onlyCmd) ||
(key === 'p' && onlyCmd)
) {
moveStep = -1;
}
if (
(key === 'ArrowDown' && notControlShift) ||
(key === 'Tab' && notControlShift) ||
(key === 'n' && onlyCmd) ||
(key === 'N' && onlyCmd)
) {
moveStep = 1;
}
if (moveStep !== 0) {
const activeItemIndex = this.menu.indexOf(this._activeItem);
const itemIndex =
(activeItemIndex + moveStep + this.menu.length) % this.menu.length;
this._activeItem = this.menu[itemIndex] as typeof this._activeItem;
this._scrollToItem(this._activeItem);
event.preventDefault();
event.stopPropagation();
}
if (key === 'ArrowRight' && notControlShift) {
if (isSubMenuItem(this._activeItem)) {
this._openSubMenu(this._activeItem);
}
event.preventDefault();
event.stopPropagation();
}
if ((key === 'ArrowLeft' || key === 'Escape') && notControlShift) {
this.abortController.abort();
event.preventDefault();
event.stopPropagation();
}
if (key === 'Enter' && notControlShift) {
if (isSubMenuItem(this._activeItem)) {
this._openSubMenu(this._activeItem);
} else if (isActionItem(this._activeItem)) {
this.context.onClickItem(this._activeItem);
}
event.preventDefault();
event.stopPropagation();
}
},
{
capture: true,
signal: this.abortController.signal,
}
);
}
override disconnectedCallback() {
this.abortController.abort();
}
override render() {
if (this.menu.length === 0) return nothing;
const style = styleMap(this.mainMenuStyle ?? { position: 'relative' });
const groups = groupBy(this.menu, ({ group }) =>
group && !this.context.searching ? parseGroup(group)[1] : ''
);
return html`<div
class="slash-menu"
style=${style}
data-testid=${`sub-menu-${this.depth}`}
>
${Object.entries(groups).map(([groupName, items]) =>
this._renderGroup(groupName, items)
)}
</div>`;
}
override willUpdate(changedProperties: PropertyValues<this>) {
if (changedProperties.has('menu') && this.menu.length !== 0) {
this._activeItem = this.menu[0];
// this case happen on query updated
this._subMenuAbortController?.abort();
}
}
@state()
private accessor _activeItem!: SlashMenuActionItem | SlashMenuSubMenu;
@property({ attribute: false })
accessor abortController!: AbortController;
@property({ attribute: false })
accessor context!: InnerSlashMenuContext;
@property({ attribute: false })
accessor depth: number = 0;
@property({ attribute: false })
accessor mainMenuStyle: Parameters<typeof styleMap>[0] | null = null;
@property({ attribute: false })
accessor menu!: SlashMenuItem[];
}
@@ -0,0 +1,109 @@
import { scrollbarStyle } from '@blocksuite/affine-shared/styles';
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { baseTheme } from '@toeverything/theme';
import { css, unsafeCSS } from 'lit';
export const styles = css`
.overlay-mask {
pointer-events: auto;
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: var(--affine-z-index-popover);
}
.slash-menu {
position: fixed;
left: 0;
top: 0;
box-sizing: border-box;
padding: 8px 4px 8px 8px;
width: 258px;
overflow-y: auto;
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
background: ${unsafeCSSVarV2('layer/background/overlayPanel')};
box-shadow: ${unsafeCSSVar('overlayPanelShadow')};
border-radius: 8px;
z-index: var(--affine-z-index-popover);
user-select: none;
/* transition: max-height 0.2s ease-in-out; */
}
${scrollbarStyle('.slash-menu')}
.slash-menu-group-name {
box-sizing: border-box;
padding: 2px 8px;
font-size: var(--affine-font-xs);
font-weight: 500;
line-height: var(--affine-line-height);
text-align: left;
color: var(
--light-textColor-textSecondaryColor,
var(--textColor-textSecondaryColor, #8e8d91)
);
}
.slash-menu-item {
padding: 2px 8px 2px 8px;
justify-content: flex-start;
gap: 10px;
}
.slash-menu-item-icon {
box-sizing: border-box;
width: 28px;
height: 28px;
padding: 4px;
border: 1px solid var(--affine-border-color, #e3e2e4);
border-radius: 4px;
color: var(--affine-icon-color);
background: ${unsafeCSSVarV2('layer/background/overlayPanel')};
display: flex;
justify-content: center;
align-items: center;
}
.slash-menu-item-icon svg {
display: block;
width: 100%;
height: 100%;
}
.slash-menu-item.ask-ai {
color: var(--affine-brand-color);
}
.slash-menu-item.github .github-icon {
color: var(--affine-black);
}
`;
export const slashItemToolTipStyle = css`
.affine-tooltip {
display: flex;
padding: 4px 4px 2px 4px;
flex-direction: column;
align-items: flex-start;
gap: 3px;
}
.tooltip-figure svg {
display: block;
}
.tooltip-caption {
padding-left: 4px;
color: var(
--light-textColor-textSecondaryColor,
var(--textColor-textSecondaryColor, #8e8d91)
);
font-family: var(--affine-font-family);
font-size: var(--affine-font-xs);
line-height: var(--affine-line-height);
}
`;
@@ -0,0 +1,13 @@
import { html } from 'lit';
// prettier-ignore
export const CopyTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="170" height="68" rx="2" fill="white"/>
<mask id="mask0_16460_1240" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68">
<rect width="170" height="68" rx="2" fill="white"/>
</mask>
<g mask="url(#mask0_16460_1240)">
<path d="M4 7C4 5.89543 4.89543 5 6 5H172V32H6C4.89543 32 4 31.1046 4 30V7Z" fill="#F4F4F5"/>
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="15.6364">In a decentralized system, we can have a kaleidoscopic </tspan><tspan x="8" y="27.6364">complexity to our data.&#10;</tspan><tspan x="8" y="43.6364">Any user may have a different perspective on what data they </tspan><tspan x="8" y="55.6364">either have, choose to share, or accept.&#10;</tspan><tspan x="8" y="71.6364">For example, one user&#x2019;s edits to a document might be on </tspan><tspan x="8" y="83.6364">their laptop on an airplane; when the plane lands and the </tspan><tspan x="8" y="95.6364">computer reconnects, those changes are distributed to </tspan><tspan x="8" y="107.636">other users.&#10;</tspan><tspan x="8" y="123.636">Other users might choose to accept all, some, or none of </tspan><tspan x="8" y="135.636">those changes to their version of the document.</tspan></text>
</g>
</svg>
`;
@@ -0,0 +1,14 @@
import { html } from 'lit';
// prettier-ignore
export const DeleteTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="170" height="68" rx="2" fill="white"/>
<mask id="mask0_16460_1246" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68">
<rect width="170" height="68" rx="2" fill="white"/>
</mask>
<g mask="url(#mask0_16460_1246)">
<path d="M4 7C4 5.89543 4.89543 5 6 5H172V32H6C4.89543 32 4 31.1046 4 30V7Z" fill="#FDECEB"/>
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="43.6364">Any user may have a different perspective on what data they </tspan><tspan x="8" y="55.6364">either have, choose to share, or accept.&#10;</tspan><tspan x="8" y="71.6364">For example, one user&#x2019;s edits to a document might be on </tspan><tspan x="8" y="83.6364">their laptop on an airplane; when the plane lands and the </tspan><tspan x="8" y="95.6364">computer reconnects, those changes are distributed to </tspan><tspan x="8" y="107.636">other users.&#10;</tspan><tspan x="8" y="123.636">Other users might choose to accept all, some, or none of </tspan><tspan x="8" y="135.636">those changes to their version of the document.</tspan></text>
<text fill="#EB4335" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="15.6364">In a decentralized system, we can have a kaleidoscopic </tspan><tspan x="8" y="27.6364">complexity to our data.&#10;</tspan></text>
</g>
</svg>
`;
@@ -0,0 +1,11 @@
import { html } from 'lit';
// prettier-ignore
export const EmptyTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="170" height="68" rx="2" fill="white"/>
<mask id="mask0_16460_864" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68">
<rect width="170" height="68" rx="2" fill="white"/>
</mask>
<g mask="url(#mask0_16460_864)">
</g>
</svg>
`;
@@ -0,0 +1,51 @@
import type { SlashMenuTooltip } from '../types';
import { CopyTooltip } from './copy';
import { DeleteTooltip } from './delete';
import { MoveDownTooltip } from './move-down';
import { MoveUpTooltip } from './move-up';
import { NowTooltip } from './now';
import { TodayTooltip } from './today';
import { TomorrowTooltip } from './tomorrow';
import { YesterdayTooltip } from './yesterday';
export const slashMenuToolTips: Record<string, SlashMenuTooltip> = {
Today: {
figure: TodayTooltip,
caption: 'Today',
},
Tomorrow: {
figure: TomorrowTooltip,
caption: 'Tomorrow',
},
Yesterday: {
figure: YesterdayTooltip,
caption: 'Yesterday',
},
Now: {
figure: NowTooltip,
caption: 'Now',
},
'Move Up': {
figure: MoveUpTooltip,
caption: 'Move Up',
},
'Move Down': {
figure: MoveDownTooltip,
caption: 'Move Down',
},
Copy: {
figure: CopyTooltip,
caption: 'Copy / Duplicate',
},
Delete: {
figure: DeleteTooltip,
caption: 'Delete',
},
};
@@ -0,0 +1,21 @@
import { html } from 'lit';
// prettier-ignore
export const MoveDownTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="170" height="68" rx="2" fill="white"/>
<mask id="mask0_16460_1234" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68">
<rect width="170" height="68" rx="2" fill="white"/>
</mask>
<g mask="url(#mask0_16460_1234)">
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="15.6364">In a decentralized system, we can have a kaleidoscopic </tspan><tspan x="8" y="27.6364">complexity to our data.&#10;</tspan></text>
<text fill="#A9A9AD" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="43.6364">Any user may have a different perspective on what data they </tspan><tspan x="8" y="55.6364">either have, choose to share, or accept.&#10;</tspan><tspan x="8" y="71.6364">For example, one user&#x2019;s edits to a document might be on </tspan><tspan x="8" y="83.6364">their laptop on an airplane; when the plane lands and the </tspan><tspan x="8" y="95.6364">computer reconnects, those changes are distributed to </tspan><tspan x="8" y="107.636">other users.&#10;</tspan><tspan x="8" y="123.636">Other users might choose to accept all, some, or none of </tspan><tspan x="8" y="135.636">those changes to their version of the document.</tspan></text>
</g>
<g clip-path="url(#clip0_16460_1234)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M25.5073 51.0032C25.2022 50.723 24.7278 50.7432 24.4476 51.0483L20.75 55.0745L20.75 43C20.75 42.5858 20.4142 42.25 20 42.25C19.5858 42.25 19.25 42.5858 19.25 43L19.25 55.0745L15.5524 51.0483C15.2722 50.7432 14.7978 50.723 14.4927 51.0032C14.1876 51.2833 14.1674 51.7578 14.4476 52.0629L19.4476 57.5073C19.5896 57.662 19.79 57.75 20 57.75C20.21 57.75 20.4104 57.662 20.5524 57.5073L25.5524 52.0629C25.8326 51.7578 25.8124 51.2833 25.5073 51.0032Z" fill="#121212"/>
</g>
<defs>
<clipPath id="clip0_16460_1234">
<rect width="24" height="24" fill="white" transform="translate(8 38)"/>
</clipPath>
</defs>
</svg>
`;
@@ -0,0 +1,21 @@
import { html } from 'lit';
// prettier-ignore
export const MoveUpTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="170" height="68" rx="2" fill="white"/>
<mask id="mask0_16460_1228" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68">
<rect width="170" height="68" rx="2" fill="white"/>
</mask>
<g mask="url(#mask0_16460_1228)">
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="43.6364">Any user may have a different perspective on what data they </tspan><tspan x="8" y="55.6364">either have, choose to share, or accept.&#10;</tspan><tspan x="8" y="71.6364">For example, one user&#x2019;s edits to a document might be on </tspan><tspan x="8" y="83.6364">their laptop on an airplane; when the plane lands and the </tspan><tspan x="8" y="95.6364">computer reconnects, those changes are distributed to </tspan><tspan x="8" y="107.636">other users.&#10;</tspan><tspan x="8" y="123.636">Other users might choose to accept all, some, or none of </tspan><tspan x="8" y="135.636">those changes to their version of the document.</tspan></text>
<text fill="#A9A9AD" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="15.6364">In a decentralized system, we can have a kaleidoscopic </tspan><tspan x="8" y="27.6364">complexity to our data.&#10;</tspan></text>
</g>
<g clip-path="url(#clip0_16460_1228)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M25.5073 16.9968C25.2022 17.277 24.7278 17.2568 24.4476 16.9517L20.75 12.9255L20.75 25C20.75 25.4142 20.4142 25.75 20 25.75C19.5858 25.75 19.25 25.4142 19.25 25L19.25 12.9255L15.5524 16.9517C15.2722 17.2568 14.7978 17.277 14.4927 16.9968C14.1876 16.7167 14.1674 16.2422 14.4476 15.9371L19.4476 10.4927C19.5896 10.338 19.79 10.25 20 10.25C20.21 10.25 20.4104 10.338 20.5524 10.4927L25.5524 15.9371C25.8326 16.2422 25.8124 16.7167 25.5073 16.9968Z" fill="#121212"/>
</g>
<defs>
<clipPath id="clip0_16460_1228">
<rect width="24" height="24" fill="white" transform="translate(8 6)"/>
</clipPath>
</defs>
</svg>
`;
@@ -0,0 +1,14 @@
import { html } from 'lit';
// prettier-ignore
export const NowTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="170" height="68" rx="2" fill="white"/>
<mask id="mask0_16460_1143" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68">
<rect width="170" height="68" rx="2" fill="white"/>
</mask>
<g mask="url(#mask0_16460_1143)">
<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="38.0488" y="16.6364">now</tspan><tspan x="81.8574" y="16.6364"> and time.&#10;</tspan></text>
<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="16.6364">Insert </tspan><tspan x="57.8047" y="16.6364"> date</tspan></text>
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="34.6364">11:45:14 Wed 3 Aug, 2022</tspan></text>
</g>
</svg>
`;
@@ -0,0 +1,14 @@
import { html } from 'lit';
// prettier-ignore
export const TodayTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="170" height="68" rx="2" fill="white"/>
<mask id="mask0_16460_1128" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68">
<rect width="170" height="68" rx="2" fill="white"/>
</mask>
<g mask="url(#mask0_16460_1128)">
<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="95.5098" y="16.6364">.&#10;</tspan></text>
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="34.6364">Wed 3 Aug, 2022</tspan></text>
<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="16.6364">Insert today&#x2019;s date</tspan></text>
</g>
</svg>
`;
@@ -0,0 +1,14 @@
import { html } from 'lit';
// prettier-ignore
export const TomorrowTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="170" height="68" rx="2" fill="white"/>
<mask id="mask0_16460_1133" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68">
<rect width="170" height="68" rx="2" fill="white"/>
</mask>
<g mask="url(#mask0_16460_1133)">
<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="38.0488" y="16.6364">tomorrow&#x2019;s</tspan><tspan x="114.211" y="16.6364">.&#10;</tspan></text>
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="34.6364">Wed 3 Aug, 2022</tspan></text>
<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="16.6364">Insert </tspan><tspan x="90.1582" y="16.6364"> date</tspan></text>
</g>
</svg>
`;
@@ -0,0 +1,14 @@
import { html } from 'lit';
// prettier-ignore
export const YesterdayTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="170" height="68" rx="2" fill="white"/>
<mask id="mask0_16460_1138" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68">
<rect width="170" height="68" rx="2" fill="white"/>
</mask>
<g mask="url(#mask0_16460_1138)">
<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="38.0488" y="16.6364">yesterday&#x2019;s</tspan><tspan x="115.334" y="16.6364">.&#10;</tspan></text>
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="34.6364">Wed 3 Aug, 2022</tspan></text>
<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="16.6364">Insert </tspan><tspan x="91.2812" y="16.6364"> date</tspan></text>
</g>
</svg>
`;
@@ -0,0 +1,59 @@
import type { BlockStdScope } from '@blocksuite/block-std';
import type { BlockModel } from '@blocksuite/store';
import type { TemplateResult } from 'lit';
export type SlashMenuContext = {
std: BlockStdScope;
model: BlockModel;
};
export type SlashMenuTooltip = {
figure: TemplateResult;
caption: string;
};
type SlashMenuItemBase = {
name: string;
description?: string;
icon?: TemplateResult;
/**
* This field defines sorting and grouping of menu items like VSCode.
* The first number indicates the group index, the second number indicates the item index in the group.
* The group name is the string between `_` and `@`.
* You can find an example figure in https://code.visualstudio.com/api/references/contribution-points#menu-example
*/
group?: `${number}_${string}@${number}`;
searchAlias?: string[];
/**
* The condition to show the menu item.
*/
when?: (ctx: SlashMenuContext) => boolean;
};
export type SlashMenuActionItem = SlashMenuItemBase & {
action: (ctx: SlashMenuContext) => void;
tooltip?: SlashMenuTooltip;
/**
* The alias of the menu item for search.
*/
searchAlias?: string[];
};
export type SlashMenuSubMenu = SlashMenuItemBase & {
subMenu: SlashMenuItem[];
};
export type SlashMenuItem = SlashMenuActionItem | SlashMenuSubMenu;
export type SlashMenuConfig = {
/**
* The items in the slash menu. It can be generated dynamically with the context.
*/
items: SlashMenuItem[] | ((ctx: SlashMenuContext) => SlashMenuItem[]);
/**
* Slash menu will not be triggered when the condition is true.
*/
disableWhen?: (ctx: SlashMenuContext) => boolean;
};
@@ -0,0 +1,105 @@
import type {
SlashMenuActionItem,
SlashMenuConfig,
SlashMenuContext,
SlashMenuItem,
SlashMenuSubMenu,
} from './types';
export function isActionItem(item: SlashMenuItem): item is SlashMenuActionItem {
return 'action' in item;
}
export function isSubMenuItem(item: SlashMenuItem): item is SlashMenuSubMenu {
return 'subMenu' in item;
}
export function slashItemClassName({ name }: SlashMenuItem) {
return name.split(' ').join('-').toLocaleLowerCase();
}
export function parseGroup(group: NonNullable<SlashMenuItem['group']>) {
return [
parseInt(group.split('_')[0]),
group.split('_')[1].split('@')[0],
parseInt(group.split('@')[1]),
] as const;
}
function itemCompareFn(a: SlashMenuItem, b: SlashMenuItem) {
if (a.group === undefined && b.group === undefined) return 0;
if (a.group === undefined) return -1;
if (b.group === undefined) return 1;
const [aGroupIndex, aGroupName, aItemIndex] = parseGroup(a.group);
const [bGroupIndex, bGroupName, bItemIndex] = parseGroup(b.group);
if (isNaN(aGroupIndex)) return -1;
if (isNaN(bGroupIndex)) return 1;
if (aGroupIndex < bGroupIndex) return -1;
if (aGroupIndex > bGroupIndex) return 1;
if (aGroupName !== bGroupName) return aGroupName.localeCompare(bGroupName);
if (isNaN(aItemIndex)) return -1;
if (isNaN(bItemIndex)) return 1;
return aItemIndex - bItemIndex;
}
export function buildSlashMenuItems(
items: SlashMenuItem[],
context: SlashMenuContext,
transform?: (item: SlashMenuItem) => SlashMenuItem
): SlashMenuItem[] {
if (transform) items = items.map(transform);
const result = items
.filter(item => (item.when ? item.when(context) : true))
.sort(itemCompareFn)
.map(item => {
if (isSubMenuItem(item)) {
return {
...item,
subMenu: buildSlashMenuItems(item.subMenu, context),
};
} else {
return { ...item };
}
});
return result;
}
export function mergeSlashMenuConfigs(
configs: Map<string, SlashMenuConfig>
): SlashMenuConfig {
return {
items: ctx =>
Array.from(configs.values()).flatMap(({ items }) =>
typeof items === 'function' ? items(ctx) : items
),
disableWhen: ctx =>
configs
.values()
.map(({ disableWhen }) => disableWhen?.(ctx) ?? false)
.some(Boolean),
};
}
export function formatDate(date: Date) {
// yyyy-mm-dd
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
const strTime = `${year}-${month}-${day}`;
return strTime;
}
export function formatTime(date: Date) {
// mm-dd hh:mm
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
const strTime = `${month}-${day} ${hours}:${minutes}`;
return strTime;
}
@@ -0,0 +1,181 @@
import {
type AffineInlineEditor,
getInlineEditorByModel,
} from '@blocksuite/affine-rich-text';
import type { UIEventStateContext } from '@blocksuite/block-std';
import { TextSelection, WidgetComponent } from '@blocksuite/block-std';
import { DisposableGroup } from '@blocksuite/global/slot';
import { InlineEditor } from '@blocksuite/inline';
import debounce from 'lodash-es/debounce';
import { AFFINE_SLASH_MENU_TRIGGER_KEY } from './consts';
import { SlashMenuExtension } from './extensions';
import { SlashMenu } from './slash-menu-popover';
import type { SlashMenuConfig, SlashMenuContext, SlashMenuItem } from './types';
import { buildSlashMenuItems } from './utils';
let globalAbortController = new AbortController();
function closeSlashMenu() {
globalAbortController.abort();
}
const showSlashMenu = debounce(
({
context,
config,
container = document.body,
abortController = new AbortController(),
configItemTransform,
}: {
context: SlashMenuContext;
config: SlashMenuConfig;
container?: HTMLElement;
abortController?: AbortController;
configItemTransform: (item: SlashMenuItem) => SlashMenuItem;
}) => {
globalAbortController = abortController;
const disposables = new DisposableGroup();
abortController.signal.addEventListener('abort', () =>
disposables.dispose()
);
const inlineEditor = getInlineEditorByModel(
context.std.host,
context.model
);
if (!inlineEditor) return;
const slashMenu = new SlashMenu(inlineEditor, abortController);
disposables.add(() => slashMenu.remove());
slashMenu.context = context;
slashMenu.items = buildSlashMenuItems(
typeof config.items === 'function' ? config.items(context) : config.items,
context,
configItemTransform
);
// FIXME(Flrande): It is not a best practice,
// but merely a temporary measure for reusing previous components.
// Mount
container.append(slashMenu);
return slashMenu;
},
100,
{ leading: true }
);
export class AffineSlashMenuWidget extends WidgetComponent {
private readonly _getInlineEditor = (
evt: KeyboardEvent | CompositionEvent
) => {
if (evt.target instanceof HTMLElement) {
const editor = (
evt.target.closest('.inline-editor') as {
inlineEditor?: AffineInlineEditor;
}
)?.inlineEditor;
if (editor instanceof InlineEditor) {
return editor;
}
}
const textSelection = this.host.selection.find(TextSelection);
if (!textSelection) return;
const model = this.host.doc.getBlock(textSelection.blockId)?.model;
if (!model) return;
return getInlineEditorByModel(this.host, model);
};
private readonly _handleInput = (
inlineEditor: InlineEditor,
isCompositionEnd: boolean
) => {
const inlineRangeApplyCallback = (callback: () => void) => {
// the inline ranged updated in compositionEnd event before this event callback
if (isCompositionEnd) callback();
else inlineEditor.slots.inlineRangeSync.once(callback);
};
if (this.block.model.flavour !== 'affine:page') {
console.error('SlashMenuWidget should be used in RootBlock');
return;
}
inlineRangeApplyCallback(() => {
const textSelection = this.host.selection.find(TextSelection);
if (!textSelection) return;
const block = this.host.view.getBlock(textSelection.blockId);
if (!block) return;
const model = block.model;
if (this.config.disableWhen?.({ model, std: this.std })) return;
const inlineRange = inlineEditor.getInlineRange();
if (!inlineRange) return;
const textPoint = inlineEditor.getTextPoint(inlineRange.index);
if (!textPoint) return;
const [leafStart, offsetStart] = textPoint;
const text = leafStart.textContent
? leafStart.textContent.slice(0, offsetStart)
: '';
if (!text.endsWith(AFFINE_SLASH_MENU_TRIGGER_KEY)) return;
closeSlashMenu();
showSlashMenu({
context: {
model,
std: this.std,
},
config: this.config,
configItemTransform: this.configItemTransform,
});
});
};
private readonly _onCompositionEnd = (ctx: UIEventStateContext) => {
const event = ctx.get('defaultState').event as CompositionEvent;
if (event.data !== AFFINE_SLASH_MENU_TRIGGER_KEY) return;
const inlineEditor = this._getInlineEditor(event);
if (!inlineEditor) return;
this._handleInput(inlineEditor, true);
};
private readonly _onKeyDown = (ctx: UIEventStateContext) => {
const eventState = ctx.get('keyboardState');
const event = eventState.raw;
const key = event.key;
if (event.isComposing || key !== AFFINE_SLASH_MENU_TRIGGER_KEY) return;
const inlineEditor = this._getInlineEditor(event);
if (!inlineEditor) return;
this._handleInput(inlineEditor, false);
};
get config() {
return this.std.get(SlashMenuExtension).config;
}
// TODO(@L-Sun): Remove this when moving each config item to corresponding blocks
// This is a temporary way for patching the slash menu config
configItemTransform: (item: SlashMenuItem) => SlashMenuItem = item => item;
override connectedCallback() {
super.connectedCallback();
// this.handleEvent('beforeInput', this._onBeforeInput);
this.handleEvent('keyDown', this._onKeyDown);
this.handleEvent('compositionEnd', this._onCompositionEnd);
}
}
@@ -0,0 +1,18 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src"],
"references": [
{ "path": "../../components" },
{ "path": "../../rich-text" },
{ "path": "../../shared" },
{ "path": "../../../framework/block-std" },
{ "path": "../../../framework/global" },
{ "path": "../../../framework/inline" },
{ "path": "../../../framework/store" }
]
}
@@ -0,0 +1,49 @@
{
"name": "@blocksuite/affine-widget-toolbar",
"description": "Affine toolbar widget.",
"type": "module",
"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": "MIT",
"dependencies": {
"@blocksuite/affine-block-database": "workspace:*",
"@blocksuite/affine-block-table": "workspace:*",
"@blocksuite/affine-components": "workspace:*",
"@blocksuite/affine-model": "workspace:*",
"@blocksuite/affine-shared": "workspace:*",
"@blocksuite/block-std": "workspace:*",
"@blocksuite/global": "workspace:*",
"@blocksuite/icons": "^2.2.1",
"@floating-ui/dom": "^1.6.13",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.12",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.21"
},
"exports": {
".": "./src/index.ts",
"./effects": "./src/effects.ts"
},
"files": [
"src",
"dist",
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.19.0",
"devDependencies": {
"@types/lodash.groupby": "^4",
"@types/lodash.mergewith": "^4",
"@types/lodash.orderby": "^4",
"@types/lodash.partition": "^4",
"@types/lodash.topairs": "^4"
}
}
@@ -0,0 +1,11 @@
import { AFFINE_TOOLBAR_WIDGET, AffineToolbarWidget } from './toolbar';
export function effects() {
customElements.define(AFFINE_TOOLBAR_WIDGET, AffineToolbarWidget);
}
declare global {
interface HTMLElementTagNameMap {
[AFFINE_TOOLBAR_WIDGET]: AffineToolbarWidget;
}
}
@@ -0,0 +1,12 @@
import { WidgetViewExtension } from '@blocksuite/block-std';
import { literal, unsafeStatic } from 'lit/static-html.js';
import { AFFINE_TOOLBAR_WIDGET } from './toolbar';
export * from './toolbar';
export const toolbarWidget = WidgetViewExtension(
'affine:page',
AFFINE_TOOLBAR_WIDGET,
literal`${unsafeStatic(AFFINE_TOOLBAR_WIDGET)}`
);
@@ -0,0 +1,451 @@
import { DatabaseSelection } from '@blocksuite/affine-block-database';
import { TableSelection } from '@blocksuite/affine-block-table';
import { EditorToolbar } from '@blocksuite/affine-components/toolbar';
import {
CodeBlockModel,
ImageBlockModel,
ListBlockModel,
ParagraphBlockModel,
} from '@blocksuite/affine-model';
import {
getBlockSelectionsCommand,
getSelectedBlocksCommand,
} from '@blocksuite/affine-shared/commands';
import {
ToolbarContext,
ToolbarFlag as Flag,
ToolbarRegistryIdentifier,
} from '@blocksuite/affine-shared/services';
import { matchModels } from '@blocksuite/affine-shared/utils';
import {
BlockSelection,
SurfaceSelection,
TextSelection,
WidgetComponent,
} from '@blocksuite/block-std';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import { Bound, getCommonBound } from '@blocksuite/global/gfx';
import { nextTick } from '@blocksuite/global/utils';
import type { Placement, ReferenceElement } from '@floating-ui/dom';
import { batch, effect, signal } from '@preact/signals-core';
import { css } from 'lit';
import throttle from 'lodash-es/throttle';
import { autoUpdatePosition, renderToolbar } from './utils';
export const AFFINE_TOOLBAR_WIDGET = 'affine-toolbar-widget';
export class AffineToolbarWidget extends WidgetComponent {
static override styles = css`
editor-toolbar {
position: absolute;
top: 0;
left: 0;
opacity: 0;
display: none;
width: max-content;
backface-visibility: hidden;
z-index: var(--affine-z-index-popover);
will-change: opacity, transform;
transition-property: opacity, overlay, display;
transition-duration: 120ms;
transition-timing-function: ease-out;
transition-behavior: allow-discrete;
}
editor-toolbar[data-open] {
display: flex;
opacity: 1;
transition-timing-function: ease-in;
@starting-style {
opacity: 0;
}
}
`;
range$ = signal<Range | null>(null);
flavour$ = signal('affine:note');
toolbar = new EditorToolbar();
get toolbarRegistry() {
return this.std.get(ToolbarRegistryIdentifier);
}
override connectedCallback() {
super.connectedCallback();
const {
flavour$,
range$,
disposables,
toolbar,
toolbarRegistry,
host,
std,
} = this;
const { flags, message$ } = toolbarRegistry;
const context = new ToolbarContext(std);
// TODO(@fundon): fix toolbar position shaking when the wheel scrolls
// document.body.append(toolbar);
this.shadowRoot!.append(toolbar);
// Formatting
// Selects text in note.
disposables.add(
std.selection.find$(TextSelection).subscribe(result => {
const activated =
context.activated &&
Boolean(
result &&
!result.isCollapsed() &&
result.from.length + (result.to?.length ?? 0)
);
batch(() => {
flags.toggle(Flag.Text, activated);
if (!activated) return;
const range = std.range.value ?? null;
range$.value = activated ? range : null;
flags.refresh(Flag.Text);
});
})
);
// Formatting
// Selects `native` text in database's cell or in table.
disposables.addFromEvent(document, 'selectionchange', () => {
const range = std.range.value ?? null;
let activated = context.activated && Boolean(range && !range.collapsed);
if (activated) {
const result = std.selection.find(DatabaseSelection);
const viewSelection = result?.viewSelection;
activated = Boolean(
viewSelection &&
((viewSelection.selectionType === 'area' &&
viewSelection.isEditing) ||
(viewSelection.selectionType === 'cell' &&
viewSelection.isEditing))
);
if (!activated) {
const result = std.selection.find(TableSelection);
const viewSelection = result?.data;
activated = Boolean(viewSelection && viewSelection.type === 'area');
}
}
batch(() => {
flags.toggle(Flag.Native, activated);
if (!activated) return;
range$.value = activated ? range : null;
flavour$.value = 'affine:note';
flags.refresh(Flag.Native);
});
});
// Selects blocks in note.
disposables.add(
std.selection.filter$(BlockSelection).subscribe(result => {
const count = result.length;
let flavour = 'affine:note';
let activated = context.activated && Boolean(count);
if (activated) {
// Handles a signal block.
const block = count === 1 && std.store.getBlock(result[0].blockId);
// Chencks if block's config exists.
if (block) {
const modelFlavour = block.model.flavour;
const existed =
toolbarRegistry.modules.has(modelFlavour) ||
toolbarRegistry.modules.has(`custom:${modelFlavour}`);
if (existed) {
flavour = modelFlavour;
} else {
activated = matchModels(block.model, [
ParagraphBlockModel,
ListBlockModel,
CodeBlockModel,
ImageBlockModel,
]);
}
}
}
batch(() => {
flavour$.value = flavour;
flags.toggle(Flag.Block, activated);
if (!activated) return;
flags.refresh(Flag.Block);
});
})
);
// Selects elements in edgeless.
// Triggered only when not in editing state.
disposables.add(
std.selection.filter$(SurfaceSelection).subscribe(result => {
const activated =
context.activated &&
Boolean(result.length) &&
!result.some(e => e.editing);
flags.toggle(Flag.Surface, activated);
})
);
disposables.add(
std.selection.slots.changed.on(selections => {
if (!context.activated) return;
const value = flags.value$.peek();
if (flags.contains(Flag.Hovering | Flag.Hiding, value)) return;
if (!flags.check(Flag.Text, value)) return;
const hasTextSelection =
selections.filter(s => s.is(TextSelection)).length > 0;
if (!hasTextSelection) return;
const range = std.range.value ?? null;
range$.value = range && !range.collapsed ? range : null;
// TODO(@fundon): maybe here can be further optimized
// 1. Prevents flickering effects.
// 2. We cannot use `host.getUpdateComplete()` here
// because it would cause excessive DOM queries, leading to UI jamming.
nextTick()
.then(() => flags.refresh(Flag.Text))
.catch(console.error);
})
);
// TODO(@fundon): improve these cases
// When switch the view mode, wait until the view is created
// `card view` or `embed view`
disposables.add(
std.view.viewUpdated
.filter(view => view.type === 'block')
.on(record => {
if (
flags.isBlock() &&
std.selection
.filter$(BlockSelection)
.peek()
.find(s => s.blockId === record.id)
) {
if (record.method === 'add') {
flags.refresh(Flag.Block);
}
return;
}
})
);
disposables.add(
std.store.slots.blockUpdated.on(record => {
if (
flags.isBlock() &&
record.type === 'update' &&
record.props.key === 'text'
) {
flags.refresh(Flag.Block);
return;
}
})
);
// Handles `drag and drop`
const dragStart = () => flags.toggle(Flag.Hiding, true);
const dragEnd = () => flags.toggle(Flag.Hiding, false);
const eventOptions = { passive: false };
this.handleEvent('dragStart', dragStart);
this.handleEvent('dragEnd', dragEnd);
this.handleEvent('nativeDrop', dragEnd);
disposables.addFromEvent(host, 'dragenter', dragStart, eventOptions);
disposables.addFromEvent(
host,
'dragleave',
throttle(
event => {
const { x, y, target } = event;
if (target === this) return;
const rect = host.getBoundingClientRect();
if (
x >= rect.left &&
y >= rect.top &&
x <= rect.bottom &&
y <= rect.right
)
return;
dragEnd();
},
144,
{ trailing: true }
),
eventOptions
);
// Handles hover elements
disposables.add(
toolbarRegistry.message$.subscribe(data => {
if (
!context.activated ||
flags.contains(Flag.Text | Flag.Native | Flag.Block)
) {
flags.toggle(Flag.Hovering, false);
return;
}
const activated = !!data;
batch(() => {
flags.toggle(Flag.Hovering, activated);
if (!activated) return;
const { flavour, setFloating } = data;
setFloating(toolbar);
flavour$.value = flavour;
flags.refresh(Flag.Hovering);
});
})
);
// Should update position of notes' toolbar in edgeless
disposables.add(
this.std.get(GfxControllerIdentifier).viewport.viewportUpdated.on(() => {
if (!context.activated) return;
if (flags.value === Flag.None || flags.check(Flag.Hiding)) {
return;
}
if (flags.isText()) {
flags.refresh(Flag.Text);
return;
}
if (flags.isNative()) {
flags.refresh(Flag.Native);
return;
}
if (flags.isBlock()) {
flags.refresh(Flag.Block);
return;
}
})
);
disposables.add(
flags.value$.subscribe(value => {
// Hides toolbar
if (value === Flag.None || flags.check(Flag.Hiding, value)) {
delete toolbar.dataset.open;
return;
}
// Shows toolbar
// 1. `Flag.Text`: formatting in note
// 2. `Flag.Native`: formating in database
// 3. `Flag.Block`: blocks in note
// 4. `Flag.Hovering`: inline links in note/database
if (
flags.contains(
Flag.Hovering | Flag.Text | Flag.Native | Flag.Block,
value
)
) {
renderToolbar(toolbar, context, flavour$.peek());
toolbar.dataset.open = 'true';
return;
}
// Shows toolbar in edgeles
// TODO(@fundon): handles edgeless toolbar
})
);
disposables.add(
effect(() => {
const value = flags.value$.value;
const flavour = flavour$.value;
if (!context.activated || flags.contains(Flag.Hiding, value)) return;
if (
!flags.contains(
Flag.Hovering | Flag.Text | Flag.Native | Flag.Block,
value
)
)
return;
// TODO(@fundon): improves here
const isNote = flavour === 'affine:note';
let placement = isNote ? ('top' as Placement) : undefined;
let virtualEl: ReferenceElement | null = null;
if (flags.check(Flag.Hovering, value)) {
const message = message$.value;
if (!message) return;
const { element } = message;
virtualEl = element;
placement = 'top';
} else if (flags.check(Flag.Block, value)) {
const [ok, { selectedBlocks }] = context.chain
.pipe(getBlockSelectionsCommand)
.pipe(getSelectedBlocksCommand, { types: ['block'] })
.run();
if (!ok || !selectedBlocks?.length) return;
virtualEl = {
getBoundingClientRect: () => {
const rects = selectedBlocks.map(e => e.getBoundingClientRect());
const bounds = getCommonBound(rects.map(Bound.fromDOMRect));
if (!bounds) return rects[0];
return new DOMRect(bounds.x, bounds.y, bounds.w, bounds.h);
},
getClientRects: () =>
selectedBlocks.map(e => e.getBoundingClientRect()),
};
} else {
const range = range$.value;
if (!range) return;
virtualEl = {
getBoundingClientRect: () => range.getBoundingClientRect(),
getClientRects: () =>
Array.from(range.getClientRects()).filter(rect =>
Math.round(rect.width)
),
};
}
if (!virtualEl) return;
return autoUpdatePosition(virtualEl, toolbar, placement);
})
);
}
}
@@ -0,0 +1,315 @@
import {
type EditorToolbar,
renderToolbarSeparator,
} from '@blocksuite/affine-components/toolbar';
import {
ActionPlacement,
type ToolbarAction,
type ToolbarActions,
type ToolbarContext,
type ToolbarModuleConfig,
} from '@blocksuite/affine-shared/services';
import { BlockSelection } from '@blocksuite/block-std';
import { nextTick } from '@blocksuite/global/utils';
import { MoreVerticalIcon } from '@blocksuite/icons/lit';
import type {
AutoUpdateOptions,
Placement,
ReferenceElement,
} from '@floating-ui/dom';
import {
autoUpdate,
computePosition,
flip,
hide,
inline,
limitShift,
offset,
shift,
} from '@floating-ui/dom';
import { html, render, type TemplateResult } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.js';
import { join } from 'lit/directives/join.js';
import { keyed } from 'lit/directives/keyed.js';
import { repeat } from 'lit/directives/repeat.js';
import groupBy from 'lodash-es/groupBy';
import mergeWith from 'lodash-es/mergeWith';
import orderBy from 'lodash-es/orderBy';
import partition from 'lodash-es/partition';
import toPairs from 'lodash-es/toPairs';
export function autoUpdatePosition(
referenceElement: ReferenceElement,
toolbar: EditorToolbar,
placement: Placement = 'top-start',
options: AutoUpdateOptions = { elementResize: false, animationFrame: true }
) {
const abortController = new AbortController();
const signal = abortController.signal;
const update = async () => {
await Promise.race([
new Promise(resolve => {
const listener = () => resolve(signal.reason);
signal.addEventListener('abort', listener, { once: true });
if (signal.aborted) return;
signal.removeEventListener('abort', listener);
resolve(null);
}),
toolbar.updateComplete.then(nextTick),
]);
if (signal.aborted) return;
const { x, y } = await computePosition(referenceElement, toolbar, {
placement,
middleware: [
offset(10),
inline(),
shift(state => ({
padding: {
top: 10,
right: 10,
bottom: 150,
left: 10,
},
crossAxis: state.placement.includes('bottom'),
limiter: limitShift(),
})),
flip({ padding: 10 }),
hide(),
],
});
toolbar.style.transform = `translate3d(${x}px, ${y}px, 0)`;
};
const cleanup = autoUpdate(
referenceElement,
toolbar,
() => {
update().catch(console.error);
},
options
);
return () => {
cleanup();
if (signal.aborted) return;
abortController.abort();
};
}
function group(actions: ToolbarAction[]) {
const grouped = groupBy(actions, a => a.id);
const paired = toPairs(grouped).map(([_, items]) => {
if (items.length === 1) return items[0];
const [first, ...others] = items;
if (others.length === 1) return merge({ ...first }, others[0]);
return others.reduce(merge, { ...first });
});
return paired;
}
export function combine(actions: ToolbarActions, context: ToolbarContext) {
const grouped = group(actions);
const generated = grouped.map(action => {
if ('generate' in action && action.generate) {
// TODO(@fundon): should delete `generate` fn
return {
...action,
...action.generate(context),
};
}
return action;
});
const filtered = generated.filter(action => {
if (typeof action.when === 'function') return action.when(context);
return action.when ?? true;
});
return filtered;
}
const merge = (a: any, b: any) =>
mergeWith(a, b, (obj, src) =>
Array.isArray(obj) ? group(obj.concat(src)) : src
);
/**
* Renders toolbar
*
* Merges the following configs:
* 1. `affine:note`
* 2. `custom:affine:note`
* 3. `affine:*`
* 4. `custom:affine:*`
*/
export function renderToolbar(
toolbar: EditorToolbar,
context: ToolbarContext,
flavour: string
) {
const toolbarRegistry = context.toolbarRegistry;
const module = toolbarRegistry.modules.get(flavour);
if (!module) return;
const customModule = toolbarRegistry.modules.get(`custom:${flavour}`);
const customWildcardModule = toolbarRegistry.modules.get(`custom:affine:*`);
const config = module.config satisfies ToolbarModuleConfig;
const customConfig = (customModule?.config ?? {
actions: [],
}) satisfies ToolbarModuleConfig;
const customWildcardConfig = (customWildcardModule?.config ?? {
actions: [],
}) satisfies ToolbarModuleConfig;
const combined = combine(
[
...config.actions,
...customConfig.actions,
...customWildcardConfig.actions,
],
context
);
const ordered = orderBy(
combined,
['placement', 'id', 'score'],
['asc', 'asc', 'asc']
);
const [moreActionGroup, primaryActionGroup] = partition(
ordered,
a => a.placement === ActionPlacement.More
);
if (moreActionGroup.length) {
const moreMenuItems = renderActions(
moreActionGroup,
context,
renderMenuActionItem
);
if (moreMenuItems.length) {
// TODO(@fundon): edgeless case needs to be considered
const key = `${flavour}:${context.getCurrentModelBy(BlockSelection)?.id}`;
primaryActionGroup.push({
id: 'more',
content: html`${keyed(
key,
html`
<editor-menu-button
class="more-menu"
.contentPadding="${'8px'}"
.button=${html`
<editor-icon-button aria-label="More" .tooltip="${'More'}">
${MoreVerticalIcon()}
</editor-icon-button>
`}
>
<div data-size="large" data-orientation="vertical">
${join(moreMenuItems, () =>
renderToolbarSeparator('horizontal')
)}
</div>
</editor-menu-button>
`
)}`,
});
}
}
render(
join(renderActions(primaryActionGroup, context), () =>
renderToolbarSeparator()
),
toolbar
);
}
function renderActions(
actions: ToolbarActions,
context: ToolbarContext,
render = renderActionItem
) {
return actions
.map(action => {
let content: TemplateResult | null = null;
if ('content' in action && action.content) {
if (typeof action.content === 'function') {
content = action.content(context);
} else {
content = action.content;
}
return content;
}
if ('actions' in action && action.actions.length) {
const combined = combine(action.actions, context);
if (!combined.length) return content;
const ordered = orderBy(combined, ['score', 'id'], ['asc', 'asc']);
return repeat(
ordered,
b => b.id,
b => render(b, context)
);
}
if ('run' in action && action.run) {
return render(action, context);
}
return content;
})
.filter(action => action !== null);
}
// TODO(@fundon): supports templates
function renderActionItem(action: ToolbarAction, context: ToolbarContext) {
const ids = action.id.split('.');
const id = ids[ids.length - 1];
return html`
<editor-icon-button
data-testid=${ifDefined(id)}
aria-label=${ifDefined(action.label ?? action.tooltip ?? id)}
?active=${typeof action.active === 'function'
? action.active(context)
: action.active}
.tooltip=${action.tooltip}
@click=${() => action.run?.(context)}
>
${action.icon}
${action.label ? html`<span class="label">${action.label}</span>` : null}
</editor-icon-button>
`;
}
function renderMenuActionItem(action: ToolbarAction, context: ToolbarContext) {
const ids = action.id.split('.');
const id = ids[ids.length - 1];
return html`
<editor-menu-action
data-testid=${ifDefined(id)}
aria-label=${ifDefined(action.label ?? action.tooltip ?? id)}
class="${ifDefined(
action.variant === 'destructive' ? 'delete' : undefined
)}"
?active=${typeof action.active === 'function'
? action.active(context)
: action.active}
.tooltip=${ifDefined(action.tooltip)}
@click=${() => action.run?.(context)}
>
${action.icon}
${action.label ? html`<span class="label">${action.label}</span>` : null}
</editor-menu-action>
`;
}
@@ -0,0 +1,18 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src"],
"references": [
{ "path": "../../blocks/block-database" },
{ "path": "../../blocks/block-table" },
{ "path": "../../components" },
{ "path": "../../model" },
{ "path": "../../shared" },
{ "path": "../../../framework/block-std" },
{ "path": "../../../framework/global" }
]
}